diff --git a/.gitignore b/.gitignore index 8e070ee..5921b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist/ .vite/ *.tsbuildinfo PROMPTS.md + +# Debug scripts (rendered html / scratch) +scripts/debug-*.mjs +scripts/last-render.html diff --git a/README.md b/README.md index 65b8688..80014d3 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,108 @@ -# Ifc-Calc +# Open Calculations Studio -Lightweight CalcPAD alternative — een TypeScript-gebaseerde rekenmodule voor constructieve berekeningen. Werkt als standalone web-app én als integreerbare library voor FEM-applicaties (normtoetsingen). +Open desktop-applicatie voor constructieve berekeningen op basis van CalcPAD-syntax, met directe export naar IFC (4x3 STEP) en IFCX (JSON-LD). Werkt zowel als standalone Tauri-app als embedded library voor FEM-applicaties en normtoetsingen. + +> Voorheen bekend als **Ifc-Calc** — herbouwd onder de OpenAEC Foundation als opvolger van CalcPAD met open file-formats en moderne web-tech. ## Features -- **Formules met eenheden** — mm, kN, N/mm², mm⁴ etc. via mathjs -- **Automatische unit-simplificatie** — `b*h` geeft direct `mm²`, geen `to` nodig -- **Wiskundige opmaak** — KaTeX rendering met echte breuken, superscripts, Griekse letters -- **Formule-keten** — toont `naam = formule = ingevulde waarden = resultaat` -- **SVG tekeningen** — parametrische tekeningen met `{{variabele}}` interpolatie -- **Conditionals** — `#if` / `#else` / `#end if` voor toetsingen -- **Live preview** — split-pane editor met CodeMirror en debounced preview +### Rekenen +- **Formules met eenheden** — `mm`, `kN`, `N/mm²`, `mm⁴`, … via mathjs unit-aware arithmetic +- **Automatische unit-simplificatie** — `b*h` geeft direct `mm²`, geen handmatige `to`-cast nodig +- **Wiskundige opmaak** — KaTeX-rendering met echte breuken, superscripts, subscripts en Griekse letters +- **Formule-keten** — toont `naam = formule = ingevulde waarden = resultaat` op één regel +- **Live preview** — split-pane editor (CodeMirror 6) en debounced preview, syntax highlighting voor `.cpd` +- **Belastingsgevallen** — tabs onderaan voor SLS / ULS / aardbeving etc., elk met eigen prompt-waarden + +### CalcPAD-compatibiliteit +Full subset compatibility met real-world CalcPAD-bestanden. Het 1094-regel `2259-Intertek-units.cpd` regressie-bestand evalueert met 0 errors en rendert 6 SVG-tekeningen. + +- `"Title`, `'prose`, `// comment` +- `#if … #else if … #else … #end if`, `#hide / #show / #pre / #post` +- `#repeat n … #end repeat` en `#for var = lo : hi … #loop` (incl. nested) +- `#def name(p1; p2; …) … #end def` macros, `#def Name$ = literal` constants +- `#include filename.cpd` — externe macro-libraries en SVG drawings +- `$Plot{ … @ x = lo : hi }` — inline parametrische plots +- Subscript-comma's (`V_b,0`, `n_Int,support,points`), `$`-getypeerde refs, matrix `[a;b|c;d]` +- Format-specs (`:F2`), dotted/percent identifiers (`Cs.Cd`, `F_0.9G50%TotalWeight`) +- mathjs Matrix-aware `take`, `hlookup`, `hlookup_ge`, `hlookup_le`, `vlookup`, `get` + +### Tekeningen +- **Parametrische SVG-macros** — prose-line value interpolation (`'`) +- **`@svg … @end`** blocks voor handgeschreven SVG +- **`@img(file.svg)`** — externe SVG-tekeningen worden inline ge-embed (incl. ``, hatch patterns, gradients) via dezelfde include-resolver als `#include` +- **`@img(file.png|jpg|…)`** — raster afbeeldingen via `` + +### IFC export +Live IFCX (JSON-LD draft) en IFC4x3 STEP-SPF generatie vanuit de huidige calc. +- Detecteert structurele elementen via conventionele variabelenamen (`b_fdn`, `l_fdn`, `D_paal`, `M_Ed`, `R_c_d`, …) +- Altijd geldig Project → Site → Building → Storey skelet +- IFC-tab met spatial tree, STEP viewer en IFCX JSON viewer (syntax highlighting + klikbare entity-types naar BuildingSMART docs) +- Eén klik export: `.ifc` / `.ifcx` download + +### Overig +- **Belastingsgevallen** (load cases) met eigen prompt-waarden per case +- **PDF-export** via Tauri shell + headless preview +- **GEF-upload** (`@gef name`) voor sonderingsdata +- **5 themes** (light, forge, openaec, blueprint, contrast) — OpenAEC design tokens +- **`@select var "Label" … @end`** dropdown-blocks voor materiaalkeuzes etc. ## Projectstructuur ``` -Ifc-Calc/ +Open-Calculations-Studio/ ├── packages/ -│ ├── core/ # @ifc-calc/core — rekenengine (npm library) +│ ├── core/ # @ifc-calc/core — rekenengine (TS library) │ │ ├── src/ -│ │ │ ├── parser.ts # Document parser (line-based syntax) -│ │ │ ├── evaluator.ts # Expressie-evaluator (mathjs wrapper) -│ │ │ ├── latex.ts # Expressie → LaTeX converter -│ │ │ ├── renderer.ts # HTML output renderer (KaTeX) -│ │ │ ├── types.ts # Shared types -│ │ │ └── index.ts # Public API +│ │ │ ├── parser.ts # Line-based parser, macro expansion, #include +│ │ │ ├── evaluator.ts # mathjs evaluator + CalcPAD helpers +│ │ │ ├── ifc-generator.ts # IFCX / IFC4x3 STEP generators +│ │ │ ├── latex.ts # Expr → LaTeX converter +│ │ │ ├── renderer.ts # HTML renderer (KaTeX, SVG coalescing) +│ │ │ ├── gef-parser.ts # Sondering (GEF) parser +│ │ │ ├── types.ts # Public types +│ │ │ └── index.ts # Public API │ │ └── package.json -│ └── web/ # @ifc-calc/web — browser app -│ ├── src/ -│ │ ├── main.ts # App entry + voorbeeld document -│ │ ├── editor.ts # CodeMirror editor -│ │ └── preview.ts # Live preview -│ ├── index.html -│ └── package.json -├── package.json # npm workspaces root -└── tsconfig.base.json +│ ├── desktop/ # @openaec/calculations-studio — Tauri app +│ │ ├── src/ # React UI (ribbon, panels, preview) +│ │ ├── src-tauri/ # Rust shell (Tauri 2) +│ │ └── src/templates/calcpad-samples/ # Vendored .cpd + .svg samples +│ └── web/ # @ifc-calc/web — browser-only build +├── docs/superpowers/ # Design docs + implementation plans +└── package.json # npm workspaces root ``` ## Document Syntax -``` -# Heading +```calcpad +"Project — woning Laageind 57 -Beschrijvende tekst. +#include svg_drawing.cpd +# 1. Invoer b = 300 mm h = 500 mm -A = b*h +N_Ed = ?*(kN) +fck = 30 N/mm^2 + +# 2. Geometrie +A = b*h // mm² — automatisch +W_y = b*h^2/6 // mm³ -sigma = F / A to N/mm^2 +# 3. Toetsing +sigma = N_Ed/A to N/mm^2 -#if sigma < f_cd - Voldoet. +#if sigma ≤ fck/1.5 + 'Voldoet ✓ #else - Voldoet NIET! + 'Voldoet NIET ✗ #end if -@svg - - - -@end +# 4. Detail-tekening +@img(detail-D1.svg) -@img(pad/naar/afbeelding.png) +# 5. Belasting-diagram +$Plot{f(x) @ x = 0 : L} ``` ## Installatie @@ -74,30 +114,74 @@ npm install ## Ontwikkeling ```bash -# Core package bouwen +# Core package bouwen (watch) npm run build --workspace=@ifc-calc/core -# Web app starten (dev server) -npm run dev --workspace=@ifc-calc/web +# Desktop (Tauri) starten +npm run tauri:dev --workspace=@openaec/calculations-studio + +# Of alleen de Vite browser-preview +npm run dev --workspace=@openaec/calculations-studio ``` ## Gebruik als library ```typescript -import { process } from '@ifc-calc/core'; - -const html = process(` +import { + parse, + evaluate, + render, + generateIfcx, + generateIfc4x3Step, +} from '@ifc-calc/core'; + +const source = ` b = 300 mm h = 500 mm A = b*h -`); -// html bevat gerenderde KaTeX formules +N_Ed = 120 kN +sigma = N_Ed/A to N/mm^2 +`; + +// Render naar HTML (KaTeX formules + SVG) +const ast = parse(source); +const evaluated = evaluate(ast, {}); +const html = render(evaluated); + +// IFC export uit dezelfde calc +const ifcx = generateIfcx(evaluated, { projectName: 'Toetsing balk B1' }); +const step = generateIfc4x3Step(evaluated, { projectName: 'Toetsing balk B1' }); +``` + +### Externe SVG / `#include` inlinen + +```typescript +import detailSvg from './detail-D1.svg?raw'; +import svgDrawing from './svg_drawing.cpd?raw'; + +const ast = parse(source, { + includes: new Map([ + ['detail-D1.svg', detailSvg], // wordt door @img(detail-D1.svg) inline ge-embed + ['svg_drawing.cpd', svgDrawing], // wordt door #include svg_drawing.cpd geladen + ]), +}); ``` ## Tech Stack +- **TypeScript** — type-safe codebase - **mathjs** — expressie-parsing, eenheden, matrices - **KaTeX** — wiskundige opmaak -- **CodeMirror 6** — editor met syntax highlighting -- **Vite** — bundler +- **CodeMirror 6** — editor met `.cpd` syntax highlighting + autocomplete +- **React 19** — desktop UI +- **Tauri 2** — Rust shell met file-system + dialog plugins +- **Vite** — bundler met `?raw` imports voor samples - **npm workspaces** — monorepo + +## Licentie + +Zie [`LICENSE`](LICENSE). + +## Status + +In actieve ontwikkeling onder de [OpenAEC Foundation](https://github.com/OpenAEC-Foundation). PRs en issues welkom. diff --git a/packages/core/src/evaluator.ts b/packages/core/src/evaluator.ts index e2c6692..d63158a 100644 --- a/packages/core/src/evaluator.ts +++ b/packages/core/src/evaluator.ts @@ -3,6 +3,46 @@ import type { AstNode, ConditionalNode, EvaluatedNode } from './types.js'; const math: MathJsInstance = create(all, {}); +/** + * Trigonometric input mode. Default is `rad` (mathjs native). When set to + * `deg` or `gra`, the wrappers below scale every unit-less input. Inputs + * already carrying a unit (e.g. `45 deg`) pass through unchanged because + * mathjs handles the conversion itself. + */ +export type AngleMode = 'rad' | 'deg' | 'gra'; +let currentAngleMode: AngleMode = 'rad'; + +/** Set the global angle mode used by sin/cos/tan family for unit-less input. */ +export function setAngleMode(mode: AngleMode): void { + currentAngleMode = mode; +} + +function toRadians(v: unknown): unknown { + if (typeof v !== 'number') return v; + if (currentAngleMode === 'deg') return v * Math.PI / 180; + if (currentAngleMode === 'gra') return v * Math.PI / 200; + return v; +} +function fromRadians(v: unknown): unknown { + if (typeof v !== 'number') return v; + if (currentAngleMode === 'deg') return v * 180 / Math.PI; + if (currentAngleMode === 'gra') return v * 200 / Math.PI; + return v; +} + +// Wrap trig functions so plain numbers honour the active angle mode. mathjs +// units (45 deg, π rad) bypass the wrapper. +const nativeSin = math.sin, nativeCos = math.cos, nativeTan = math.tan; +const nativeAsin = math.asin, nativeAcos = math.acos, nativeAtan = math.atan; +math.import({ + sin: (v: unknown) => nativeSin(toRadians(v) as number), + cos: (v: unknown) => nativeCos(toRadians(v) as number), + tan: (v: unknown) => nativeTan(toRadians(v) as number), + asin: (v: unknown) => fromRadians(nativeAsin(v as number)), + acos: (v: unknown) => fromRadians(nativeAcos(v as number)), + atan: (v: unknown) => fromRadians(nativeAtan(v as number)), +}, { override: true }); + // CalcPAD-style helper functions that mathjs doesn't ship with. // mathjs returns Matrix objects from `[…]` literals — Array.isArray(M) is @@ -16,6 +56,59 @@ function toArrayLike(v: unknown): unknown[] | null { return null; } +/** Numeric value, unit-aware (extracts SI value from mathjs Unit). */ +function asNumber(v: unknown): number { + if (typeof v === 'number') return v; + if (v && typeof (v as { toNumber?: (u?: string) => number }).toNumber === 'function') { + try { return (v as { toNumber: () => number }).toNumber(); } catch { /* fall through */ } + } + if (v && typeof v === 'object' && 'value' in v && typeof (v as { value: unknown }).value === 'number') { + return (v as { value: number }).value; + } + return Number(v); +} + +/** + * CalcPAD `hlookup` family, executed against a 2D matrix. + * + * hlookup(M; search; lookupCol; returnCol) — exact match + * hlookup_ge(M; search; lookupCol; returnCol) — smallest M[i][lookupCol-1] ≥ search + * hlookup_le(M; search; lookupCol; returnCol) — largest M[i][lookupCol-1] ≤ search + * + * `returnCol` defaults to `lookupCol` (returns the matched value itself). + * When `M` is a flat vector (1D), the lookup operates on it directly. + */ +function lookupHelper(args: unknown[], mode: 'eq' | 'ge' | 'le'): unknown { + if (args.length === 0) return 0; + const matrix = toArrayLike(args[0]); + if (!matrix) return 0; + const target = asNumber(args[1] ?? 0); + const lookupCol = Math.max(1, Math.trunc(asNumber(args[2] ?? 1))); + const returnCol = Math.max(1, Math.trunc(asNumber(args[3] ?? lookupCol))); + + // Detect 1D vs 2D + const first = matrix[0]; + const firstRow = toArrayLike(first); + const is2D = firstRow !== null; + + let best: unknown = null; + let bestDelta = Infinity; + for (const row of matrix) { + const r = is2D ? toArrayLike(row) : null; + const key = is2D ? asNumber(r?.[lookupCol - 1]) : asNumber(row); + if (!Number.isFinite(key)) continue; + if (mode === 'eq' && key !== target) continue; + if (mode === 'ge' && key < target) continue; + if (mode === 'le' && key > target) continue; + const delta = Math.abs(key - target); + if (delta < bestDelta) { + bestDelta = delta; + best = is2D ? r?.[returnCol - 1] : row; + } + } + return best ?? 0; +} + math.import( { // Inline ternary: `if(cond, t, f)` @@ -24,9 +117,11 @@ math.import( }, // 1-based vector index: `take(2, [10, 20, 30])` → 20 // Also tolerates 1-arg form `take(vec)` → first element. + // + // CalcPAD chains like `take(1; hlookup_ge(M; h; 1; 1))` may pass a scalar + // as the "vector" (when hlookup_* returns a single matched value); in + // that case treat the scalar as a 1-element vector and return it. take: function (...args: unknown[]) { - // CalcPAD syntax is `take(idx, vec)`; after `;` → `,` normalization - // the second arg is the vector. mathjs may pass either order; accept both. if (args.length === 1) { const v = toArrayLike(args[0]); return v ? v[0] : args[0]; @@ -34,7 +129,11 @@ math.import( const [a, b] = args; const idx = typeof a === 'number' ? a : typeof b === 'number' ? b : NaN; const vec = toArrayLike(a) ?? toArrayLike(b); - if (!vec) return Number.NaN; + if (!vec) { + // No array on either side — treat the non-index arg as the scalar + // "vector" and return it as-is (idx is effectively 1). + return typeof a === 'number' ? b : a; + } const i = Math.max(1, Math.trunc(idx)) - 1; return vec[Math.min(i, vec.length - 1)]; }, @@ -48,48 +147,10 @@ math.import( // Stubbed: search for the closest match in the first array argument and // return the matching value (or 0 on miss). Far from a faithful Excel // impl but prevents whole-conditional cascades from failing. - hlookup: function (...args: unknown[]) { - const arr = args.map(toArrayLike).find((a) => a !== null); - const target = args.find((a) => typeof a === 'number' || (a && typeof (a as object).valueOf === 'function')) ?? 0; - if (!arr) return 0; - const tNum = Number(target); - let bestIdx = 0; - let bestDelta = Infinity; - for (let i = 0; i < arr.length; i++) { - const v = Number(arr[i]); - const d = Math.abs(v - tNum); - if (d < bestDelta) { bestDelta = d; bestIdx = i; } - } - return arr[bestIdx]; - }, - vlookup: function (...args: unknown[]) { - return (math as unknown as { hlookup: (...a: unknown[]) => unknown }).hlookup(...args); - }, - // Variants of hlookup — closest-greater-or-equal, closest-less-or-equal - hlookup_ge: function (...args: unknown[]) { - const arr = args.map(toArrayLike).find((a) => a !== null); - const target = Number(args.find((a) => typeof a === 'number') ?? 0); - if (!arr || arr.length === 0) return 0; - let candidate: unknown = arr[0]; - let bestDelta = Infinity; - for (const v of arr) { - const n = Number(v); - if (n >= target && n - target < bestDelta) { bestDelta = n - target; candidate = v; } - } - return candidate; - }, - hlookup_le: function (...args: unknown[]) { - const arr = args.map(toArrayLike).find((a) => a !== null); - const target = Number(args.find((a) => typeof a === 'number') ?? 0); - if (!arr || arr.length === 0) return 0; - let candidate: unknown = arr[arr.length - 1]; - let bestDelta = Infinity; - for (const v of arr) { - const n = Number(v); - if (n <= target && target - n < bestDelta) { bestDelta = target - n; candidate = v; } - } - return candidate; - }, + hlookup: function (...args: unknown[]) { return lookupHelper(args, 'eq'); }, + vlookup: function (...args: unknown[]) { return lookupHelper(args, 'eq'); }, + hlookup_ge: function (...args: unknown[]) { return lookupHelper(args, 'ge'); }, + hlookup_le: function (...args: unknown[]) { return lookupHelper(args, 'le'); }, n_rows: function (v: unknown) { const a = toArrayLike(v); return a ? a.length : 1; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5f6582..e3b1387 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { parse, type ParseOptions } from './parser.js'; -export { evaluate } from './evaluator.js'; -export type { SelectValues } from './evaluator.js'; +export { evaluate, setAngleMode } from './evaluator.js'; +export type { SelectValues, AngleMode } from './evaluator.js'; export { render, defaultStyles } from './renderer.js'; export { exprToLatex, nameToLatex } from './latex.js'; export { parseGef, type GefData } from './gef-parser.js'; diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index 11219c4..fed19a5 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -80,8 +80,9 @@ const POST_RE = /^#post\b/; const ANGLE_MODE_RE = /^#(rad|deg|gra|equ)\b/; // CalcPAD `#def Name$ = "string"` constant definitions, `#include filename`, // `#novar` (hide variable substitution), `#varsub` (variable substitution -// control). Silently ignored — they don't change calculation values. -const NO_OP_DIRECTIVE_RE = /^#(def|include|novar|varsub)\b/; +// control), `#val` (force value-only substitution — no symbolic expansion). +// All silently ignored — they don't change calculation values. +const NO_OP_DIRECTIVE_RE = /^#(def|include|novar|varsub|val|noc)\b/; const REPEAT_RE = /^#repeat\s+(.+)$/; const ENDREPEAT_RE = /^#end\s+repeat\b/; // CalcPAD `#for var = lo : hi … #loop` (#break supported as early exit) @@ -470,6 +471,28 @@ function inlineSvgImages(source: string, includes: ReadonlyMap): export interface ParseOptions { /** Map of filename → contents for `#include filename` resolution. */ includes?: ReadonlyMap; + /** + * Map of filename → URL for rewriting `` references inside + * prose. Lookup is by basename (case-insensitive), so the source can write + * `src="./Images/Picture0.png"` and we'll match the `"Picture0.png"` key. + */ + imageUrls?: ReadonlyMap; +} + +/** + * Rewrite `` and `src='path'` attributes in prose so the + * basename matches a known asset URL (Vite-bundled, base64 data URL, etc.). + * Anything not in the map is left untouched. + */ +function rewriteImageSrc(source: string, imageUrls: ReadonlyMap): string { + // Build a case-insensitive basename → URL map. + const lookup = new Map(); + for (const [k, v] of imageUrls) lookup.set(k.toLowerCase(), v); + return source.replace(/src\s*=\s*(["'])([^"']+)\1/g, (full, quote: string, path: string) => { + const base = path.split(/[\\/]/).pop()?.toLowerCase() ?? ""; + const url = lookup.get(base); + return url ? `src=${quote}${url}${quote}` : full; + }); } // ── Entry point ──────────────────────────────────────────────────────── @@ -478,6 +501,9 @@ export function parse(source: string, options: ParseOptions = {}): AstNode[] { source = inlineIncludes(source, options.includes); source = inlineSvgImages(source, options.includes); } + if (options.imageUrls && options.imageUrls.size > 0) { + source = rewriteImageSrc(source, options.imageUrls); + } // Macro expansion runs BEFORE \$-stripping so we still see param names like // `x1\$` and substitute them across the body. After expansion we strip \$ // from any remaining identifiers. diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 6acbf7f..85c9cc9 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -132,7 +132,11 @@ function renderUserFunction(node: { name: string; params: string[]; expression: function renderVarDisplay(node: { name: string; result: string; unit: string }): string { const nameLatex = nameToLatex(node.name); - const valueLatex = resultToLatex(node.result, node.unit); + // node.result already includes the unit ("2.91 m"); pass num + unit to + // resultToLatex separately so we don't double-print the unit suffix. + const { numStr, unitStr } = splitResult(node.result); + const effectiveUnit = node.unit || unitStr; + const valueLatex = resultToLatex(numStr, effectiveUnit); const latex = `${nameLatex} = ${valueLatex}`; return `
${renderLatex(latex, true)}
`; } diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 8f66374..afebd63 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import TitleBar from "./components/TitleBar"; import Ribbon from "./components/ribbon/Ribbon"; import DocumentBar from "./components/DocumentBar"; @@ -11,6 +11,11 @@ import SplitPane from "./components/calc/SplitPane"; import ProjectBrowser from "./components/calc/ProjectBrowser"; import IfcViewerPanel from "./components/calc/IfcViewerPanel"; import { getSetting } from "./store"; +import { useDocumentStore } from "./store/documentStore"; +import { useRecentFiles } from "./hooks/useRecentFiles"; +import { openCalculationFile, unwrapFromIfcCalculation } from "./tauri/fileOps"; +import { setAngleMode, type AngleMode } from "@ifc-calc/core"; +import { UNITS_DEFAULTS, type UnitsSettings } from "./components/settings/SettingsDialog"; export default function App() { const [settingsOpen, setSettingsOpen] = useState(false); @@ -23,6 +28,14 @@ export default function App() { setTheme(saved); applyTheme(saved); }); + getSetting("units", UNITS_DEFAULTS).then((u) => { + setAngleMode(u.angleMode as AngleMode); + }); + const onUnits = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail) setAngleMode(detail.angleMode as AngleMode); + }; + window.addEventListener("units-changed", onUnits); // Show window once theme is applied (avoids flash of unstyled chrome) import("@tauri-apps/api/window") .then(({ getCurrentWindow }) => { @@ -31,6 +44,7 @@ export default function App() { .catch(() => { // Browser fallback (npm run dev without Tauri) }); + return () => window.removeEventListener("units-changed", onUnits); }, []); const handleThemeChange = (newTheme: string) => { @@ -38,6 +52,47 @@ export default function App() { applyTheme(newTheme); }; + const loadTemplate = useDocumentStore((s) => s.loadTemplate); + const markSaved = useDocumentStore((s) => s.markSaved); + const { addRecentFile } = useRecentFiles(); + + const handleBrowse = useCallback(async () => { + try { + const file = await openCalculationFile(); + if (!file) return; + loadTemplate(file.content, file.name); + markSaved(file.path); + await addRecentFile({ + path: file.path, + name: file.name, + type: "report", + timestamp: Date.now(), + }); + } catch (err) { + alert(`Bestand openen mislukt: ${(err as Error).message}`); + } + }, [loadTemplate, markSaved, addRecentFile]); + + const handleOpenRecent = useCallback(async (path: string) => { + try { + // Only Tauri runtime can read by absolute path; browser fallback cannot. + const win = window as unknown as { __TAURI_INTERNALS__?: unknown }; + if (!win.__TAURI_INTERNALS__) { + alert("Recente bestanden openen vereist de desktop-app."); + return; + } + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + const raw = await readTextFile(path); + const content = unwrapFromIfcCalculation(raw); + const name = path.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, "") ?? path; + loadTemplate(content, name); + markSaved(path); + await addRecentFile(path); + } catch (err) { + alert(`Bestand openen mislukt: ${(err as Error).message}`); + } + }, [loadTemplate, markSaved, addRecentFile]); + return ( <> setSettingsOpen(true)} /> @@ -66,6 +121,8 @@ export default function App() { setBackstageOpen(false); setSettingsOpen(true); }} + onBrowse={handleBrowse} + onOpenFile={handleOpenRecent} /> void; onOpenSettings: () => void; + /** Open a specific (recent) file by absolute path. */ onOpenFile?: (path: string) => void; + /** Launch the OS file picker via the "Browse..." action in the Open panel. */ + onBrowse?: () => void; } -export default function Backstage({ open, onClose, onOpenSettings, onOpenFile }: BackstageProps) { +export default function Backstage({ open, onClose, onOpenSettings, onOpenFile, onBrowse }: BackstageProps) { const { t } = useTranslation("backstage"); const [activePanel, setActivePanel] = useState("none"); const { recentFiles, removeRecentFile, clearRecentFiles } = useRecentFiles(); @@ -87,7 +90,6 @@ export default function Backstage({ open, onClose, onOpenSettings, onOpenFile }: const hasActivePanel = activePanel === "open" || activePanel === "about" || - activePanel === "import" || activePanel === "export"; return ( @@ -141,12 +143,6 @@ export default function Backstage({ open, onClose, onOpenSettings, onOpenFile }: onClick={() => actionAndClose()} /> - setActivePanel("import")} - /> { onClose(); onBrowse?.(); }} onOpenFile={(path) => { onClose(); onOpenFile?.(path); }} onRemoveFile={removeRecentFile} onClearAll={clearRecentFiles} /> )} {activePanel === "about" && } - {activePanel === "import" && } {activePanel === "export" && } )} @@ -286,50 +282,15 @@ function AboutPanel() { ); } -function ImportPanel() { - const { t } = useTranslation("backstage"); - return ( -
-

{t("importPanel.title")}

-
-
-
- - - - - -
-
-

{t("importPanel.fromFile")}

-

{t("importPanel.fromFileDesc")}

-
-
-
-
- - - - - -
-
-

{t("importPanel.fromTemplate")}

-

{t("importPanel.fromTemplateDesc")}

-
-
-
-
- ); -} - function OpenPanel({ recentFiles, + onBrowse, onOpenFile, onRemoveFile, onClearAll, }: { recentFiles: RecentFile[]; + onBrowse: () => void; onOpenFile: (path: string) => void; onRemoveFile: (path: string) => void; onClearAll: () => void; @@ -365,8 +326,24 @@ function OpenPanel({ return (
-
-

{t("openPanel.title", "Recent Files")}

+ +
+

{t("openPanel.title", "Recente bestanden")}

{recentFiles.length > 0 && ( )}
diff --git a/packages/desktop/src/components/calc/Editor.css b/packages/desktop/src/components/calc/Editor.css index 3518d90..11e728c 100644 --- a/packages/desktop/src/components/calc/Editor.css +++ b/packages/desktop/src/components/calc/Editor.css @@ -23,7 +23,8 @@ max-height: 100% !important; width: auto !important; font-family: var(--font-mono); - font-size: 13px; + /* Driven by useZoom — wrapper sets --cm-font-size; falls back to 13px. */ + font-size: var(--cm-font-size, 13px); background: var(--theme-bg); color: var(--theme-text); } diff --git a/packages/desktop/src/components/calc/Editor.tsx b/packages/desktop/src/components/calc/Editor.tsx index 2f90d3a..706d5be 100644 --- a/packages/desktop/src/components/calc/Editor.tsx +++ b/packages/desktop/src/components/calc/Editor.tsx @@ -1,12 +1,16 @@ import { useCallback } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { useDocumentStore } from "../../store/documentStore"; +import { useZoom } from "../../hooks/useZoom"; import { ifcCalcLang } from "./ifcCalcLanguage"; import "./Editor.css"; +const BASE_FONT_PX = 13; + export default function Editor() { const source = useDocumentStore((s) => s.source); const setSource = useDocumentStore((s) => s.setSource); + const { ref, zoom } = useZoom(); const onChange = useCallback( (value: string) => { @@ -16,7 +20,11 @@ export default function Editor() { ); return ( -
+
0\n\t…\n#else if a == 0\n\t…\n#else\n\t…\n#end if" }, + ], + }, + { + title: "Loops", + items: [ + { label: "#repeat n … #end repeat", insert: "#repeat 10\n\t…\n#end repeat" }, + { label: "#for var = lo : hi … #loop", insert: "#for i = 1 : 9\n\t…\n#loop" }, + { label: "#break (vroegtijdig stoppen)", insert: "#break" }, + ], + }, + { + title: "Macro's en includes", + items: [ + { label: "#def name(args) … #end def", insert: "#def line$(x1$; y1$; x2$; y2$)\n\t'\n#end def" }, + { label: "#def Name$ = waarde", insert: "#def style1$ = \"stroke:black\"" }, + { label: "#include bestand.cpd", insert: "#include svg_drawing.cpd" }, + ], + }, + { + title: "Tekeningen", + items: [ + { label: "@svg … @end (handgeschreven)", insert: "@svg\n\t\n@end" }, + { label: "@img(bestand.png)", insert: "@img(detail.png)" }, + { label: "@img(bestand.svg) — inline embed", insert: "@img(detail.svg)" }, + { label: "$Plot{ f(x) @ x = lo : hi }", insert: "$Plot{f(x) @ x = -10 : 10}" }, + ], + }, + { + title: "Tekst en titels", + items: [ + { label: "\"Project-titel", insert: "\"Project Titel" }, + { label: "'Vetgedrukt", insert: "'Vetgedrukt" }, + { label: "# Hoofdstuk", insert: "# 1. Invoer" }, + { label: "// commentaar", insert: "// commentaar" }, + ], + }, + { + title: "Hoek-eenheden", + inline: [ + { label: "#deg", insert: "#deg" }, + { label: "#rad", insert: "#rad" }, + { label: "#gra", insert: "#gra" }, + ], + }, + { + title: "Veelgebruikte eenheden", + description: + "Lengte: mm · cm · m · km · Massa: g · kg · ton · Kracht: N · kN · MN · " + + "Druk: Pa · kPa · MPa · GPa · Hoek: deg · rad · grad · Tijd: s · min · h", + }, +]; + +export default function HelpPanel() { + const source = useDocumentStore((s) => s.source); + const setSource = useDocumentStore((s) => s.setSource); + // First two sections always open; rest start collapsed. + const [open, setOpen] = useState>(new Set([0, 1, 2])); + + const toggle = (i: number) => + setOpen((prev) => { + const next = new Set(prev); + next.has(i) ? next.delete(i) : next.add(i); + return next; + }); + + const insert = (snippet: string) => { + const sep = source.length === 0 || source.endsWith("\n") ? "" : "\n"; + setSource(source + sep + snippet + "\n"); + }; + + return ( +
+
+

OpenAEC Calc — taalreferentie

+

+ Klik een fragment om het in de editor in te voegen. Sluit dit paneel door iets in de editor te typen. +

+
+
+ {SECTIONS.map((sec, i) => { + const isCollapsible = !!(sec.items && sec.items.length > 0); + const isOpen = !isCollapsible || open.has(i); + return ( +
+ + {isOpen && ( +
+ {sec.description &&

{sec.description}

} + {sec.inline && ( +
+ {sec.inline.map((it, j) => ( + + ))} +
+ )} + {sec.items && ( +
    + {sec.items.map((it, j) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/desktop/src/components/calc/IfcViewerPanel.tsx b/packages/desktop/src/components/calc/IfcViewerPanel.tsx index cf5ce0e..6d31e0a 100644 --- a/packages/desktop/src/components/calc/IfcViewerPanel.tsx +++ b/packages/desktop/src/components/calc/IfcViewerPanel.tsx @@ -2,8 +2,9 @@ import { useState, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useDocumentStore } from "../../store/documentStore"; import { useLoadCaseStore } from "../../store/loadCaseStore"; -import { calcpadIncludes } from "../../templates/calcpad-includes"; +import { calcpadIncludes, calcpadImageUrls } from "../../templates/calcpad-includes"; import { parse, evaluate, generateIfcx, generateIfc4x3Step, type IfcGenerationResult, type IfcxDocument } from "@ifc-calc/core"; +import { wrapAsIfcCalculation } from "../../tauri/fileOps"; import "./IfcViewerPanel.css"; // ── BuildingSMART documentation links ───────────────────────── @@ -328,7 +329,13 @@ function IfcxViewer({ content }: { content: string }) { // ── Main panel ─────────────────────────────────────────────── -function useGeneratedIfc(): IfcGenerationResult { +/** IFCX shown in the viewer mirrors EXACTLY what Save writes to disk. */ +interface GeneratedIfc extends IfcGenerationResult { + /** Pretty-printed JSON identical to the on-disk `.ifc-calculation` payload. */ + ifcxJsonString: string; +} + +function useGeneratedIfc(): GeneratedIfc { const source = useDocumentStore((s) => s.source); const filePath = useDocumentStore((s) => s.filePath); const activeId = useLoadCaseStore((s) => s.activeId); @@ -337,24 +344,28 @@ function useGeneratedIfc(): IfcGenerationResult { return useMemo(() => { try { - const ast = parse(source, { includes: calcpadIncludes }); + const ast = parse(source, { includes: calcpadIncludes, imageUrls: calcpadImageUrls }); const ev = evaluate(ast, selectValues); const projectName = filePath?.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") ?? "Berekening"; const ifcx = generateIfcx(ev, { projectName }); const step = generateIfc4x3Step(ev, { projectName }); - return { ifcx, step }; + // Show the IFCX exactly as it will be persisted on disk — same JSON the + // saveCalculationFile() helper writes. + const ifcxJsonString = wrapAsIfcCalculation(source, ifcx); + return { ifcx, step, ifcxJsonString }; } catch (err) { + const errorDoc: IfcxDocument = { header: { name: "error.ifcx", error: (err as Error).message }, data: [] }; return { - ifcx: { header: { name: "error.ifcx", error: (err as Error).message }, data: [] }, + ifcx: errorDoc, step: `// Generation error: ${(err as Error).message}\n`, + ifcxJsonString: JSON.stringify(errorDoc, null, 2), }; } }, [source, selectValues, filePath]); } export default function IfcViewerPanel() { - const { ifcx, step } = useGeneratedIfc(); - const ifcxJson = useMemo(() => JSON.stringify(ifcx, null, 2), [ifcx]); + const { ifcx, step, ifcxJsonString } = useGeneratedIfc(); const tree = useMemo(() => buildTreeFromIfcx(ifcx), [ifcx]); return ( @@ -363,7 +374,7 @@ export default function IfcViewerPanel() {
