Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/core/src/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
31 changes: 18 additions & 13 deletions packages/core/src/latex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
84 changes: 81 additions & 3 deletions packages/core/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
/(?<![\p{L}\p{N}_.])([\p{L}_][\p{L}\p{N}_]*)\.\(([^()]*)\)/gu,
(_m, name, expr) => `${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(
/(?<![\p{L}\p{N}_.])([\p{L}_][\p{L}\p{N}_]*)\.(\d+)(?![\p{L}\p{N}_.])/gu,
(_m, name, idx) => `${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(
/(?<![\p{L}\p{N}_.])([\p{L}_][\p{L}\p{N}_]*)\.([a-z])(?![\p{L}\p{N}_])/gu,
(_m, name, idx) => `${name}[${idx}]`,
);
// Identifier-cluster fold: `Cs.Cd`, `F_0.9G50%TotalWeight` → underscores.
out = out.replace(
/(?<![\p{L}\p{N}_])([\p{L}_][\p{L}\p{N}_]*(?:[.%][\p{L}\p{N}_]+)+)(?![\p{L}\p{N}_])/gu,
Expand Down Expand Up @@ -513,6 +527,7 @@ export function parse(source: string, options: ParseOptions = {}): AstNode[] {
foldIdentifierDots(stripFormatSpecs(foldSubscriptCommas(source))),
),
);
source = rewriteSolverDirectives(source);
// CalcPAD's `'` character toggles between CODE and PROSE on a single line.
// Lines start in CODE mode. Each `'` flips mode. So:
// a = ?', 'b = ? → CODE `a = ?` + PROSE `, ` + CODE `b = ?`
Expand Down Expand Up @@ -730,9 +745,13 @@ function parseLines(
continue;
}

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

// SVG block
if (SVG_START_RE.test(trimmed)) {
Expand Down Expand Up @@ -918,11 +937,70 @@ function stripCondWrapping(cond: string): string {
* π → pi
* f(a; b; c) → f(a, b, c) (CalcPAD uses `;` as arg separator)
*/
/**
* Lift CalcPAD iterative-solver directives out of expressions so they can
* use mathjs user-functions internally.
*
* `$Find{f(x) @ x = lo : hi}` → bisection on f(x) = 0 in [lo, hi]
* `$Root{...}` → alias for $Find
* `$Solve{f(x) @ x = guess}` → Newton-Raphson from `guess`
* `$Sup{f(x) @ x = lo : hi}` → golden-section search for the max
* `$Inf{f(x) @ x = lo : hi}` → golden-section search for the min
*
* Each directive is replaced by a `_find_root` / `_solve_newton` /
* `_extremum` call (registered in evaluator.ts as JS-backed math functions)
* and an auxiliary `__solver_N(var) = body` function is emitted on a line
* directly before the original.
*/
function rewriteSolverDirectives(source: string): string {
const lines = source.split('\n');
const out: string[] = [];
let counter = 0;
for (const line of lines) {
const defs: string[] = [];
const newLine = line.replace(SOLVER_RE, (match, op: string, inner: string) => {
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, ',');
}

Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -165,6 +171,7 @@ export type AstNode =
| RepeatNode
| PlotNode
| SvgNode
| BreakNode
| ImageNode
| SelectNode
| GefUploadNode;
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openaec/calculations-studio",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 15 additions & 1 deletion packages/desktop/src/components/calc/Preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading