);
}
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 `
`;
@@ -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" && }