- +
); } diff --git a/packages/desktop/src/components/calc/Preview.tsx b/packages/desktop/src/components/calc/Preview.tsx index 01ce196..6bc65fc 100644 --- a/packages/desktop/src/components/calc/Preview.tsx +++ b/packages/desktop/src/components/calc/Preview.tsx @@ -2,7 +2,9 @@ import { useEffect, useRef, useMemo } from "react"; import { process, defaultStyles } from "@ifc-calc/core"; import { useDocumentStore } from "../../store/documentStore"; import { useLoadCaseStore } from "../../store/loadCaseStore"; -import { calcpadIncludes } from "../../templates/calcpad-includes"; +import { useZoom } from "../../hooks/useZoom"; +import { calcpadIncludes, calcpadImageUrls } from "../../templates/calcpad-includes"; +import HelpPanel from "./HelpPanel"; import "katex/dist/katex.min.css"; import "./Preview.css"; @@ -33,7 +35,7 @@ export default function Preview() { const html = useMemo(() => { try { - return process(source, selectValues, { includes: calcpadIncludes }); + return process(source, selectValues, { includes: calcpadIncludes, imageUrls: calcpadImageUrls }); } catch (err) { const msg = (err as Error).message; return `

Render error: ${msg}

`; @@ -85,13 +87,24 @@ export default function Preview() { }; }, [html, selectValues, setSelectValue]); + const { ref: zoomRef, zoom } = useZoom(); + const isEmpty = source.trim().length === 0; + return ( -
-
+
+ {isEmpty ? ( + + ) : ( +
+ )}
); } diff --git a/packages/desktop/src/components/calc/ifcCalcLanguage.ts b/packages/desktop/src/components/calc/ifcCalcLanguage.ts index 014e8b7..6a860a0 100644 --- a/packages/desktop/src/components/calc/ifcCalcLanguage.ts +++ b/packages/desktop/src/components/calc/ifcCalcLanguage.ts @@ -1,8 +1,11 @@ -import { StreamLanguage, type StreamParser } from "@codemirror/language"; import { + StreamLanguage, HighlightStyle, syntaxHighlighting, + foldService, + type StreamParser, } from "@codemirror/language"; +import type { EditorState } from "@codemirror/state"; import { autocompletion, type Completion, @@ -12,6 +15,50 @@ import { import { tags as t } from "@lezer/highlight"; import type { Extension } from "@codemirror/state"; +// ── Block folding ────────────────────────────────────────────────────── +// +// Match opener regex on a line; scan forward for the corresponding closer. +// The fold range starts at the END of the opening line (so the opener +// remains visible) and stops at the START of the closing line. +type FoldRule = { open: RegExp; close: RegExp }; + +const FOLD_RULES: FoldRule[] = [ + { open: /^\s*@svg\b/, close: /^\s*@end\b/ }, + { open: /^\s*@select\b/, close: /^\s*@end\b/ }, + { open: /^\s*#if\b/, close: /^\s*#end\s+if\b/ }, + { open: /^\s*#repeat\b/, close: /^\s*#end\s+repeat\b/ }, + { open: /^\s*#for\b/, close: /^\s*#loop\b/ }, + // `#def name(...)` (multi-line; the inline `#def name = ...` form is skipped + // because the opener regex below also rejects lines containing `=`). + { open: /^\s*#def\s+[\p{L}_][\p{L}\p{N}_]*\$?\s*\([^)]*\)\s*$/u, close: /^\s*#end\s+def\b/ }, + // `#hide`/`#show` block — fold the hidden section. + { open: /^\s*#hide\b/, close: /^\s*#show\b/ }, +]; + +function ifcCalcFoldService(state: EditorState, lineStart: number, lineEnd: number) { + const line = state.doc.sliceString(lineStart, lineEnd); + const rule = FOLD_RULES.find((r) => r.open.test(line)); + if (!rule) return null; + // Scan downward for the closer, tracking nesting of the same kind. + let nest = 1; + let i = lineEnd + 1; // start of next line + while (i <= state.doc.length) { + const ln = state.doc.lineAt(i); + const text = state.doc.sliceString(ln.from, ln.to); + if (rule.open.test(text)) nest++; + else if (rule.close.test(text)) { + nest--; + if (nest === 0) { + // Fold from end of opener line to start of closer line. + return { from: lineEnd, to: ln.from }; + } + } + if (ln.to + 1 > state.doc.length) break; + i = ln.to + 1; + } + return null; +} + /** * CodeMirror 6 stream parser for the .ifc-calculation / CalcPAD syntax. * @@ -330,6 +377,7 @@ export function ifcCalcLang(): Extension { return [ ifcCalcStream, syntaxHighlighting(ifcCalcHighlight), + foldService.of(ifcCalcFoldService), autocompletion({ override: [calcpadCompletionSource], activateOnTyping: true, diff --git a/packages/desktop/src/components/ribbon/CalcTab.tsx b/packages/desktop/src/components/ribbon/CalcTab.tsx index b21ff56..e5a221a 100644 --- a/packages/desktop/src/components/ribbon/CalcTab.tsx +++ b/packages/desktop/src/components/ribbon/CalcTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import RibbonGroup from "./RibbonGroup"; import RibbonButton from "./RibbonButton"; @@ -9,21 +9,17 @@ import { saveDiskIcon, undoIcon, redoIcon, - headingIcon, - formulaIcon, - selectListIcon, imageIcon, svgShapeIcon, - pdfPreviewIcon, pdfIcon, } from "./calcIcons"; -import { parse, evaluate } from "@ifc-calc/core"; +import { parse, evaluate, generateIfcx } from "@ifc-calc/core"; import { useDocumentStore } from "../../store/documentStore"; import { useLoadCaseStore } from "../../store/loadCaseStore"; -import { previewPdfReport, savePdfReport } from "../../tauri/pdfReport"; +import { savePdfReport } from "../../tauri/pdfReport"; import { openCalculationFile, saveCalculationFile } from "../../tauri/fileOps"; -import { calcpadIncludes } from "../../templates/calcpad-includes"; -import PdfPreviewModal from "../calc/PdfPreviewModal"; +import { calcpadIncludes, calcpadImageUrls } from "../../templates/calcpad-includes"; +import { useRecentFiles } from "../../hooks/useRecentFiles"; interface CalcTabProps { onSettingsClick?: () => void; @@ -39,47 +35,56 @@ export default function CalcTab({ onSettingsClick: _onSettingsClick }: CalcTabPr const activeId = useLoadCaseStore((s) => s.activeId); const valuesByCase = useLoadCaseStore((s) => s.valuesByCase); const selectValues = valuesByCase[activeId] ?? {}; - const [previewOpen, setPreviewOpen] = useState(false); + const { addRecentFile } = useRecentFiles(); const handleOpen = useCallback(async () => { try { const file = await openCalculationFile(); if (!file) return; loadTemplate(file.content, file.name); + useDocumentStore.getState().markSaved(file.path); + await addRecentFile({ + path: file.path, + name: file.name, + type: "report", + timestamp: Date.now(), + }); } catch (err) { console.error("Open file failed:", err); alert(`Bestand openen mislukt: ${(err as Error).message}`); } - }, [loadTemplate]); + }, [loadTemplate, addRecentFile]); + + const projectName = filePath ?? "Berekening"; + + const evaluateCurrent = useCallback(() => { + const ast = parse(source, { includes: calcpadIncludes, imageUrls: calcpadImageUrls }); + return evaluate(ast, selectValues); + }, [source, selectValues]); const handleSave = useCallback(async () => { try { - const path = await saveCalculationFile(source, filePath ?? "Berekening"); + // Save as IFCX (.ifc-calculation) — the IFCX document IS the file, with + // the CalcPAD source embedded under `source.content` for round-trip. + const nodes = evaluateCurrent(); + const ifcx = generateIfcx(nodes, { projectName }); + const path = await saveCalculationFile(source, ifcx, projectName); if (path) { - // Persist the new file path so DocumentBar / next save reflects it. useDocumentStore.getState().markSaved(path); } } catch (err) { console.error("Save file failed:", err); alert(`Bestand opslaan mislukt: ${(err as Error).message}`); } - }, [source, filePath]); - - const projectName = filePath ?? "Berekening"; + }, [source, projectName, evaluateCurrent]); - const evaluateCurrent = useCallback(() => { - const ast = parse(source, { includes: calcpadIncludes }); - return evaluate(ast, selectValues); - }, [source, selectValues]); - - const handlePreviewPdf = useCallback(() => { - setPreviewOpen(true); - }, []); - - const generatePdfPath = useCallback(async () => { - const nodes = evaluateCurrent(); - return previewPdfReport(nodes, projectName); - }, [evaluateCurrent, projectName]); + const handleNew = useCallback(() => { + if (useDocumentStore.getState().dirty) { + const ok = confirm("Niet-opgeslagen wijzigingen worden weggegooid. Doorgaan?"); + if (!ok) return; + } + loadTemplate("", "Nieuw"); + }, [loadTemplate]); const handleSavePdf = useCallback(async () => { try { @@ -96,8 +101,8 @@ export default function CalcTab({ onSettingsClick: _onSettingsClick }: CalcTabPr
- {}} /> - + + @@ -108,24 +113,12 @@ export default function CalcTab({ onSettingsClick: _onSettingsClick }: CalcTabPr - - {}} /> - {}} /> - {}} /> - - {}} /> {}} /> -
- setPreviewOpen(false)} - projectName={projectName} - generate={generatePdfPath} - onSave={handleSavePdf} - />
); } diff --git a/packages/desktop/src/components/ribbon/IfcTab.tsx b/packages/desktop/src/components/ribbon/IfcTab.tsx index 5e85a9b..833ba6a 100644 --- a/packages/desktop/src/components/ribbon/IfcTab.tsx +++ b/packages/desktop/src/components/ribbon/IfcTab.tsx @@ -1,139 +1,11 @@ -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import RibbonGroup from "./RibbonGroup"; -import RibbonButton from "./RibbonButton"; -import { - ifcImportIcon, - ifcExportIcon, - ifcTreeIcon, - ifcPropertiesIcon, - ifcValidateIcon, -} from "./calcIcons"; -import { useDocumentStore } from "../../store/documentStore"; -import { useLoadCaseStore } from "../../store/loadCaseStore"; -import { calcpadIncludes } from "../../templates/calcpad-includes"; -import { - parse, - evaluate, - generateIfcx, - generateIfc4x3Step, -} from "@ifc-calc/core"; - /** - * Ribbon tab for the IFC view — mirrors the OpenAEC style-book demo - * (File / Model / Tools) and uses the generated IFCX + STEP output as the - * data source. + * Ribbon tab for the IFC view. + * + * Intentionally empty — the IFC panel (IfcViewerPanel) is the working area and + * shows the same IFCX document that gets saved to disk as a .ifc-calculation + * file. No ribbon actions are needed; export/import flow is via File → Save / + * File → Open in the Calc tab. */ export default function IfcTab() { - const { t } = useTranslation("ribbon"); - const source = useDocumentStore((s) => s.source); - const filePath = useDocumentStore((s) => s.filePath); - const activeId = useLoadCaseStore((s) => s.activeId); - const valuesByCase = useLoadCaseStore((s) => s.valuesByCase); - - const buildExports = useCallback(() => { - const ast = parse(source, { includes: calcpadIncludes }); - const ev = evaluate(ast, valuesByCase[activeId] ?? {}); - const projectName = filePath?.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") ?? "Berekening"; - return { - projectName, - ifcx: generateIfcx(ev, { projectName }), - step: generateIfc4x3Step(ev, { projectName }), - }; - }, [source, activeId, valuesByCase, filePath]); - - const handleExportStep = useCallback(() => { - const { projectName, step } = buildExports(); - triggerDownload(step, `${slug(projectName)}.ifc`, "application/x-step"); - }, [buildExports]); - - const handleExportIfcx = useCallback(() => { - const { projectName, ifcx } = buildExports(); - triggerDownload(JSON.stringify(ifcx, null, 2), `${slug(projectName)}.ifcx`, "application/json"); - }, [buildExports]); - - const handleValidate = useCallback(() => { - const { ifcx } = buildExports(); - const issues: string[] = []; - if (!ifcx.header.projectName) issues.push("• projectnaam ontbreekt"); - if (ifcx.data.length < 4) issues.push("• spatial skeleton onvolledig (Project/Site/Building/Storey)"); - const elements = ifcx.data.filter((d) => !["Project", "Site", "Building", "BuildingStorey"].includes(d.type)); - if (elements.length === 0) issues.push("• geen structurele elementen gedetecteerd"); - alert(issues.length === 0 - ? `IFCX valide. ${ifcx.data.length} entries, schema ${ifcx.header.schema ?? "?"}.` - : `IFCX validatie:\n${issues.join("\n")}`); - }, [buildExports]); - - return ( -
-
- - alert("IFC importeren komt nog — staat op de roadmap.")} - /> - - - - - - { /* tree is shown in the IFC panel itself */ }} - /> - { - const { ifcx } = buildExports(); - const counts: Record = {}; - for (const e of ifcx.data) counts[e.type] = (counts[e.type] ?? 0) + 1; - const lines = Object.entries(counts).map(([k, v]) => ` ${k}: ${v}`).join("\n"); - alert(`IFCX statistieken voor "${ifcx.header.projectName}":\n${lines}`); - }} - /> - - - - - -
-
- ); -} - -function slug(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "berekening"; -} - -function triggerDownload(content: string, fileName: string, mime: string) { - const blob = new Blob([content], { type: mime }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + return
; } diff --git a/packages/desktop/src/components/settings/SettingsDialog.tsx b/packages/desktop/src/components/settings/SettingsDialog.tsx index 67935a2..2f6bddb 100644 --- a/packages/desktop/src/components/settings/SettingsDialog.tsx +++ b/packages/desktop/src/components/settings/SettingsDialog.tsx @@ -22,7 +22,24 @@ const THEME_OPTIONS = [ Voorbeeld met domein-tab: const TAB_IDS = ["general", "appearance", "calculation", "about"] as const; ─────────────────────────────────────────────────────────── */ -const TAB_IDS = ["general", "appearance", "about"] as const; +const TAB_IDS = ["general", "appearance", "units", "about"] as const; + +/** Persisted under the "units" settings key. Defaults match CalcPAD's. */ +export interface UnitsSettings { + angleMode: "deg" | "rad" | "gra"; + defaultLength: "mm" | "cm" | "m"; + defaultForce: "N" | "kN" | "MN"; + defaultStress: "Pa" | "kPa" | "MPa" | "GPa" | "N/mm^2"; + returnAngleUnits: boolean; +} + +export const UNITS_DEFAULTS: UnitsSettings = { + angleMode: "rad", + defaultLength: "mm", + defaultForce: "kN", + defaultStress: "MPa", + returnAngleUnits: false, +}; export function applyTheme(theme?: string) { document.documentElement.setAttribute("data-theme", theme || "light"); @@ -48,11 +65,13 @@ export default function SettingsDialog({ // Draft state — only committed on Save const [draftTheme, setDraftTheme] = useState(theme); const [draftLang, setDraftLang] = useState("auto"); + const [draftUnits, setDraftUnits] = useState(UNITS_DEFAULTS); const [confirmResetOpen, setConfirmResetOpen] = useState(false); // Snapshot of original values when dialog opens, for reverting on Cancel const originalTheme = useRef(theme); const originalLang = useRef(""); + const originalUnits = useRef(UNITS_DEFAULTS); // Reset draft to current values when dialog opens useEffect(() => { @@ -63,6 +82,10 @@ export default function SettingsDialog({ originalLang.current = lang; setDraftLang(lang); }); + getSetting("units", UNITS_DEFAULTS).then((u) => { + originalUnits.current = u; + setDraftUnits(u); + }); } }, [open, theme]); @@ -85,6 +108,7 @@ export default function SettingsDialog({ applyTheme(originalTheme.current); setDraftLang(originalLang.current); changeLanguage(originalLang.current); + setDraftUnits(originalUnits.current); onClose(); }; @@ -97,6 +121,9 @@ export default function SettingsDialog({ setSetting("language", draftLang); changeLanguage(draftLang); + setSetting("units", draftUnits); + window.dispatchEvent(new CustomEvent("units-changed", { detail: draftUnits })); + onClose(); }; @@ -110,6 +137,7 @@ export default function SettingsDialog({ applyTheme("light"); setDraftLang("auto"); changeLanguage("auto"); + setDraftUnits(UNITS_DEFAULTS); setConfirmResetOpen(false); }; @@ -152,6 +180,9 @@ export default function SettingsDialog({ {activeTab === "appearance" && ( )} + {activeTab === "units" && ( + + )} {activeTab === "about" && }
@@ -284,6 +315,91 @@ function ThemeDropdown({ ); } +/* ─── Units Tab ───────────────────────────────────────────── + Mirrors the CalcPAD `#deg`/`#rad`/`#gra` switches plus default display + units. Saved values are picked up by the evaluator (angle mode) and + shown in the preview. + ─────────────────────────────────────────────────────────── */ +function UnitsTabContent({ + units, + onChange, +}: { + units: UnitsSettings; + onChange: (next: UnitsSettings) => void; +}) { + const { t } = useTranslation("settings"); + const patch = (partial: Partial) => onChange({ ...units, ...partial }); + return ( +
+

{t("units.angle", "Hoekmodus")}

+
+ {t("units.angleMode", "Hoek-eenheid")} + patch({ angleMode: v as UnitsSettings["angleMode"] })} + style={{ width: 180 }} + /> +
+
+ {t("units.returnAngleUnits", "Hoeken mét eenheid teruggeven")} + patch({ returnAngleUnits: e.target.checked })} + /> +
+ +

{t("units.defaults", "Standaard weergave-eenheden")}

+
+ {t("units.length", "Lengte")} + patch({ defaultLength: v as UnitsSettings["defaultLength"] })} + style={{ width: 120 }} + /> +
+
+ {t("units.force", "Kracht")} + patch({ defaultForce: v as UnitsSettings["defaultForce"] })} + style={{ width: 120 }} + /> +
+
+ {t("units.stress", "Spanning / druk")} + patch({ defaultStress: v as UnitsSettings["defaultStress"] })} + style={{ width: 120 }} + /> +
+
+ ); +} + // ─── About Tab ─────────────────────────────────────────────── // Pas naam, versie en beschrijving aan via i18n keys // in locales/{lang}/settings.json, sectie "about" diff --git a/packages/desktop/src/hooks/useZoom.ts b/packages/desktop/src/hooks/useZoom.ts new file mode 100644 index 0000000..786b6ab --- /dev/null +++ b/packages/desktop/src/hooks/useZoom.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Ctrl+wheel zoom for a scrollable pane. Returns a ref to attach to the pane + * root and a `zoom` factor (default 1). Zoom is clamped to [min, max] and + * `e.preventDefault()` is called on Ctrl+wheel so the browser doesn't zoom + * the whole window. + * + * Each consumer holds independent state — Editor and Preview can zoom + * separately. + */ +export function useZoom(initial = 1, min = 0.5, max = 3) { + const [zoom, setZoom] = useState(initial); + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return; + e.preventDefault(); + // ~10 % per notch on a typical mouse wheel. + const step = -e.deltaY * 0.001; + setZoom((z) => Math.min(max, Math.max(min, z * (1 + step)))); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [min, max]); + + const reset = () => setZoom(1); + return { ref, zoom, setZoom, reset }; +} diff --git a/packages/desktop/src/i18n/locales/en/backstage.json b/packages/desktop/src/i18n/locales/en/backstage.json index 7fbd999..1453f55 100644 --- a/packages/desktop/src/i18n/locales/en/backstage.json +++ b/packages/desktop/src/i18n/locales/en/backstage.json @@ -24,6 +24,15 @@ "stichting": "Stichting OpenAEC Foundation (NL)", "copyright": "© 2026 OpenAEC Foundation. Code: MIT. Content: CC BY-SA 4.0." }, + "openPanel": { + "browse": "Browse…", + "browseDesc": "Open a .cpd or .ifc-calculation file from disk", + "title": "Recent files", + "noRecent": "No recent files", + "clearAll": "Clear all", + "justNow": "Just now", + "remove": "Remove" + }, "importPanel": { "title": "Import", "fromFile": "Import from File", diff --git a/packages/desktop/src/i18n/locales/en/ribbon.json b/packages/desktop/src/i18n/locales/en/ribbon.json index a29cca5..ce1a941 100644 --- a/packages/desktop/src/i18n/locales/en/ribbon.json +++ b/packages/desktop/src/i18n/locales/en/ribbon.json @@ -15,6 +15,7 @@ "export": "Export", "new": "New", "open": "Open", + "browse": "Browse…", "save": "Save", "undo": "Undo", "redo": "Redo", diff --git a/packages/desktop/src/i18n/locales/en/settings.json b/packages/desktop/src/i18n/locales/en/settings.json index 8337ce6..6584b9b 100644 --- a/packages/desktop/src/i18n/locales/en/settings.json +++ b/packages/desktop/src/i18n/locales/en/settings.json @@ -5,12 +5,25 @@ "tabs": { "general": "General", "appearance": "Appearance", + "units": "Units", "editor": "Editor", "files": "Files", "shortcuts": "Shortcuts", "plugins": "Plugins", "about": "About" }, + "units": { + "angle": "Angle mode", + "angleMode": "Angle unit", + "deg": "Degrees (°)", + "rad": "Radians (rad)", + "gra": "Gradians (grad)", + "returnAngleUnits": "Return angles with unit attached", + "defaults": "Default display units", + "length": "Length", + "force": "Force", + "stress": "Stress / pressure" + }, "general": { "application": "Application", "language": "Language", diff --git a/packages/desktop/src/i18n/locales/nl/backstage.json b/packages/desktop/src/i18n/locales/nl/backstage.json index 8bf7f21..6907b70 100644 --- a/packages/desktop/src/i18n/locales/nl/backstage.json +++ b/packages/desktop/src/i18n/locales/nl/backstage.json @@ -24,6 +24,15 @@ "stichting": "Stichting OpenAEC Foundation (NL)", "copyright": "© 2026 OpenAEC Foundation. Code: MIT. Inhoud: CC BY-SA 4.0." }, + "openPanel": { + "browse": "Bladeren…", + "browseDesc": "Open een .cpd of .ifc-calculation bestand vanaf schijf", + "title": "Recente bestanden", + "noRecent": "Geen recente bestanden", + "clearAll": "Wis lijst", + "justNow": "Zojuist", + "remove": "Verwijderen" + }, "importPanel": { "title": "Importeren", "fromFile": "Importeren uit bestand", diff --git a/packages/desktop/src/i18n/locales/nl/ribbon.json b/packages/desktop/src/i18n/locales/nl/ribbon.json index 0925119..a9b1bf1 100644 --- a/packages/desktop/src/i18n/locales/nl/ribbon.json +++ b/packages/desktop/src/i18n/locales/nl/ribbon.json @@ -15,6 +15,7 @@ "export": "Exporteren", "new": "Nieuw", "open": "Openen", + "browse": "Bladeren…", "save": "Opslaan", "undo": "Ongedaan", "redo": "Opnieuw", diff --git a/packages/desktop/src/i18n/locales/nl/settings.json b/packages/desktop/src/i18n/locales/nl/settings.json index 18da6cb..acd3968 100644 --- a/packages/desktop/src/i18n/locales/nl/settings.json +++ b/packages/desktop/src/i18n/locales/nl/settings.json @@ -3,12 +3,25 @@ "tabs": { "general": "Algemeen", "appearance": "Uiterlijk", + "units": "Eenheden", "editor": "Editor", "files": "Bestanden", "shortcuts": "Sneltoetsen", "plugins": "Invoegtoepassingen", "about": "Over" }, + "units": { + "angle": "Hoekmodus", + "angleMode": "Hoek-eenheid", + "deg": "Graden (°)", + "rad": "Radialen (rad)", + "gra": "Gradianen (grad)", + "returnAngleUnits": "Hoeken mét eenheid teruggeven", + "defaults": "Standaard weergave-eenheden", + "length": "Lengte", + "force": "Kracht", + "stress": "Spanning / druk" + }, "general": { "application": "Applicatie", "language": "Taal", diff --git a/packages/desktop/src/tauri/fileOps.ts b/packages/desktop/src/tauri/fileOps.ts index 8525d51..b19aa2b 100644 --- a/packages/desktop/src/tauri/fileOps.ts +++ b/packages/desktop/src/tauri/fileOps.ts @@ -3,8 +3,21 @@ * * Falls back gracefully in browser-dev (no Tauri runtime) by using the * File System Access API where available, or a stub . + * + * File format + * ----------- + * `.ifc-calculation` files are IFCX (JSON-LD draft) documents with an extra + * `source` field holding the raw CalcPAD text. This lets the SAME file act as + * • an IFC representation of the calc result (consumable by an IFC viewer) + * • the round-trippable calc source (consumable by this app) + * + * Legacy `.cpd`, `.cpdz` and raw-text `.ifc-calculation` files are still + * accepted on open — `unwrapFromIfcCalculation` falls through to treating the + * payload as raw CalcPAD when JSON parsing fails or `source` is absent. */ +import type { IfcxDocument } from "@ifc-calc/core"; + function isTauri(): boolean { try { // @ts-expect-error — runtime probe @@ -24,9 +37,47 @@ const SUPPORTED_FILTERS = [ export interface OpenedFile { path: string; name: string; + /** Raw CalcPAD source — IFCX-wrapping (when present) is already stripped. */ content: string; } +/** + * Wrap a CalcPAD source + IFCX representation into the on-disk + * `.ifc-calculation` JSON-LD format. The result IS a valid IFCX document + * (an IFC consumer can read it) with one extra `source` field for round-trip. + */ +export function wrapAsIfcCalculation(source: string, ifcx: IfcxDocument): string { + const doc = { + ...ifcx, + source: { + format: "calcpad", + language: "ifc-calculation", + content: source, + }, + }; + return JSON.stringify(doc, null, 2); +} + +/** + * Extract the CalcPAD source from a `.ifc-calculation` file. Accepts either: + * • new format: JSON document with `source.content` + * • legacy: raw CalcPAD text (also matches `.cpd` / `.cpdz` files) + */ +export function unwrapFromIfcCalculation(content: string): string { + // Quick guard — only try JSON.parse when the payload looks like JSON. + const trimmed = content.trimStart(); + if (!trimmed.startsWith("{")) return content; + try { + const parsed = JSON.parse(trimmed) as { source?: { content?: unknown } }; + if (parsed && typeof parsed.source?.content === "string") { + return parsed.source.content; + } + } catch { + // Fall through — not JSON, treat as raw CalcPAD. + } + return content; +} + /** * Open a `.ifc-calculation` or `.cpd` file via the OS file picker. * Resolves with the loaded content, or `null` if the user cancelled. @@ -44,7 +95,8 @@ export async function openCalculationFile(): Promise { }); if (!picked || typeof picked !== "string") return null; - const content = await readTextFile(picked); + const raw = await readTextFile(picked); + const content = unwrapFromIfcCalculation(raw); const name = pathBaseName(picked); return { path: picked, name, content }; } @@ -57,7 +109,8 @@ export async function openCalculationFile(): Promise { input.onchange = async () => { const f = input.files?.[0]; if (!f) return resolve(null); - const content = await f.text(); + const raw = await f.text(); + const content = unwrapFromIfcCalculation(raw); resolve({ path: f.name, name: stripExt(f.name), content }); }; input.oncancel = () => resolve(null); @@ -79,33 +132,42 @@ function sanitizeFileName(name: string): string { } /** - * Save the current document via a Save As dialog. Defaults to the supplied - * `defaultName` (without extension) and writes a `.ifc-calculation` file. The - * resolved absolute path is returned, or `null` if the user cancelled. + * Save the current calc as a `.ifc-calculation` file via a Save As dialog. + * + * The on-disk payload is an IFCX JSON-LD document with the CalcPAD source + * embedded under `source.content` — see the module header for the format. + * The resolved absolute path is returned, or `null` if the user cancelled. */ export async function saveCalculationFile( - content: string, + source: string, + ifcx: IfcxDocument, defaultName: string, ): Promise { + const payload = wrapAsIfcCalculation(source, ifcx); + const defaultFile = `${sanitizeFileName(defaultName)}.ifc-calculation`; + if (isTauri()) { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const path = await save({ title: "Bestand opslaan als", - defaultPath: `${sanitizeFileName(defaultName)}.ifc-calculation`, - filters: SUPPORTED_FILTERS, + defaultPath: defaultFile, + filters: [ + { name: "OpenAEC Calculation (IFCX)", extensions: ["ifc-calculation"] }, + { name: "Alle bestanden", extensions: ["*"] }, + ], }); if (!path) return null; - await writeTextFile(path, content); + await writeTextFile(path, payload); return path; } // Browser fallback — download via Blob link - const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const blob = new Blob([payload], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${sanitizeFileName(defaultName)}.ifc-calculation`; + a.download = defaultFile; document.body.appendChild(a); a.click(); document.body.removeChild(a); diff --git a/packages/desktop/src/templates/calcpad-includes.ts b/packages/desktop/src/templates/calcpad-includes.ts index 7dc5e83..f0582f4 100644 --- a/packages/desktop/src/templates/calcpad-includes.ts +++ b/packages/desktop/src/templates/calcpad-includes.ts @@ -7,6 +7,12 @@ import svgDrawing from "./calcpad-samples/svg_drawing.cpd?raw"; import funderingDetail from "./calcpad-samples/fundering-detail.svg?raw"; +import picture0 from "./calcpad-samples/Images/Picture0.png?url"; +import picture1 from "./calcpad-samples/Images/Picture1.png?url"; +import picture2 from "./calcpad-samples/Images/Picture2.png?url"; +import picture3 from "./calcpad-samples/Images/Picture3.png?url"; +import picture4 from "./calcpad-samples/Images/Picture4.png?url"; +import picture5 from "./calcpad-samples/Images/Picture5.png?url"; export const calcpadIncludes: ReadonlyMap = new Map([ // CalcPAD library — drawing macros @@ -14,3 +20,16 @@ export const calcpadIncludes: ReadonlyMap = new Map([ // External SVG drawings inlined via @img(.svg) ["fundering-detail.svg", funderingDetail], ]); + +/** + * Raster images referenced inline via ``. + * Looked up by basename (case-insensitive) at parse time. + */ +export const calcpadImageUrls: ReadonlyMap = new Map([ + ["Picture0.png", picture0], + ["Picture1.png", picture1], + ["Picture2.png", picture2], + ["Picture3.png", picture3], + ["Picture4.png", picture4], + ["Picture5.png", picture5], +]); diff --git a/packages/desktop/src/templates/calcpad-samples/Images/Picture0.png b/packages/desktop/src/templates/calcpad-samples/Images/Picture0.png new file mode 100644 index 0000000..647b322 Binary files /dev/null and b/packages/desktop/src/templates/calcpad-samples/Images/Picture0.png differ diff --git a/packages/desktop/src/templates/calcpad-samples/Images/Picture1.png b/packages/desktop/src/templates/calcpad-samples/Images/Picture1.png new file mode 100644 index 0000000..5206950 Binary files /dev/null and b/packages/desktop/src/templates/calcpad-samples/Images/Picture1.png differ diff --git a/packages/desktop/src/templates/calcpad-samples/Images/Picture2.png b/packages/desktop/src/templates/calcpad-samples/Images/Picture2.png new file mode 100644 index 0000000..10fcacc Binary files /dev/null and b/packages/desktop/src/templates/calcpad-samples/Images/Picture2.png differ diff --git a/packages/desktop/src/templates/calcpad-samples/Images/Picture3.png b/packages/desktop/src/templates/calcpad-samples/Images/Picture3.png new file mode 100644 index 0000000..d7b3fba Binary files /dev/null and b/packages/desktop/src/templates/calcpad-samples/Images/Picture3.png differ diff --git a/packages/desktop/src/templates/calcpad-samples/Images/Picture4.png b/packages/desktop/src/templates/calcpad-samples/Images/Picture4.png new file mode 100644 index 0000000..7fc6c9a Binary files /dev/null and b/packages/desktop/src/templates/calcpad-samples/Images/Picture4.png differ diff --git a/packages/desktop/src/templates/calcpad-samples/Images/Picture5.png b/packages/desktop/src/templates/calcpad-samples/Images/Picture5.png new file mode 100644 index 0000000..ed9c170 Binary files /dev/null and b/packages/desktop/src/templates/calcpad-samples/Images/Picture5.png differ