Skip to content

Commit 9f5c914

Browse files
DutchSailorclaude
andauthored
v0.1.4 — #break, solvers, project/library sidebar, gevelkolom + windverband (#7)
* feat(core): #break, |→to, ≤≥≡≠, vector index .(expr)/.i, iterative solvers Brings the engine in line with real-world CalcPAD usage: - `#break` is now a true loop-exit (BreakNode in AST, scope-flag in evaluator). The Intertek 2259 foundation calc now produces the same pile-type choices [5, 8, 4, 5] as the CalcPAD PDF, which previously iterated all 9 attempts and ended on type 9. - `expr | unit` is now interpreted as the CalcPAD target-unit operator → mathjs `expr to unit`. Eliminated the NaN cascade in `p_ground`. - `≤ ≥ ≡ ≠` symbols normalized to `<= >= == !=` for ALL expressions (was conditional-only). - Vector-index `name.(expr)` and `name.i` (single-letter index) now rewrite to `name[expr]` / `name[i]`. Fixes the 5.1 drawFloor schema where `aaa1.i` / `aaa1.(i+1)` were folded to a single identifier. - Iterative solver directives lifted into helper functions: `$Find{f(x) @ x = lo:hi}` → bisection `$Root{...}` → alias for $Find `$Solve{f(x) @ x = guess}` → Newton-Raphson `$Sup{f(x) @ x = lo:hi}` → golden-section max `$Inf{f(x) @ x = lo:hi}` → golden-section min Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(preview): CalcPAD-look formules + geen horizontale slider - `unitPartToLatex` herschreven zonder mathjs round-trip — geen leading "1" meer voor units. `235 N/mm²` ipv `235 1 N/mm²`. - `.calc-line` heeft niet meer de grijze achtergrond + blauwe linker- streep. Foutregels behouden hun rode visuele indicator. - `.calc-preview-content` zet `overflow-x: hidden` + `overflow-wrap: anywhere`. Lange KaTeX-formules wrappen naar de volgende regel ipv een horizontale scrollbar te tonen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(desktop): Project + Library secties, projectgegevens, gevelkolom, windverband ProjectBrowser splitst nu in twee secties met section-headers: • PROJECT (boven): klikbare projectgegevens + de calc-sheets • LIBRARY (onder): Books / Standards / CalcPAD-voorbeelden Nieuwe sheets onder Project: • Projectgegevens — Eurocode-uitgangspunten (CC-klasse, K_FI, windgebied, terreincategorie, sneeuwbelasting, grondsoort, geotechnische categorie) via @select dropdowns. • Stalen gevelkolom (wind + N + kip) — alle 42 IPE/HEA, matrix- lookup van h, b, t_w, t_f, A, W_el,y, I_y. Toets buiging §6.2.5, dwarskracht §6.2.6, druk §6.2.4, kip §6.3.2.4 (vereen- voudigde slankheidsmethode met n_kipsteunen), gecombineerd §6.2.9, en BGT-verplaatsing §7.2. Schema + M/V-lijn SVG. • Verticaal windverband — trekstaaftoetsing (strip of hoeklijn). Schema is een rechthoek + kruis (XZ-aanzicht) met krachtdriehoek die de wind ontbindt in F_h, F_v en F_t,Ed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(desktop): persist source + load case values, bump to 0.1.4 documentStore (source + filePath) en loadCaseStore (cases + activeId + valuesByCase) hydrateren nu uit de Tauri-store bij app-boot en auto-saven debounced (250–500 ms) bij elke wijziging. Open je calc na herstart en je dropdown-/prompt-keuzes staan er weer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c70b446 commit 9f5c914

18 files changed

Lines changed: 1048 additions & 48 deletions

packages/core/src/evaluator.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,71 @@ math.import(
179179
{ override: true },
180180
);
181181

