diff --git a/packages/core/src/evaluator.ts b/packages/core/src/evaluator.ts
index d63158a..8fe8f6f 100644
--- a/packages/core/src/evaluator.ts
+++ b/packages/core/src/evaluator.ts
@@ -179,6 +179,71 @@ math.import(
{ override: true },
);
+// ── Iterative numerical solvers (used by parser's $Find/$Solve/$Sup/$Inf
+// rewrite). The `fn` argument is a mathjs user-defined function in scope;
+// we call it via `Number(fn(x))` and run a robust scalar algorithm.
+math.import(
+ {
+ /** Bisection root-finder for f(x) = 0 in [lo, hi]. */
+ _find_root: function (fn: unknown, lo: unknown, hi: unknown) {
+ if (typeof fn !== 'function') return Number.NaN;
+ const evalAt = (x: number): number => Number((fn as (n: number) => unknown)(x));
+ let a = Number(lo); let b = Number(hi);
+ if (a > b) [a, b] = [b, a];
+ let fa = evalAt(a); let fb = evalAt(b);
+ if (!isFinite(fa) || !isFinite(fb)) return Number.NaN;
+ if (fa === 0) return a;
+ if (fb === 0) return b;
+ if (fa * fb > 0) return Number.NaN;
+ for (let i = 0; i < 80; i++) {
+ const c = (a + b) / 2;
+ const fc = evalAt(c);
+ if (Math.abs(fc) < 1e-12 || (b - a) < 1e-14) return c;
+ if (fa * fc < 0) { b = c; fb = fc; } else { a = c; fa = fc; }
+ }
+ return (a + b) / 2;
+ },
+ /** Newton-Raphson root-finder for f(x) = 0 starting from `guess`. */
+ _solve_newton: function (fn: unknown, guess: unknown) {
+ if (typeof fn !== 'function') return Number.NaN;
+ const evalAt = (x: number): number => Number((fn as (n: number) => unknown)(x));
+ let x = Number(guess);
+ const h = 1e-7;
+ for (let i = 0; i < 60; i++) {
+ const f = evalAt(x);
+ if (!isFinite(f)) return Number.NaN;
+ if (Math.abs(f) < 1e-12) return x;
+ const fp = (evalAt(x + h) - f) / h;
+ if (!isFinite(fp) || Math.abs(fp) < 1e-15) break;
+ const dx = f / fp;
+ x = x - dx;
+ if (Math.abs(dx) < 1e-12) return x;
+ }
+ return x;
+ },
+ /** Golden-section extremum over [lo, hi]; sign=+1 → sup, -1 → inf. */
+ _extremum: function (fn: unknown, lo: unknown, hi: unknown, sign: unknown) {
+ if (typeof fn !== 'function') return Number.NaN;
+ const s = Number(sign) >= 0 ? 1 : -1;
+ const evalAt = (x: number): number => s * Number((fn as (n: number) => unknown)(x));
+ const phi = (Math.sqrt(5) - 1) / 2;
+ let a = Number(lo); let b = Number(hi);
+ if (a > b) [a, b] = [b, a];
+ let c = b - (b - a) * phi;
+ let d = a + (b - a) * phi;
+ for (let i = 0; i < 80; i++) {
+ if (evalAt(c) > evalAt(d)) b = d; else a = c;
+ c = b - (b - a) * phi;
+ d = a + (b - a) * phi;
+ if ((b - a) < 1e-14) break;
+ }
+ const xOpt = (a + b) / 2;
+ return Number((fn as (n: number) => unknown)(xOpt));
+ },
+ },
+ { override: true },
+);
+
export interface Scope {
[key: string]: unknown;
}
@@ -197,10 +262,16 @@ export function evaluate(nodes: AstNode[], selectValues?: SelectValues): Evaluat
return evaluateNodes(nodes, scope, selectValues || {});
}
+/** Sentinel key on `scope` used by `#break` to short-circuit out of a loop. */
+const BREAK_FLAG = '__break__';
+
function evaluateNodes(nodes: AstNode[], scope: Scope, selectValues: SelectValues): EvaluatedNode[] {
const result: EvaluatedNode[] = [];
for (const node of nodes) {
+ // `#break` raised by a deeper node — stop evaluating siblings. The
+ // enclosing `repeat` case picks up the flag and exits the loop.
+ if (scope[BREAK_FLAG]) break;
switch (node.type) {
case 'heading':
if (node.hidden) break;
@@ -300,6 +371,12 @@ function evaluateNodes(nodes: AstNode[], scope: Scope, selectValues: SelectValue
break;
}
+ case 'break': {
+ // Set the break flag — the enclosing repeat case consumes it.
+ scope[BREAK_FLAG] = true;
+ break;
+ }
+
case 'repeat': {
let count = 0;
try {
@@ -315,6 +392,12 @@ function evaluateNodes(nodes: AstNode[], scope: Scope, selectValues: SelectValue
scope['_i'] = iter;
const children = evaluateNodes(node.body, scope, selectValues);
if (!node.hidden) result.push(...children);
+ if (scope[BREAK_FLAG]) {
+ // Iteration stopped — consume the flag so outer loops aren't
+ // also broken out of.
+ delete scope[BREAK_FLAG];
+ break;
+ }
}
break;
}
diff --git a/packages/core/src/latex.ts b/packages/core/src/latex.ts
index 3c66929..5e77c39 100644
--- a/packages/core/src/latex.ts
+++ b/packages/core/src/latex.ts
@@ -262,20 +262,25 @@ export function resultToLatex(numStr: string, unitStr: string): string {
return `${num} \\; ${unitPartToLatex(unitStr)}`;
}
-/** Format a unit string like "N / mm^2" as LaTeX */
+/** Format a unit string like "N / mm^2" or "kN m" directly as LaTeX. */
export function unitPartToLatex(unitStr: string): string {
- // Handle compound units like "N / mm^2" or "kN m"
- // Parse as a mathjs expression to get proper LaTeX
- try {
- // Wrap in "1 unit" so mathjs parses it as a unit expression
- const node = parse(`1 ${unitStr}`);
- const latex = nodeToLatex(node);
- // Remove the leading "1 \;" from the result
- return latex.replace(/^1\s*\\[;,]\s*/, '').replace(/^1\s+/, '');
- } catch {
- // Fallback: simple text rendering
- return `\\text{${unitStr.replace(/\^(\d+)/g, '}^{$1}\\text{')}}`;
- }
+ // Direct converter — no mathjs round-trip (which prepends `1~` that's
+ // hard to strip reliably). Tokens are identifiers optionally followed by
+ // `^exp`. `/` introduces a denominator; space is implicit multiplication.
+ const fmtToken = (tok: string): string => {
+ const m = tok.match(/^([\p{L}_][\p{L}\p{N}_]*)(?:\^(-?\d+))?$/u);
+ if (!m) return `\\mathrm{${tok}}`;
+ if (!m[2]) return `\\mathrm{${m[1]}}`;
+ return `{\\mathrm{${m[1]}}}^{${m[2]}}`;
+ };
+ const fmtGroup = (s: string): string =>
+ s.trim().split(/\s+/).filter(Boolean).map(fmtToken).join('\\,');
+
+ const parts = unitStr.split('/').map((s) => s.trim()).filter(Boolean);
+ if (parts.length === 0) return '';
+ if (parts.length === 1) return fmtGroup(parts[0]);
+ // a/b/c → a / (b·c)
+ return `\\frac{${fmtGroup(parts[0])}}{${parts.slice(1).map(fmtGroup).join('\\,')}}`;
}
/** Format a variable name as LaTeX */
diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts
index fed19a5..c5514d8 100644
--- a/packages/core/src/parser.ts
+++ b/packages/core/src/parser.ts
@@ -92,6 +92,7 @@ const BREAK_RE = /^#break\b/;
// Match `$Plot{...}` even when CalcPAD has appended trailing persisted-input
// data after the closing brace (e.g. `}\v2\t3`).
const PLOT_RE = /^\$Plot\s*\{([^}]+)\}/;
+const SOLVER_RE = /\$(Find|Root|Solve|Sup|Inf)\s*\{([^}]+)\}/g;
const TRAILING_DATA_RE = /^[\s\d.eE+\-,;]+$/; // pure numeric/whitespace line
// HTML wrappers CalcPAD uses around conditions / equation fragments.
@@ -364,12 +365,25 @@ function foldIdentifierDots(source: string): string {
// attribute values must survive unchanged.
const transformOutsideQuotes = (text: string): string => {
let out = text;
+ // CalcPAD vector index `name.(expr)` → `name[expr]`. Must precede dot-fold
+ // so the parenthesised index isn't accidentally read as a member access.
+ out = out.replace(
+ /(? `${name}[${expr}]`,
+ );
// CalcPAD vector index `name.digit` → `name[digit]`. Must precede the
// identifier-cluster fold so `cc.3` isn't mistakenly read as `cc_3`.
out = out.replace(
/(? `${name}[${idx}]`,
);
+ // CalcPAD vector index by loop-variable: `name.i`, `name.j` (single
+ // lowercase letter) → `name[i]`. Distinguishes from dotted identifiers
+ // like `Cs.Cd` (uppercase / multi-char RHS) which still get folded.
+ out = out.replace(
+ /(? `${name}[${idx}]`,
+ );
// Identifier-cluster fold: `Cs.Cd`, `F_0.9G50%TotalWeight` → underscores.
out = out.replace(
/(? {
+ const atIdx = inner.lastIndexOf('@');
+ if (atIdx === -1) return match;
+ const body = inner.slice(0, atIdx).trim();
+ const param = inner.slice(atIdx + 1).trim();
+ const eqIdx = param.indexOf('=');
+ if (eqIdx === -1) return match;
+ const varName = param.slice(0, eqIdx).trim();
+ const range = param.slice(eqIdx + 1).trim();
+ counter += 1;
+ const fnName = `__solver_${counter}`;
+ defs.push(`${fnName}(${varName}) = ${body}`);
+ if (op === 'Solve') {
+ return `_solve_newton(${fnName}, ${range})`;
+ }
+ const colonIdx = range.indexOf(':');
+ if (colonIdx === -1) return match;
+ const lo = range.slice(0, colonIdx).trim();
+ const hi = range.slice(colonIdx + 1).trim();
+ if (op === 'Find' || op === 'Root') return `_find_root(${fnName}, ${lo}, ${hi})`;
+ if (op === 'Sup') return `_extremum(${fnName}, ${lo}, ${hi}, 1)`;
+ if (op === 'Inf') return `_extremum(${fnName}, ${lo}, ${hi}, -1)`;
+ return match;
+ });
+ for (const d of defs) out.push(d);
+ out.push(newLine);
+ }
+ return out.join('\n');
+}
+
function normalizeExpression(expr: string): string {
return expr
.replace(/\bsqr\b/g, 'sqrt')
.replace(/\blg\b/g, 'log10')
.replace(/π/g, 'pi')
+ .replace(/≡/g, '==')
+ .replace(/≠/g, '!=')
+ .replace(/≥/g, '>=')
+ .replace(/≤/g, '<=')
+ // CalcPAD `expr|unit` = target-unit conversion → mathjs `expr to unit`.
+ // Runs after matrix-rewrite so `[a;b|c;d]` matrices are already
+ // converted to nested arrays before this point.
+ .replace(/\|(\s*[\p{L}\p{N}_/\s^*-]+)$/u, ' to $1')
.replace(/;/g, ',');
}
diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts
index 85c9cc9..b525b3c 100644
--- a/packages/core/src/renderer.ts
+++ b/packages/core/src/renderer.ts
@@ -289,12 +289,11 @@ export const defaultStyles = `
}
.calc-line {
- background: #f8fafc;
- border-left: 3px solid #3b82f6;
- padding: 0.4em 1.2em;
- margin: 0.35em 0;
- border-radius: 0 6px 6px 0;
- overflow-x: auto;
+ /* CalcPAD-stijl: geen achtergrond / linker streep — gewoon de formule. */
+ padding: 0.15em 0;
+ margin: 0.25em 0;
+ /* Verbergt overflow zonder slider; KaTeX past zich via .calc-line .katex aan. */
+ overflow-x: hidden;
}
.calc-line .katex-display {
@@ -312,8 +311,10 @@ export const defaultStyles = `
}
.calc-error {
- border-left-color: #dc2626;
+ /* Fout-regels krijgen wel een lichte rood-tint zodat ze opvallen. */
background: #fef2f2;
+ border-left: 3px solid #dc2626;
+ padding-left: 0.6em;
}
.calc-error-msg {
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 3521ca0..b2c73a8 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -154,6 +154,12 @@ export interface GefUploadNode {
hidden?: boolean;
}
+/** CalcPAD `#break` — early exit from the enclosing #repeat / #for loop. */
+export interface BreakNode {
+ type: 'break';
+ hidden?: boolean;
+}
+
export type AstNode =
| HeadingNode
| TextNode
@@ -165,6 +171,7 @@ export type AstNode =
| RepeatNode
| PlotNode
| SvgNode
+ | BreakNode
| ImageNode
| SelectNode
| GefUploadNode;
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 590a9b2..0611900 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@openaec/calculations-studio",
"private": true,
- "version": "0.1.3",
+ "version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index 89336a0..6ba42d1 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "open-calculations-studio"
-version = "0.1.3"
+version = "0.1.4"
description = "Open Calculations Studio - lightweight CalcPAD alternative for Eurocode verifications"
authors = ["OpenAEC Contributors"]
edition = "2021"
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index 58321f3..ca38736 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Open Calculations Studio",
- "version": "0.1.3",
+ "version": "0.1.4",
"identifier": "studio.opencalculations.app",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/packages/desktop/src/components/calc/Preview.css b/packages/desktop/src/components/calc/Preview.css
index ad6a107..201fb5b 100644
--- a/packages/desktop/src/components/calc/Preview.css
+++ b/packages/desktop/src/components/calc/Preview.css
@@ -11,10 +11,24 @@
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
- overflow-x: auto;
+ /* Geen horizontale scrollbalk — content moet wrappen of clippen. */
+ overflow-x: hidden;
padding: 24px 32px;
font-family: var(--font-body);
color: var(--theme-text);
+ /* Lange formules / tekst breken naar de volgende regel ipv te scrollen. */
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+/* KaTeX display-blokken: laat ze wrappen ipv één lange regel te forceren. */
+.calc-preview-content .calc-line,
+.calc-preview-content .katex-display {
+ max-width: 100%;
+}
+
+.calc-preview-content .katex-display {
+ overflow-x: hidden;
}
.calc-preview-content::-webkit-scrollbar {
diff --git a/packages/desktop/src/components/calc/ProjectBrowser.css b/packages/desktop/src/components/calc/ProjectBrowser.css
index a601d0e..f35f51b 100644
--- a/packages/desktop/src/components/calc/ProjectBrowser.css
+++ b/packages/desktop/src/components/calc/ProjectBrowser.css
@@ -210,3 +210,47 @@
.project-browser-tree::-webkit-scrollbar-thumb:hover {
background: var(--theme-accent, #D97706);
}
+
+/* ─── Sections (Project / Library) ───────────────────────────── */
+
+.tree-section {
+ margin: 8px 0 4px;
+}
+
+.tree-section + .tree-section {
+ margin-top: 16px;
+ border-top: 1px solid var(--theme-border-subtle);
+ padding-top: 8px;
+}
+
+.tree-section-header {
+ display: flex;
+ align-items: center;
+ padding: 4px 10px 6px;
+}
+
+.tree-section-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+ color: var(--theme-accent, #D97706);
+ opacity: 0.85;
+}
+
+.tree-section-children {
+ display: flex;
+ flex-direction: column;
+}
+
+.tree-item-emphasis {
+ font-weight: 600;
+ color: var(--theme-text);
+ background: var(--theme-accent-soft);
+ border-radius: 4px;
+ margin: 2px 6px;
+}
+
+.tree-item-emphasis:hover {
+ background: var(--theme-hover);
+}
diff --git a/packages/desktop/src/components/calc/ProjectBrowser.tsx b/packages/desktop/src/components/calc/ProjectBrowser.tsx
index fd0b711..c5fb140 100644
--- a/packages/desktop/src/components/calc/ProjectBrowser.tsx
+++ b/packages/desktop/src/components/calc/ProjectBrowser.tsx
@@ -13,9 +13,30 @@ interface TreeProps {
function TreeNodeView({ node, level, selectedId, onSelect }: TreeProps) {
const [expanded, setExpanded] = useState(
- node.kind === "category" ? !!node.defaultExpanded : false,
+ node.kind === "category" ? !!node.defaultExpanded : node.kind === "section",
);
+ if (node.kind === "section") {
+ return (
+
+
+ {node.label}
+
+
+ {node.children.map((child) => (
+
+ ))}
+
+
+ );
+ }
+
if (node.kind === "category") {
return (
0 ? " tree-subcategory" : ""}`}>
@@ -47,14 +68,15 @@ function TreeNodeView({ node, level, selectedId, onSelect }: TreeProps) {
const isSelected = selectedId === node.id;
const hasTemplate = !!node.templateId;
+ const isEmphasis = node.kind === "item" && node.emphasis;
return (
onSelect(node.id, node.templateId, node.label)}
title={hasTemplate ? `Laad: ${node.label}` : `${node.label} (nog niet beschikbaar)`}
>
- {hasTemplate ? "○" : "□"}
+ {!isEmphasis && {hasTemplate ? "○" : "□"} }
{node.label}
);
diff --git a/packages/desktop/src/components/calc/projectTree.ts b/packages/desktop/src/components/calc/projectTree.ts
index 57c31e8..199143e 100644
--- a/packages/desktop/src/components/calc/projectTree.ts
+++ b/packages/desktop/src/components/calc/projectTree.ts
@@ -1,20 +1,53 @@
/**
- * Project browser tree — hierarchical organization of every template
- * (Eurocodes, Vandepitte books, project calcs) the user can drop into the editor.
+ * Project browser tree — sidebar voor de editor.
+ *
+ * Twee secties, gescheiden door een `section` node:
+ * • PROJECT (boven) — projectgegevens + de calc-sheets van dit project
+ * • LIBRARY (onder) — referentie-materiaal (boeken, NEN-EN, CalcPAD samples)
*
* `templateId` matches a key in `src/templates/index.ts`.
*/
export type TreeNode =
+ | { kind: "section"; id: string; label: string; children: TreeNode[] }
| { kind: "category"; id: string; label: string; defaultExpanded?: boolean; children: TreeNode[]; count?: number }
- | { kind: "item"; id: string; label: string; templateId?: string };
+ | { kind: "item"; id: string; label: string; templateId?: string; emphasis?: boolean };
+
+/**
+ * Calc-sheets binnen het huidige project. Voor nu hardcoded; later vervangen
+ * door dynamische projectstaat (persisted per project file).
+ */
+const projectSheets: TreeNode[] = [
+ {
+ kind: "item",
+ id: "project-metadata",
+ label: "📋 Projectgegevens",
+ templateId: "project-metadata",
+ emphasis: true,
+ },
+ { kind: "item", id: "sheet-stalen-gevelkolom", label: "Stalen gevelkolom (wind + N)", templateId: "stalen-gevelkolom" },
+ { kind: "item", id: "sheet-verticaal-windverband", label: "Verticaal windverband", templateId: "verticaal-windverband" },
+ { kind: "item", id: "sheet-paaldraagvermogen", label: "Paaldraagvermogen", templateId: "paaldraagvermogen" },
+ { kind: "item", id: "sheet-stalen-ligger", label: "Stalen ligger IPE 300", templateId: "stalen-ligger" },
+];
export const projectTree: TreeNode[] = [
+ {
+ kind: "section",
+ id: "project",
+ label: "Project",
+ children: projectSheets,
+ },
+ {
+ kind: "section",
+ id: "library",
+ label: "Library",
+ children: [
{
kind: "category",
id: "books",
label: "Books",
- defaultExpanded: true,
+ defaultExpanded: false,
count: 8,
children: [
{ kind: "item", id: "book-bijlage-a", label: "Constructieberekening Bijlage A" },
@@ -130,25 +163,15 @@ export const projectTree: TreeNode[] = [
},
],
},
- {
- kind: "category",
- id: "calculations",
- label: "Calculations",
- defaultExpanded: true,
- count: 3,
- children: [
- { kind: "item", id: "calc-calcpad-demo", label: "CalcPAD syntax demo", templateId: "calcpad-demo" },
- { kind: "item", id: "calc-paaldraagvermogen", label: "Paaldraagvermogen", templateId: "paaldraagvermogen" },
- { kind: "item", id: "calc-stalen-ligger", label: "Stalen ligger IPE 300", templateId: "stalen-ligger" },
- ],
- },
{
kind: "category",
id: "calcpad-samples",
label: "CalcPAD voorbeelden",
- defaultExpanded: true,
- count: 10,
+ defaultExpanded: false,
+ count: 11,
children: [
+ { kind: "item", id: "cpd-2259-intertek", label: "2259 Intertek units (real-world)", templateId: "cpd-2259-intertek" },
+ { kind: "item", id: "cpd-calcpad-demo", label: "CalcPAD syntax demo", templateId: "calcpad-demo" },
{ kind: "item", id: "cpd-quadratic", label: "Quadratic Equation", templateId: "cpd-quadratic" },
{ kind: "item", id: "cpd-cubic", label: "Cubic Equation", templateId: "cpd-cubic" },
{ kind: "item", id: "cpd-lissajous", label: "Lissajous Curve", templateId: "cpd-lissajous" },
@@ -161,4 +184,6 @@ export const projectTree: TreeNode[] = [
{ kind: "item", id: "cpd-deep-beam", label: "Deep Beam (Elastic)", templateId: "cpd-deep-beam" },
],
},
+ ],
+ },
];
diff --git a/packages/desktop/src/store/documentStore.ts b/packages/desktop/src/store/documentStore.ts
index ab499b3..de07343 100644
--- a/packages/desktop/src/store/documentStore.ts
+++ b/packages/desktop/src/store/documentStore.ts
@@ -1,6 +1,14 @@
import { create } from "zustand";
import type { SelectValues } from "@ifc-calc/core";
import { paalExample } from "../templates/examples";
+import { getSetting, setSetting } from "../store";
+
+const STORE_KEY = "documentState";
+
+interface PersistedDoc {
+ source: string;
+ filePath: string | null;
+}
interface DocumentState {
source: string;
@@ -27,3 +35,23 @@ export const useDocumentStore = create
((set) => ({
markSaved: (filePath) =>
set({ filePath, dirty: false }),
}));
+
+// Persistence — hydrate source + filePath, then auto-save (debounced) on
+// each change. Survives app restart so the user picks up where they left off.
+let docPersistTimer: ReturnType | null = null;
+function scheduleDocPersist(snapshot: PersistedDoc) {
+ if (docPersistTimer) clearTimeout(docPersistTimer);
+ docPersistTimer = setTimeout(() => {
+ void setSetting(STORE_KEY, snapshot);
+ docPersistTimer = null;
+ }, 500);
+}
+
+void getSetting(STORE_KEY, null).then((saved) => {
+ if (saved && typeof saved.source === "string") {
+ useDocumentStore.setState({ source: saved.source, filePath: saved.filePath ?? null, dirty: false });
+ }
+ useDocumentStore.subscribe((s) =>
+ scheduleDocPersist({ source: s.source, filePath: s.filePath }),
+ );
+});
diff --git a/packages/desktop/src/store/loadCaseStore.ts b/packages/desktop/src/store/loadCaseStore.ts
index 6de4e6c..4787c29 100644
--- a/packages/desktop/src/store/loadCaseStore.ts
+++ b/packages/desktop/src/store/loadCaseStore.ts
@@ -1,5 +1,14 @@
import { create } from "zustand";
import type { SelectValues } from "@ifc-calc/core";
+import { getSetting, setSetting } from "../store";
+
+const STORE_KEY = "loadCaseState";
+
+interface Persisted {
+ cases: LoadCase[];
+ activeId: string;
+ valuesByCase: Record;
+}
/**
* Per-document `belastingsgeval` (load case) state. Each load case stores its
@@ -38,6 +47,17 @@ interface LoadCaseState {
setActiveValue: (name: string, value: string) => void;
}
+// Debounced persistence — coalesce rapid writes (e.g. while user types in a
+// prompt input) into a single Tauri store update.
+let persistTimer: ReturnType | null = null;
+function schedulePersist(snapshot: Persisted) {
+ if (persistTimer) clearTimeout(persistTimer);
+ persistTimer = setTimeout(() => {
+ void setSetting(STORE_KEY, snapshot);
+ persistTimer = null;
+ }, 250);
+}
+
export const useLoadCaseStore = create((set, get) => ({
cases: defaultCases,
activeId: defaultCases[0].id,
@@ -88,3 +108,22 @@ export const useLoadCaseStore = create((set, get) => ({
};
}),
}));
+
+// Persistence — hydrate from Tauri store on first import, then auto-save on
+// every mutation. Values survive app restart.
+void getSetting(STORE_KEY, null).then((saved) => {
+ if (saved && saved.cases && saved.cases.length > 0) {
+ useLoadCaseStore.setState({
+ cases: saved.cases,
+ activeId: saved.activeId ?? saved.cases[0].id,
+ valuesByCase: saved.valuesByCase ?? {},
+ });
+ }
+ useLoadCaseStore.subscribe((s) =>
+ schedulePersist({
+ cases: s.cases,
+ activeId: s.activeId,
+ valuesByCase: s.valuesByCase,
+ }),
+ );
+});
diff --git a/packages/desktop/src/templates/index.ts b/packages/desktop/src/templates/index.ts
index f469076..ef3e0d2 100644
--- a/packages/desktop/src/templates/index.ts
+++ b/packages/desktop/src/templates/index.ts
@@ -1,6 +1,9 @@
import { paalExample, exampleDoc } from "./examples";
import { calcpadDemo } from "./calcpad-demo";
import { calcpadSamples } from "./calcpad-samples";
+import { projectMetadata } from "./projectMetadata";
+import { stalenGevelkolom } from "./stalenGevelkolom";
+import { verticaalWindverband } from "./verticaalWindverband";
import {
ec5Buiging, ec5Afschuiving, ec5Druk, ec5DrukLoodrecht,
ec5Knik, ec5Doorbuiging, ec5HoutenBalk,
@@ -32,6 +35,9 @@ import {
} from "./en1992";
export const templates: Record = {
+ "project-metadata": projectMetadata,
+ "stalen-gevelkolom": stalenGevelkolom,
+ "verticaal-windverband": verticaalWindverband,
"calcpad-demo": calcpadDemo,
...calcpadSamples,
"paaldraagvermogen": paalExample,
diff --git a/packages/desktop/src/templates/projectMetadata.ts b/packages/desktop/src/templates/projectMetadata.ts
new file mode 100644
index 0000000..ffb1708
--- /dev/null
+++ b/packages/desktop/src/templates/projectMetadata.ts
@@ -0,0 +1,109 @@
+/**
+ * Project metadata sheet — vult de Eurocode-uitgangspunten in die door alle
+ * berekeningen binnen het project gebruikt worden. Gevolgklasse, wind-,
+ * sneeuwgebied, ontwerplevensduur etc. komen voort uit NEN-EN 1990 + NB.
+ */
+
+export const projectMetadata = `"Project metadata
+'Vul hieronder de algemene projectgegevens en Eurocode-uitgangspunten in. Deze
+'waarden worden in alle berekeningen binnen dit project gebruikt.
+
+'1. Projectgegevens
+
+#hide
+project_nummer = 0
+project_naam = "Nieuw project"
+opdrachtgever = "Onbekend"
+constructeur = "Onbekend"
+locatie = "Onbekend"
+#show
+
+'
+'Projectnummer 'project_nummer'
+'Projectnaam 'project_naam'
+'Opdrachtgever 'opdrachtgever'
+'Constructeur 'constructeur'
+'Locatie 'locatie'
+'
+
+'2. Constructieve uitgangspunten (NEN-EN 1990 + NB)
+
+@select CC "Gevolgklasse"
+ CC1 — beperkte gevolgen = 1
+ CC2 — middelmatige gevolgen = 2
+ CC3 — grote gevolgen = 3
+@end
+
+@select RC "Betrouwbaarheidsklasse"
+ RC1 = 1
+ RC2 = 2
+ RC3 = 3
+@end
+
+@select DesignLife "Ontwerplevensduur"
+ 10 jaar (tijdelijk) = 10
+ 25 jaar = 25
+ 50 jaar (standaard) = 50
+ 100 jaar (monumenten/infra) = 100
+@end
+
+'KFI — gevolgklasse factor (Tabel NB.A1.1)
+#if CC ≡ 1
+ K_FI = 0.9
+#else if CC ≡ 2
+ K_FI = 1.0
+#else
+ K_FI = 1.1
+#end if
+
+'KFI = 'K_FI
+
+'3. Klimatologische uitgangspunten (NEN-EN 1991 + NB)
+
+@select WindGebied "Windgebied (NB)"
+ Gebied I (kust) = 1
+ Gebied II = 2
+ Gebied III (binnenland) = 3
+@end
+
+@select Terrein "Terreincategorie"
+ 0 — open zee = 0
+ I — meer/vlakte = 1
+ II — open gebied = 2
+ III — bebouwd gebied = 3
+ IV — stedelijk gebied = 4
+@end
+
+@select SneeuwBelasting "Sneeuwbelasting (kN/m²)"
+ s_k = 0.56 (NL standaard) = 0.56
+ s_k = 0.70 (verhoogd) = 0.70
+@end
+
+s_k = SneeuwBelasting kN/m^2
+'Karakteristieke sneeuwbelasting: sk = 's_k
+
+'4. Geotechnische uitgangspunten (NEN 9997-1)
+
+@select GrondType "Grondsoort"
+ Zand, vast = 1
+ Zand, los = 2
+ Klei, stijf = 3
+ Klei, slap = 4
+ Veen = 5
+ Op aanvraag = 6
+@end
+
+@select GeoCat "Geotechnische categorie"
+ GC1 — eenvoudig = 1
+ GC2 — standaard = 2
+ GC3 — complex = 3
+@end
+
+'5. Notities
+'Hier kun je vrije notities, scope-afbakeningen en projectspecifieke
+'aandachtspunten kwijt.
+
+'
+'Bij wijziging van bovenstaande uitgangspunten moeten alle gekoppelde
+'rekensheets opnieuw worden uitgevoerd om de update mee te nemen.
+`;
diff --git a/packages/desktop/src/templates/stalenGevelkolom.ts b/packages/desktop/src/templates/stalenGevelkolom.ts
new file mode 100644
index 0000000..3c8d3e5
--- /dev/null
+++ b/packages/desktop/src/templates/stalenGevelkolom.ts
@@ -0,0 +1,313 @@
+/**
+ * Toetsing van een stalen gevelkolom op windbelasting + normaalkracht +
+ * kip volgens EN 1993-1-1 §6.2 + §6.3.2.4 (simplified slenderness method).
+ *
+ * Profielen 1–18: IPE 80 t/m IPE 600
+ * Profielen 19–42: HEA 100 t/m HEA 1000
+ *
+ * Doorsnede-eigenschappen volgens "Stahl im Hochbau" / steelconstruction.info,
+ * eenheden hieronder in mm / mm² / mm³ / mm⁴.
+ */
+
+export const stalenGevelkolom = `"Stalen gevelkolom — toetsing wind + N + kip
+
+'Toetsing van een enkelvoudig opgelegde stalen gevelkolom belast door
+'winddruk + axiaal, inclusief kip via de vereenvoudigde slankheidsmethode
+'(EN 1993-1-1 §6.3.2.4).
+
+# 1. Profielkeuze
+
+@select profile "Staalprofiel"
+ IPE 80 = 1
+ IPE 100 = 2
+ IPE 120 = 3
+ IPE 140 = 4
+ IPE 160 = 5
+ IPE 180 = 6
+ IPE 200 = 7
+ IPE 220 = 8
+ IPE 240 = 9
+ IPE 270 = 10
+ IPE 300 = 11
+ IPE 330 = 12
+ IPE 360 = 13
+ IPE 400 = 14
+ IPE 450 = 15
+ IPE 500 = 16
+ IPE 550 = 17
+ IPE 600 = 18
+ HEA 100 = 19
+ HEA 120 = 20
+ HEA 140 = 21
+ HEA 160 = 22
+ HEA 180 = 23
+ HEA 200 = 24
+ HEA 220 = 25
+ HEA 240 = 26
+ HEA 260 = 27
+ HEA 280 = 28
+ HEA 300 = 29
+ HEA 320 = 30
+ HEA 340 = 31
+ HEA 360 = 32
+ HEA 400 = 33
+ HEA 450 = 34
+ HEA 500 = 35
+ HEA 550 = 36
+ HEA 600 = 37
+ HEA 650 = 38
+ HEA 700 = 39
+ HEA 800 = 40
+ HEA 900 = 41
+ HEA 1000 = 42
+@end
+
+@select staalkwaliteit "Staalkwaliteit"
+ S235 = 235
+ S275 = 275
+ S355 = 355
+@end
+
+f_y = staalkwaliteit N/mm^2
+γ_M0 = 1.0
+γ_M1 = 1.0
+
+#hide
+'Profielmatrix — kolomvolgorde:
+' 1: id 2: h(mm) 3: b(mm) 4: t_w(mm)
+' 5: t_f(mm) 6: A(mm²) 7: W_el,y(mm³) 8: I_y(mm⁴)
+profiles = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18; 19; 20; 21; 22; 23; 24; 25; 26; 27; 28; 29; 30; 31; 32; 33; 34; 35; 36; 37; 38; 39; 40; 41; 42 |80; 100; 120; 140; 160; 180; 200; 220; 240; 270; 300; 330; 360; 400; 450; 500; 550; 600; 96; 114; 133; 152; 171; 190; 210; 230; 250; 270; 290; 310; 330; 350; 390; 440; 490; 540; 590; 640; 690; 790; 890; 990 |46; 55; 64; 73; 82; 91; 100; 110; 120; 135; 150; 160; 170; 180; 190; 200; 210; 220; 100; 120; 140; 160; 180; 200; 220; 240; 260; 280; 300; 300; 300; 300; 300; 300; 300; 300; 300; 300; 300; 300; 300; 300 |3.8; 4.1; 4.4; 4.7; 5.0; 5.3; 5.6; 5.9; 6.2; 6.6; 7.1; 7.5; 8.0; 8.6; 9.4; 10.2; 11.1; 12.0; 5.0; 5.0; 5.5; 6.0; 6.0; 6.5; 7.0; 7.5; 7.5; 8.0; 8.5; 9.0; 9.5; 10.0; 11.0; 11.5; 12.0; 12.5; 13.0; 13.5; 14.5; 15.0; 16.0; 16.5 |5.2; 5.7; 6.3; 6.9; 7.4; 8.0; 8.5; 9.2; 9.8; 10.2; 10.7; 11.5; 12.7; 13.5; 14.6; 16.0; 17.2; 19.0; 8.0; 8.0; 8.5; 9.0; 9.5; 10.0; 11.0; 12.0; 12.5; 13.0; 14.0; 15.5; 16.5; 17.5; 19.0; 21.0; 23.0; 24.0; 25.0; 26.0; 27.0; 28.0; 30.0; 31.0 |764; 1030; 1320; 1640; 2010; 2390; 2850; 3340; 3910; 4590; 5380; 6260; 7270; 8450; 9880; 11600; 13400; 15600; 2120; 2530; 3140; 3880; 4530; 5380; 6430; 7680; 8680; 9730; 11300; 12400; 13300; 14300; 15900; 17800; 19800; 21200; 22600; 24200; 26000; 28600; 32000; 34700 |20000; 34200; 53000; 77300; 109000; 146000; 194000; 252000; 324000; 429000; 557000; 713000; 904000; 1156000; 1500000; 1928000; 2441000; 3069000; 73000; 106000; 156000; 220000; 294000; 389000; 515000; 675000; 836000; 1013000; 1260000; 1479000; 1678000; 1891000; 2311000; 2896000; 3550000; 4146000; 4787000; 5474000; 6241000; 7682000; 9485000; 11190000 |801000; 1710000; 3180000; 5410000; 8690000; 13170000; 19430000; 27720000; 38920000; 57900000; 83560000; 117700000; 162700000; 231300000; 337400000; 482000000; 671200000; 920800000; 3490000; 6060000; 10330000; 16730000; 25100000; 36920000; 54100000; 77630000; 104500000; 136700000; 182600000; 229300000; 276900000; 330900000; 450700000; 637200000; 869700000; 1119000000; 1412000000; 1752000000; 2153000000; 3034000000; 4221000000; 5538000000]
+
+h = hlookup(profiles; profile; 1; 2)*mm
+b_profile = hlookup(profiles; profile; 1; 3)*mm
+t_w = hlookup(profiles; profile; 1; 4)*mm
+t_f = hlookup(profiles; profile; 1; 5)*mm
+A = hlookup(profiles; profile; 1; 6)*mm^2
+W_el,y = hlookup(profiles; profile; 1; 7)*mm^3
+I_y = hlookup(profiles; profile; 1; 8)*mm^4
+#show
+
+'Gekozen profieleigenschappen
+h
+b_profile
+t_w
+t_f
+A
+W_el,y
+I_y
+
+# 2. Geometrie en belastingbreedte
+
+L = ?*(m)', verdiepingshoogte (m) — kniklengte van de kolom'
+b_belast = ?*(m)', belastingbreedte (m) — c.t.c. tussen kolommen'
+n_kipsteunen = ?', aantal tussenliggende kipsteunen (regels/vloeren)'
+
+L_LT = L/(n_kipsteunen + 1)', afstand tussen kipsteunen — lengte voor kipcontrole'
+
+# 3. Windbelasting
+
+q_wind = ?*(kN/m^2)', wind-rekenwaarde uit project-uitgangspunten'
+
+q_line = q_wind*b_belast', lijnlast op de kolom (kN/m)'
+
+# 4. Optredende krachten
+
+N_Ed = ?*(kN)', axiale rekenwaarde — drukkracht door bovenliggende verdiepingen'
+M_Ed = q_line*L^2/8', buigend moment in het midden bij gelijkmatige lijnlast'
+V_Ed = q_line*L/2', dwarskracht aan de oplegging'
+
+# 5. Schema en M/V-lijnen
+
+'5.1 Belastingsschema
+#hide
+q_arrows_n = 8', aantal q-pijltjes in het schema
+q_x_left = 60
+q_x_top = 40
+q_y_top = q_x_top + 0
+q_y_bottom = 360
+col_x = 200
+arrow_len = 80
+#show
+'
+'
+'
+'
+'
+#for i = 0 : q_arrows_n
+'
+'
+#loop
+'
+' q = 'q_line'
+' L = 'L'
+' N = 'N_Ed'
+'
+'
+' bbelast = 'b_belast'
+' '
+
+'5.2 M-lijn (buigend moment) en V-lijn (dwarskracht)
+#hide
+dia_x_left = 50
+dia_x_right = 430
+dia_mid = (dia_x_left + dia_x_right)/2
+mline_y_base = 100
+mline_y_max = 50
+vline_y_base = 280
+vline_y_max = 50
+#show
+'
+' M-lijn
+'
+'
+'
+'
+' MEd = 'M_Ed'
+' parabool — max in het midden
+' V-lijn
+'
+'
+'
+'
+'
+' +VEd = 'V_Ed'
+' −VEd = 'V_Ed'
+' '
+
+# 6. Toetsing buiging — §6.2.5
+
+M_c,Rd = W_el,y*f_y/γ_M0' (elastische weerstand om y-as)
+UC_M = M_Ed/M_c,Rd
+
+#if UC_M ≤ 1.0
+ 'UCM = 'UC_M = M_Ed/M_c,Rd' ≤ 1.0 → Voldoet
+#else
+ 'UCM = 'UC_M = M_Ed/M_c,Rd' > 1.0 → Voldoet NIET
+#end if
+
+# 7. Toetsing dwarskracht — §6.2.6
+
+A_v = A - 2*b_profile*t_f + (t_w + 2*0)*t_f', schuifvlak (vereenvoudigd voor I-profielen)
+V_pl,Rd = A_v*(f_y/sqrt(3))/γ_M0
+UC_V = V_Ed/V_pl,Rd
+
+#if UC_V ≤ 1.0
+ 'UCV = 'UC_V = V_Ed/V_pl,Rd' ≤ 1.0 → Voldoet
+#else
+ 'UCV = 'UC_V = V_Ed/V_pl,Rd' > 1.0 → Voldoet NIET
+#end if
+
+# 8. Toetsing druk — §6.2.4
+
+N_c,Rd = A*f_y/γ_M0
+UC_N = N_Ed/N_c,Rd
+
+#if UC_N ≤ 1.0
+ 'UCN = 'UC_N = N_Ed/N_c,Rd' ≤ 1.0 → Voldoet
+#else
+ 'UCN = 'UC_N = N_Ed/N_c,Rd' > 1.0 → Voldoet NIET
+#end if
+
+# 9. Kipcontrole — §6.3.2.4 (vereenvoudigde slankheidsmethode)
+
+'Vereenvoudigde methode voor de kniklengte tussen de kipsteunen.
+'kc = 0,94 (uniform belaste, simpel opgelegde lengte tussen steunen, Tabel 6.6).
+'kfl = 1,10 (verlaagde drukflens — Aanbevolen waarde NB).
+
+#hide
+ε = sqrt(235 N/mm^2/f_y)
+λ_1 = 93.9*ε
+A_f = b_profile*t_f', flensoppervlak (compressie flens)
+A_w_eff = (h - 2*t_f)*t_w/3', 1/3 van het web bijdragend aan de "T"
+i_f,z = sqrt((b_profile^3*t_f/12)/(A_f + A_w_eff))', traagheidsstraal van flens+1/3web om z-as
+k_c = 0.94
+k_fl = 1.10
+λ_c0 = 0.5
+#show
+
+'LLT = L / (nkipsteunen + 1) = 'L_LT' (afstand tussen kipsteunen)'
+
+i_f,z
+λ_f = (k_c*L_LT)/(i_f,z*λ_1)', §6.3.2.4 (5)
+'λ̄f = 'λ_f' (slankheid drukflens)'
+
+#if λ_f ≤ λ_c0
+ 'λ̄f ≤ λ̄c0 = 0,5 → geen kipcontrole nodig : Mb,Rd = Mc,Rd
+ M_b,Rd = M_c,Rd
+#else
+ 'λ̄f > λ̄c0 = 0,5 → kipcontrole nodig
+ #if h/b_profile ≤ 2
+ α_LT = 0.34', buigingsknik-kromme b (Tabel 6.5, h/b ≤ 2)
+ #else
+ α_LT = 0.49', buigingsknik-kromme c (Tabel 6.5, h/b > 2)
+ #end if
+ φ_LT = 0.5*(1 + α_LT*(λ_f - 0.2) + λ_f^2)
+ χ_LT = 1/(φ_LT + sqrt(φ_LT^2 - λ_f^2))
+ #if χ_LT > 1.0
+ χ_LT = 1.0
+ #end if
+ 'χLT = 'χ_LT
+ M_b,Rd = k_fl*χ_LT*W_el,y*f_y/γ_M1
+#end if
+
+UC_LT = M_Ed/M_b,Rd
+#if UC_LT ≤ 1.0
+ 'UCkip = MEd /Mb,Rd = 'UC_LT' ≤ 1.0 → Voldoet
+#else
+ 'UCkip = MEd /Mb,Rd = 'UC_LT' > 1.0 → Voldoet NIET
+#end if
+
+# 10. Verplaatsingstoets (BGT) — §7.2 + NEN-EN 1993-1-1 NB Tabel B.1
+
+'Toetsing van horizontale verplaatsing onder karakteristieke windbelasting
+'(BGT karakteristieke combinatie). Voor gevelelementen is de gangbare grens
+'δ ≤ L/300; voor algemene constructies δ ≤ L/250.
+
+E = 210000 N/mm^2', E-modulus staal'
+
+q_wind,kar = q_wind/1.5', terug naar karakteristieke wind (γ_Q,wind = 1.5)'
+q_kar,line = q_wind,kar*b_belast
+
+δ_max = 5*q_kar,line*L^4/(384*E*I_y)', maximale doorbuiging midden bij UDL'
+
+@select VerplGrens "Verplaatsingsgrens"
+ L/300 (gevel) = 300
+ L/250 (algemeen) = 250
+ L/200 (ruimer) = 200
+@end
+
+δ_lim = L/VerplGrens
+
+UC_δ = δ_max/δ_lim
+
+#if UC_δ ≤ 1.0
+ 'UCδ = δmax /δlim = 'UC_δ' ≤ 1.0 → Voldoet
+#else
+ 'UCδ = δmax /δlim = 'UC_δ' > 1.0 → Voldoet NIET
+#end if
+
+# 11. Gecombineerde toetsing — §6.2.9 + §6.3.3 (vereenvoudigd, klasse 3)
+
+UC_combi = N_Ed/N_c,Rd + M_Ed/M_b,Rd
+
+#if UC_combi ≤ 1.0
+ 'UCM+N+kip = 'UC_combi' ≤ 1.0 → Voldoet
+#else
+ 'UCM+N+kip = 'UC_combi' > 1.0 → Voldoet NIET
+#end if
+
+'
+'Vereenvoudigingen in deze toetsing:
+'
+'Kniklengte = staaflengte L (geen aparte knikcontrole §6.3.1) — voor een
+'gevelkolom met c.t.c. vloer is de kniklengte meestal gelijk aan de
+'verdiepingshoogte.
+'Kipcontrole via §6.3.2.4 vereenvoudigde slankheidsmethode i.p.v. de
+'gedetailleerde §6.3.2.2 Mcr -berekening. Conservatief voor
+'rolprofielen onder gelijkmatig belasting.
+'Dwarskrachtreductie van de momentcapaciteit (§6.2.8) is verwaarloosd
+'omdat UCV meestal < 0.5 blijft.
+'Elastische weerstand (klasse 3) — voor klasse 1/2 kun je Wpl,y gebruiken
+'in plaats van Wel,y voor circa 10–15% extra capaciteit.
+'
+`;
diff --git a/packages/desktop/src/templates/verticaalWindverband.ts b/packages/desktop/src/templates/verticaalWindverband.ts
new file mode 100644
index 0000000..8c7fb82
--- /dev/null
+++ b/packages/desktop/src/templates/verticaalWindverband.ts
@@ -0,0 +1,226 @@
+/**
+ * Toetsing van een verticaal windverband (vertical bracing) — trek-only.
+ *
+ * Conform praktijk: een windverband-diagonaal werkt als trekstaaf. Het
+ * profiel is meestal een strip (platte staal) of een hoeklijn. De
+ * "drukdiagonaal" in een X-kruis wordt verwaarloosd (slap).
+ *
+ * Toets: §6.2.3 trek + net cross-section bij boutaansluiting + §7.2 BGT.
+ */
+
+export const verticaalWindverband = `"Verticaal windverband — trekstaaftoetsing
+
+'Toetsing van de trekstaaf (strip of hoeklijn) in een verticaal windverband.
+'De getrokken diagonaal voert de horizontale wind-shear af naar de fundering;
+'de tegenovergestelde diagonaal in een X-kruis wordt slap en niet meegerekend.
+'Toets volgens EN 1993-1-1 §6.2.3 (trek, bruto + netto doorsnede).
+
+# 1. Profielkeuze
+
+@select verbandtype "Verbandtype"
+ Enkele diagonaal = 1
+ X-kruis (trek-only) = 2
+@end
+
+@select profile "Profiel (id)"
+ Strip 30×5 (A = 150 mm²) = 1
+ Strip 40×5 (A = 200 mm²) = 2
+ Strip 50×5 (A = 250 mm²) = 3
+ Strip 60×5 (A = 300 mm²) = 4
+ Strip 60×8 (A = 480 mm²) = 5
+ Strip 60×10 (A = 600 mm²) = 6
+ Strip 80×6 (A = 480 mm²) = 7
+ Strip 80×8 (A = 640 mm²) = 8
+ Strip 80×10 (A = 800 mm²) = 9
+ Strip 100×8 (A = 800 mm²) = 10
+ Strip 100×10 (A = 1000 mm²) = 11
+ Strip 100×12 (A = 1200 mm²) = 12
+ Strip 120×10 (A = 1200 mm²) = 13
+ Strip 120×12 (A = 1440 mm²) = 14
+ Strip 150×12 (A = 1800 mm²) = 15
+ L 30×30×3 (A = 175 mm²) = 16
+ L 40×40×4 (A = 308 mm²) = 17
+ L 50×50×5 (A = 480 mm²) = 18
+ L 50×50×6 (A = 569 mm²) = 19
+ L 60×60×6 (A = 691 mm²) = 20
+ L 60×60×8 (A = 905 mm²) = 21
+ L 70×70×7 (A = 940 mm²) = 22
+ L 80×80×8 (A = 1230 mm²) = 23
+ L 80×80×10 (A = 1510 mm²) = 24
+ L 90×90×9 (A = 1550 mm²) = 25
+ L 100×100×10 (A = 1920 mm²) = 26
+ L 100×100×12 (A = 2270 mm²) = 27
+ L 120×120×12 (A = 2750 mm²) = 28
+ L 150×150×12 (A = 3480 mm²) = 29
+ L 150×150×15 (A = 4300 mm²) = 30
+@end
+
+@select staalkwaliteit "Staalkwaliteit"
+ S235 = 235
+ S275 = 275
+ S355 = 355
+@end
+
+f_y = staalkwaliteit N/mm^2
+f_u = if(staalkwaliteit ≡ 235; 360; if(staalkwaliteit ≡ 275; 430; 510)) N/mm^2', treksterkte EN 10025'
+γ_M0 = 1.0
+γ_M2 = 1.25', voor netto doorsnede met boutgat'
+
+#hide
+'Profielmatrix: [id | A (mm²)]
+profiles = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18; 19; 20; 21; 22; 23; 24; 25; 26; 27; 28; 29; 30 |150; 200; 250; 300; 480; 600; 480; 640; 800; 800; 1000; 1200; 1200; 1440; 1800; 175; 308; 480; 569; 691; 940; 905; 1230; 1510; 1550; 1920; 2270; 2750; 3480; 4300]
+A = hlookup(profiles; profile; 1; 2)*mm^2
+#show
+
+'Gekozen profiel
+A
+
+# 2. Geometrie van de stabiliteitsbeun
+
+b = ?*(m)', breedte van de beun (h.o.h. tussen kolommen)'
+h = ?*(m)', verdiepingshoogte van de beun'
+
+L_d = sqrt(b^2 + h^2)', diagonaal-systemlengte'
+α = atan(h/b)', hoek van de diagonaal met de horizontale (rad)'
+α_deg = α*180/pi
+cos_α = b/L_d
+sin_α = h/L_d
+
+# 3. Windkracht op de beun
+
+F_h = ?*(kN)', totale horizontale wind-rekenwaarde op deze beun (UGT)'
+
+# 4. Kracht in de getrokken diagonaal (ontbinding)
+
+#if verbandtype ≡ 1
+ 'Enkele diagonaal — neemt de volledige horizontale kracht op
+ type_label = "enkele diagonaal"
+#else
+ 'X-kruis trek-only — slechts één diagonaal werkt per windrichting
+ type_label = "X-kruis (trek-only)"
+#end if
+
+'De horizontale wind Fh wordt langs de diagonaal ontbonden:
+' Ft,Ed = Fh / cos α (langs de diagonaal)
+' Fv = Ft,Ed · sin α (verticaal — drukt op kolom + fundering)
+
+F_t,Ed = F_h/cos_α', trekkracht in de werkende diagonaal'
+F_v = F_t,Ed*sin_α', verticale reactiecomponent in de getrokken hoekpunten'
+
+# 5. Schema-tekening — aanzicht in XZ-vlak
+
+'Rechthoekige stabiliteitsbeun met kruis-windverband. Aan de top wordt
+'Fh aangebracht (rood). De getrokken diagonaal (groen, dik) voert
+'de kracht af; de tegenovergestelde diagonaal (lichtgrijs gestippeld) wordt
+'slap. Rechts naast het kruis is het krachtdriehoekje getoond met de
+'ontbinding Ft = √(Fh ² + Fv ²).
+
+#hide
+sw = 260', breedte van het frame in svg-pixels
+sh = 200', hoogte
+x0 = 70
+x1 = x0 + sw
+y0 = 40
+y1 = y0 + sh
+mid_x = (x0 + x1)/2
+mid_y = (y0 + y1)/2
+'Krachtdriehoek rechts van de beun
+tri_x = x1 + 50
+tri_y = mid_y
+tri_w = 70
+tri_h = tri_w*sh/sw
+#show
+'
+'
+'
+'
+'
+'
+'
+'
+'
+' Ft,Ed
+' slap
+'
+'
+' Fh
+' b = 'b'
+' h = 'h'
+' type: 'type_label' · α = 'α_deg'°
+'
+'
+'
+' Fh
+'
+'
+' Fv
+'
+'
+' Ft,Ed
+'
+' Krachtdriehoek
+'
+'
+'
+' X
+'
+'
+' Z
+'
+' '
+
+'Krachtwaarden uit de ontbinding:
+' Fh = 'F_h' (toegepaste wind, horizontaal)
+' Ft,Ed = Fh / cos α = 'F_t,Ed'
+' Fv = Ft,Ed · sin α = 'F_v'
+
+# 6. Trekcontrole — §6.2.3 (bruto doorsnede)
+
+N_pl,Rd = A*f_y/γ_M0
+UC_t = F_t,Ed/N_pl,Rd
+
+#if UC_t ≤ 1.0
+ 'UCt,bruto = Ft,Ed /Npl,Rd = 'UC_t' ≤ 1.0 → Voldoet
+#else
+ 'UCt,bruto = Ft,Ed /Npl,Rd = 'UC_t' > 1.0 → Voldoet NIET
+#end if
+
+# 7. Netto doorsnede bij boutaansluiting — §6.2.3 (2)
+
+'Verlaag de bruto-doorsnede met boutgaten. Bij excentrische aansluiting
+'van een hoeklijn aan één been past §3.10.3 (effectieve netto doorsnede)
+'extra reductiefactoren — niet in deze sheet.
+
+d_bout = ?*(mm)', boutgatdiameter (0 = geen bouten / volledig gelast)'
+n_bouten = ?', aantal boutgaten in dezelfde dwarsdoorsnede'
+t_aansluit = ?*(mm)', dikte op de aansluiting (strip-dikte of hoeklijn-been)'
+
+A_net = A - n_bouten*d_bout*t_aansluit
+N_u,Rd = 0.9*A_net*f_u/γ_M2', §6.2.3 (2) netto-doorsnede met γ_M2'
+
+#if A_net ≤ 0
+ 'Aantal/diameter bouten te groot: Anet ≤ 0
+ UC_net = 9.99
+#else
+ UC_net = F_t,Ed/N_u,Rd
+ A_net
+ N_u,Rd
+ #if UC_net ≤ 1.0
+ 'UCt,netto = Ft,Ed /Nu,Rd = 'UC_net' ≤ 1.0 → Voldoet
+ #else
+ 'UCt,netto = Ft,Ed /Nu,Rd = 'UC_net' > 1.0 → Voldoet NIET
+ #end if
+#end if
+
+'
+'Vereenvoudigingen en aandachtspunten:
+'
+'Trek-only ontwerp — drukcontrole en BGT-verplaatsing zijn bewust niet
+'opgenomen. Geldig voor strippen en niet-stijve hoeklijnen die onder druk
+'slap worden.
+'Bij hoeklijn aangesloten op één been geldt §3.10.3 — effectieve netto
+'doorsnede (β-factoren). Sheet rekent met volle Anet .
+'Verbindingen niet getoetst — bouten op afschuiving, blok-shear,
+'lasdoorsneden uit EN 1993-1-8.
+'
+`;