182+
// ── Iterative numerical solvers (used by parser's $Find/$Solve/$Sup/$Inf
183+
// rewrite). The `fn` argument is a mathjs user-defined function in scope;
184+
// we call it via `Number(fn(x))` and run a robust scalar algorithm.
185+
math.import(
186+
{
187+
/** Bisection root-finder for f(x) = 0 in [lo, hi]. */
188+
_find_root: function (fn: unknown, lo: unknown, hi: unknown) {
189+
if (typeof fn !== 'function') return Number.NaN;
190+
const evalAt = (x: number): number => Number((fn as (n: number) => unknown)(x));
191+
let a = Number(lo); let b = Number(hi);
192+
if (a > b) [a, b] = [b, a];
193+
let fa = evalAt(a); let fb = evalAt(b);
194+
if (!isFinite(fa) || !isFinite(fb)) return Number.NaN;
195+
if (fa === 0) return a;
196+
if (fb === 0) return b;
197+
if (fa * fb > 0) return Number.NaN;
198+
for (let i = 0; i < 80; i++) {
199+
const c = (a + b) / 2;
200+
const fc = evalAt(c);
201+
if (Math.abs(fc) < 1e-12 || (b - a) < 1e-14) return c;
202+
if (fa * fc < 0) { b = c; fb = fc; } else { a = c; fa = fc; }
203+
}
204+
return (a + b) / 2;
205+
},
206+
/** Newton-Raphson root-finder for f(x) = 0 starting from `guess`. */
207+
_solve_newton: function (fn: unknown, guess: unknown) {
208+
if (typeof fn !== 'function') return Number.NaN;
209+
const evalAt = (x: number): number => Number((fn as (n: number) => unknown)(x));
210+
let x = Number(guess);
211+
const h = 1e-7;
212+
for (let i = 0; i < 60; i++) {
213+
const f = evalAt(x);
214+
if (!isFinite(f)) return Number.NaN;
215+
if (Math.abs(f) < 1e-12) return x;
216+
const fp = (evalAt(x + h) - f) / h;
217+
if (!isFinite(fp) || Math.abs(fp) < 1e-15) break;
218+
const dx = f / fp;
219+
x = x - dx;
220+
if (Math.abs(dx) < 1e-12) return x;
221+
}
222+
return x;
223+
},
224+
/** Golden-section extremum over [lo, hi]; sign=+1 → sup, -1 → inf. */
225+
_extremum: function (fn: unknown, lo: unknown, hi: unknown, sign: unknown) {
226+
if (typeof fn !== 'function') return Number.NaN;
227+
const s = Number(sign) >= 0 ? 1 : -1;
228+
const evalAt = (x: number): number => s * Number((fn as (n: number) => unknown)(x));
229+
const phi = (Math.sqrt(5) - 1) / 2;
230+
let a = Number(lo); let b = Number(hi);
231+
if (a > b) [a, b] = [b, a];
232+
let c = b - (b - a) * phi;
233+
let d = a + (b - a) * phi;
234+
for (let i = 0; i < 80; i++) {
235+
if (evalAt(c) > evalAt(d)) b = d; else a = c;
236+
c = b - (b - a) * phi;
237+
d = a + (b - a) * phi;
238+
if ((b - a) < 1e-14) break;
239+
}
240+
const xOpt = (a + b) / 2;
241+
return Number((fn as (n: number) => unknown)(xOpt));
242+
},
243+
},
244+
{ override: true },
245+
);
246+
182247
export interface Scope {
183248
[key: string]: unknown;
184249
}
@@ -197,10 +262,16 @@ export function evaluate(nodes: AstNode[], selectValues?: SelectValues): Evaluat
197262
return evaluateNodes(nodes, scope, selectValues || {});
198263
}
199264

265+
/** Sentinel key on `scope` used by `#break` to short-circuit out of a loop. */
266+
const BREAK_FLAG = '__break__';
267+
200268
function evaluateNodes(nodes: AstNode[], scope: Scope, selectValues: SelectValues): EvaluatedNode[] {
201269
const result: EvaluatedNode[] = [];
202270

203271
for (const node of nodes) {
272+
// `#break` raised by a deeper node — stop evaluating siblings. The
273+
// enclosing `repeat` case picks up the flag and exits the loop.
274+
if (scope[BREAK_FLAG]) break;
204275
switch (node.type) {
205276
case 'heading':
206277
if (node.hidden) break;
@@ -300,6 +371,12 @@ function evaluateNodes(nodes: AstNode[], scope: Scope, selectValues: SelectValue
300371
break;
301372
}
302373

374+
case 'break': {
375+
// Set the break flag — the enclosing repeat case consumes it.
376+
scope[BREAK_FLAG] = true;
377+
break;
378+
}
379+
303380
case 'repeat': {
304381
let count = 0;
305382
try {
@@ -315,6 +392,12 @@ function evaluateNodes(nodes: AstNode[], scope: Scope, selectValues: SelectValue
315392
scope['_i'] = iter;
316393
const children = evaluateNodes(node.body, scope, selectValues);
317394
if (!node.hidden) result.push(...children);
395+
if (scope[BREAK_FLAG]) {
396+
// Iteration stopped — consume the flag so outer loops aren't
397+
// also broken out of.
398+
delete scope[BREAK_FLAG];
399+
break;
400+
}
318401
}
319402
break;
320403
}

packages/core/src/latex.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -262,20 +262,25 @@ export function resultToLatex(numStr: string, unitStr: string): string {
262262
return `${num} \\; ${unitPartToLatex(unitStr)}`;
263263
}
264264

265-
/** Format a unit string like "N / mm^2" as LaTeX */
265+
/** Format a unit string like "N / mm^2" or "kN m" directly as LaTeX. */
266266
export function unitPartToLatex(unitStr: string): string {
267-
// Handle compound units like "N / mm^2" or "kN m"
268-
// Parse as a mathjs expression to get proper LaTeX
269-
try {
270-
// Wrap in "1 unit" so mathjs parses it as a unit expression
271-
const node = parse(`1 ${unitStr}`);
272-
const latex = nodeToLatex(node);
273-
// Remove the leading "1 \;" from the result
274-
return latex.replace(/^1\s*\\[;,]\s*/, '').replace(/^1\s+/, '');
275-
} catch {
276-
// Fallback: simple text rendering
277-
return `\\text{${unitStr.replace(/\^(\d+)/g, '}^{$1}\\text{')}}`;
278-
}
267+
// Direct converter — no mathjs round-trip (which prepends `1~` that's
268+
// hard to strip reliably). Tokens are identifiers optionally followed by
269+
// `^exp`. `/` introduces a denominator; space is implicit multiplication.
270+
const fmtToken = (tok: string): string => {
271+
const m = tok.match(/^([\p{L}_][\p{L}\p{N}_]*)(?:\^(-?\d+))?$/u);
272+
if (!m) return `\\mathrm{${tok}}`;
273+
if (!m[2]) return `\\mathrm{${m[1]}}`;
274+
return `{\\mathrm{${m[1]}}}^{${m[2]}}`;
275+
};
276+
const fmtGroup = (s: string): string =>
277+
s.trim().split(/\s+/).filter(Boolean).map(fmtToken).join('\\,');
278+
279+
const parts = unitStr.split('/').map((s) => s.trim()).filter(Boolean);
280+
if (parts.length === 0) return '';
281+
if (parts.length === 1) return fmtGroup(parts[0]);
282+
// a/b/c → a / (b·c)
283+
return `\\frac{${fmtGroup(parts[0])}}{${parts.slice(1).map(fmtGroup).join('\\,')}}`;
279284
}
280285

281286
/** Format a variable name as LaTeX */

packages/core/src/parser.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const BREAK_RE = /^#break\b/;
9292
// Match `$Plot{...}` even when CalcPAD has appended trailing persisted-input
9393
// data after the closing brace (e.g. `}\v2\t3`).
9494
const PLOT_RE = /^\$Plot\s*\{([^}]+)\}/;
95+
const SOLVER_RE = /\$(Find|Root|Solve|Sup|Inf)\s*\{([^}]+)\}/g;
9596
const TRAILING_DATA_RE = /^[\s\d.eE+\-,;]+$/; // pure numeric/whitespace line
9697

9798
// HTML wrappers CalcPAD uses around conditions / equation fragments.
@@ -364,12 +365,25 @@ function foldIdentifierDots(source: string): string {
364365
// attribute values must survive unchanged.
365366
const transformOutsideQuotes = (text: string): string => {
366367
let out = text;
368+
// CalcPAD vector index `name.(expr)` → `name[expr]`. Must precede dot-fold
369+
// so the parenthesised index isn't accidentally read as a member access.
370+
out = out.replace(
371+
/(?<![\p{L}\p{N}_.])([\p{L}_][\p{L}\p{N}_]*)\.\(([^()]*)\)/gu,
372+
(_m, name, expr) => `${name}[${expr}]`,
373+
);
367374
// CalcPAD vector index `name.digit` → `name[digit]`. Must precede the
368375
// identifier-cluster fold so `cc.3` isn't mistakenly read as `cc_3`.
369376
out = out.replace(
370377
/(?<![\p{L}\p{N}_.])([\p{L}_][\p{L}\p{N}_]*)\.(\d+)(?![\p{L}\p{N}_.])/gu,
371378
(_m, name, idx) => `${name}[${idx}]`,
372379
);
380+
// CalcPAD vector index by loop-variable: `name.i`, `name.j` (single
381+
// lowercase letter) → `name[i]`. Distinguishes from dotted identifiers
382+
// like `Cs.Cd` (uppercase / multi-char RHS) which still get folded.
383+
out = out.replace(
384+
/(?<![\p{L}\p{N}_.])([\p{L}_][\p{L}\p{N}_]*)\.([a-z])(?![\p{L}\p{N}_])/gu,
385+
(_m, name, idx) => `${name}[${idx}]`,
386+
);
373387
// Identifier-cluster fold: `Cs.Cd`, `F_0.9G50%TotalWeight` → underscores.
374388
out = out.replace(
375389
/(?<![\p{L}\p{N}_])([\p{L}_][\p{L}\p{N}_]*(?:[.%][\p{L}\p{N}_]+)+)(?![\p{L}\p{N}_])/gu,
@@ -513,6 +527,7 @@ export function parse(source: string, options: ParseOptions = {}): AstNode[] {
513527
foldIdentifierDots(stripFormatSpecs(foldSubscriptCommas(source))),
514528
),
515529
);
530+
source = rewriteSolverDirectives(source);
516531
// CalcPAD's `'` character toggles between CODE and PROSE on a single line.
517532
// Lines start in CODE mode. Each `'` flips mode. So:
518533
// a = ?', 'b = ? → CODE `a = ?` + PROSE `, ` + CODE `b = ?`
@@ -730,9 +745,13 @@ function parseLines(
730745
continue;
731746
}
732747

733-
// `#break` inside loops — not currently supported; emit as a no-op (the
734-
// iteration still completes, but the loop won't terminate early).
735-
if (BREAK_RE.test(trimmed)) { i++; continue; }
748+
// `#break` inside loops — emit a BreakNode that the evaluator's repeat
749+
// case uses as an early-exit signal (e.g. find-first-passing-pile-type).
750+
if (BREAK_RE.test(trimmed)) {
751+
nodes.push(markHidden({ type: 'break' }, state));
752+
i++;
753+
continue;
754+
}
736755

737756
// SVG block
738757
if (SVG_START_RE.test(trimmed)) {
@@ -918,11 +937,70 @@ function stripCondWrapping(cond: string): string {
918937
* π → pi
919938
* f(a; b; c) → f(a, b, c) (CalcPAD uses `;` as arg separator)
920939
*/
940+
/**
941+
* Lift CalcPAD iterative-solver directives out of expressions so they can
942+
* use mathjs user-functions internally.
943+
*
944+
* `$Find{f(x) @ x = lo : hi}` → bisection on f(x) = 0 in [lo, hi]
945+
* `$Root{...}` → alias for $Find
946+
* `$Solve{f(x) @ x = guess}` → Newton-Raphson from `guess`
947+
* `$Sup{f(x) @ x = lo : hi}` → golden-section search for the max
948+
* `$Inf{f(x) @ x = lo : hi}` → golden-section search for the min
949+
*
950+
* Each directive is replaced by a `_find_root` / `_solve_newton` /
951+
* `_extremum` call (registered in evaluator.ts as JS-backed math functions)
952+
* and an auxiliary `__solver_N(var) = body` function is emitted on a line
953+
* directly before the original.
954+
*/
955+
function rewriteSolverDirectives(source: string): string {
956+
const lines = source.split('\n');
957+
const out: string[] = [];
958+
let counter = 0;
959+
for (const line of lines) {
960+
const defs: string[] = [];
961+
const newLine = line.replace(SOLVER_RE, (match, op: string, inner: string) => {
962+
const atIdx = inner.lastIndexOf('@');
963+
if (atIdx === -1) return match;
964+
const body = inner.slice(0, atIdx).trim();
965+
const param = inner.slice(atIdx + 1).trim();
966+
const eqIdx = param.indexOf('=');
967+
if (eqIdx === -1) return match;
968+
const varName = param.slice(0, eqIdx).trim();
969+
const range = param.slice(eqIdx + 1).trim();
970+
counter += 1;
971+
const fnName = `__solver_${counter}`;
972+
defs.push(`${fnName}(${varName}) = ${body}`);
973+
if (op === 'Solve') {
974+
return `_solve_newton(${fnName}, ${range})`;
975+
}
976+
const colonIdx = range.indexOf(':');
977+
if (colonIdx === -1) return match;
978+
const lo = range.slice(0, colonIdx).trim();
979+
const hi = range.slice(colonIdx + 1).trim();
980+
if (op === 'Find' || op === 'Root') return `_find_root(${fnName}, ${lo}, ${hi})`;
981+
if (op === 'Sup') return `_extremum(${fnName}, ${lo}, ${hi}, 1)`;
982+
if (op === 'Inf') return `_extremum(${fnName}, ${lo}, ${hi}, -1)`;
983+
return match;
984+
});
985+
for (const d of defs) out.push(d);
986+
out.push(newLine);
987+
}
988+
return out.join('\n');
989+
}
990+
921991
function normalizeExpression(expr: string): string {
922992
return expr
923993
.replace(/\bsqr\b/g, 'sqrt')
924994
.replace(/\blg\b/g, 'log10')
925995
.replace(/π/g, 'pi')
996+
.replace(//g, '==')
997+
.replace(//g, '!=')
998+
.replace(//g, '>=')
999+
.replace(//g, '<=')
1000+
// CalcPAD `expr|unit` = target-unit conversion → mathjs `expr to unit`.
1001+
// Runs after matrix-rewrite so `[a;b|c;d]` matrices are already
1002+
// converted to nested arrays before this point.
1003+
.replace(/\|(\s*[\p{L}\p{N}_/\s^*-]+)$/u, ' to $1')
9261004
.replace(/;/g, ',');
9271005
}
9281006

packages/core/src/renderer.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,11 @@ export const defaultStyles = `
289289
}
290290
291291
.calc-line {
292-
background: #f8fafc;
293-
border-left: 3px solid #3b82f6;
294-
padding: 0.4em 1.2em;
295-
margin: 0.35em 0;
296-
border-radius: 0 6px 6px 0;
297-
overflow-x: auto;
292+
/* CalcPAD-stijl: geen achtergrond / linker streep — gewoon de formule. */
293+
padding: 0.15em 0;
294+
margin: 0.25em 0;
295+
/* Verbergt overflow zonder slider; KaTeX past zich via .calc-line .katex aan. */
296+
overflow-x: hidden;
298297
}
299298
300299
.calc-line .katex-display {
@@ -312,8 +311,10 @@ export const defaultStyles = `
312311
}
313312
314313
.calc-error {
315-
border-left-color: #dc2626;
314+
/* Fout-regels krijgen wel een lichte rood-tint zodat ze opvallen. */
316315
background: #fef2f2;
316+
border-left: 3px solid #dc2626;
317+
padding-left: 0.6em;
317318
}
318319
319320
.calc-error-msg {

packages/core/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ export interface GefUploadNode {
154154
hidden?: boolean;
155155
}
156156

157+
/** CalcPAD `#break` — early exit from the enclosing #repeat / #for loop. */
158+
export interface BreakNode {
159+
type: 'break';
160+
hidden?: boolean;
161+
}
162+
157163
export type AstNode =
158164
| HeadingNode
159165
| TextNode
@@ -165,6 +171,7 @@ export type AstNode =
165171
| RepeatNode
166172
| PlotNode
167173
| SvgNode
174+
| BreakNode
168175
| ImageNode
169176
| SelectNode
170177
| GefUploadNode;

packages/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@openaec/calculations-studio",
33
"private": true,
4-
"version": "0.1.3",
4+
"version": "0.1.4",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

packages/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "open-calculations-studio"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
description = "Open Calculations Studio - lightweight CalcPAD alternative for Eurocode verifications"
55
authors = ["OpenAEC Contributors"]
66
edition = "2021"

packages/desktop/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Open Calculations Studio",
4-
"version": "0.1.3",
4+
"version": "0.1.4",
55
"identifier": "studio.opencalculations.app",
66
"build": {
77
"beforeDevCommand": "npm run dev",

packages/desktop/src/components/calc/Preview.css

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,24 @@
1111
flex: 1 1 0;
1212
min-height: 0;
1313
overflow-y: auto;
14-
overflow-x: auto;
14+
/* Geen horizontale scrollbalk — content moet wrappen of clippen. */
15+
overflow-x: hidden;
1516
padding: 24px 32px;
1617
font-family: var(--font-body);
1718
color: var(--theme-text);
19+
/* Lange formules / tekst breken naar de volgende regel ipv te scrollen. */
20+
overflow-wrap: anywhere;
21+
word-break: break-word;
22+
}
23+
24+
/* KaTeX display-blokken: laat ze wrappen ipv één lange regel te forceren. */
25+
.calc-preview-content .calc-line,
26+
.calc-preview-content .katex-display {
27+
max-width: 100%;
28+
}
29+
30+
.calc-preview-content .katex-display {
31+
overflow-x: hidden;
1832
}
1933

2034
.calc-preview-content::-webkit-scrollbar {

0 commit comments

Comments
 (0)