diff --git a/web/src/components/Toolbar.svelte b/web/src/components/Toolbar.svelte index 6d41c0b0..875ad07b 100644 --- a/web/src/components/Toolbar.svelte +++ b/web/src/components/Toolbar.svelte @@ -197,7 +197,9 @@ } } const isPro = uiStore.analysisMode === 'pro'; - const results = modelStore.solve3D(uiStore.includeSelfWeight, uiStore.axisConvention3D === 'leftHand', isPro); + const versionAtStart = modelStore.modelVersion; + const results = await modelStore.solve3DAsync(uiStore.includeSelfWeight, uiStore.axisConvention3D === 'leftHand', isPro); + if (modelStore.modelVersion !== versionAtStart) return; // stale — user edited mid-solve if (typeof results === 'string') { uiStore.toast(results, 'error'); } else if (results) { diff --git a/web/src/components/toolbar/ToolbarResults.svelte b/web/src/components/toolbar/ToolbarResults.svelte index e68d747e..aa808389 100644 --- a/web/src/components/toolbar/ToolbarResults.svelte +++ b/web/src/components/toolbar/ToolbarResults.svelte @@ -139,7 +139,9 @@ } } const isPro = uiStore.analysisMode === 'pro'; - const results = modelStore.solve3D(uiStore.includeSelfWeight, uiStore.axisConvention3D === 'leftHand', isPro); + const versionAtStart = modelStore.modelVersion; + const results = await modelStore.solve3DAsync(uiStore.includeSelfWeight, uiStore.axisConvention3D === 'leftHand', isPro); + if (modelStore.modelVersion !== versionAtStart) return; // stale — user edited mid-solve if (typeof results === 'string') { uiStore.toast(results, 'error'); } else if (results) { diff --git a/web/src/lib/engine/__tests__/pro-double-solve.test.ts b/web/src/lib/engine/__tests__/pro-double-solve.test.ts index fb91cc68..cadebab7 100644 --- a/web/src/lib/engine/__tests__/pro-double-solve.test.ts +++ b/web/src/lib/engine/__tests__/pro-double-solve.test.ts @@ -37,7 +37,7 @@ describe('PRO solve: no redundant baseline single solve', () => { afterEach(() => { vi.restoreAllMocks(); uiStore.analysisMode = '2d'; }); it('combo success: single solve3D is NOT called; combos run once', async () => { - const single = vi.spyOn(modelStore, 'solve3D'); + const single = vi.spyOn(modelStore, 'solve3DAsync'); const combo = vi.spyOn(modelStore, 'solveCombinations3DParallel').mockResolvedValue(fakeComboResult()); await runGlobalSolve(); expect(combo).toHaveBeenCalledTimes(1); @@ -45,7 +45,7 @@ describe('PRO solve: no redundant baseline single solve', () => { }); it('combo failure: falls back to exactly one single solve3D', async () => { - const single = vi.spyOn(modelStore, 'solve3D').mockReturnValue(fakeCaseResult()); + const single = vi.spyOn(modelStore, 'solve3DAsync').mockResolvedValue(fakeCaseResult()); const combo = vi.spyOn(modelStore, 'solveCombinations3DParallel').mockResolvedValue('combo error'); await runGlobalSolve(); expect(combo).toHaveBeenCalledTimes(1); diff --git a/web/src/lib/engine/__tests__/solver-pool-async.test.ts b/web/src/lib/engine/__tests__/solver-pool-async.test.ts new file mode 100644 index 00000000..0bd0b68c --- /dev/null +++ b/web/src/lib/engine/__tests__/solver-pool-async.test.ts @@ -0,0 +1,254 @@ +/** + * Tests for the worker-pool single-solve path (perf/web-worker-solves): + * - parity: the pool solve path returns the same results as the main-thread + * solver (2D and 3D), exercised through an in-process FakeWorker injected + * via setWorkerFactoryForTests; + * - memoization: a second identical solve skips the engine entirely; + * - stale-response discipline: live-calc discards results when the model + * version moved (user edit) while the solve was in flight; + * - fallback: without Workers (Node default), the async path transparently + * runs the synchronous main-thread solver. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { initSolver, assertFiniteWire } from '../wasm-solver'; +import * as glue from '../../wasm/dedaliano_engine.js'; +import { setWorkerFactoryForTests, destroyPool, type WorkerLike } from '../solver-pool'; +import { + validateAndSolve2D, + validateAndSolve2DAsync, + validateAndSolve3D, + validateAndSolve3DAsync, + clearSolveResultCache, + solveResultCacheSize, + type ModelData, +} from '../solver-service'; +import { runLiveCalc } from '../live-calc'; +import { modelStore, resultsStore } from '../../store'; + +// ─── In-process fake worker (same message protocol as solver-worker.ts) ──── + +const fakeSolveCounts = { solve2d: 0, solve3d: 0 }; + +class FakeWorker implements WorkerLike { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: ((e: any) => void) | null = null; + private terminated = false; + + postMessage(rawMsg: any): void { + if (this.terminated) return; + // Emulate the structured-clone boundary both ways. + const msg = structuredClone(rawMsg); + queueMicrotask(() => { + if (this.terminated || !this.onmessage) return; + const reply = (data: any) => this.onmessage!({ data } as MessageEvent); + if (msg.type === 'init') { + reply({ type: 'ready' }); + return; + } + if (msg.type === 'solve' || msg.type === 'solve3d') { + try { + assertFiniteWire(msg.input); + const result = msg.type === 'solve' ? glue.solve_2d(msg.input) : glue.solve_3d(msg.input); + fakeSolveCounts[msg.type === 'solve' ? 'solve2d' : 'solve3d']++; + reply({ type: 'result', id: msg.id, result: structuredClone(result) }); + } catch (err: any) { + reply({ type: 'result', id: msg.id, error: err.message }); + } + } + }); + } + + terminate(): void { + this.terminated = true; + } +} + +// ─── Fixtures ────────────────────────────────────────────────────────────── + +/** 2D cantilever: fixed at node 1, tip load at node 2. */ +function beam2D(): ModelData { + return { + nodes: new Map([ + [1, { id: 1, x: 0, y: 0 } as any], + [2, { id: 2, x: 5, y: 0 } as any], + ]), + elements: new Map([ + [1, { id: 1, type: 'frame', nodeI: 1, nodeJ: 2, materialId: 1, sectionId: 1 } as any], + ]), + materials: new Map([[1, { id: 1, e: 200_000_000, nu: 0.3 } as any]]), + sections: new Map([[1, { id: 1, a: 0.01, iz: 1e-4 } as any]]), + supports: new Map([[1, { id: 1, nodeId: 1, type: 'fixed' } as any]]), + loads: [ + { type: 'nodal', data: { id: 1, nodeId: 2, fx: 0, fz: -10, my: 0, caseId: 1 } } as any, + ], + }; +} + +/** 3D cantilever: fixed3d at node 1, tip load at node 2. */ +function frame3D(): ModelData { + return { + nodes: new Map([ + [1, { id: 1, x: 0, y: 0, z: 0 } as any], + [2, { id: 2, x: 5, y: 0, z: 0 } as any], + ]), + materials: new Map([[1, { id: 1, e: 200_000_000, nu: 0.3 } as any]]), + sections: new Map([[1, { id: 1, a: 0.01, iy: 1e-4, iz: 1e-4, j: 2e-4 } as any]]), + elements: new Map([ + [1, { id: 1, type: 'frame', nodeI: 1, nodeJ: 2, materialId: 1, sectionId: 1 } as any], + ]), + supports: new Map([ + [1, { id: 1, nodeId: 1, type: 'fixed3d' } as any], + ]), + loads: [ + { type: 'nodal3d', data: { id: 1, nodeId: 2, fx: 0, fy: 0, fz: -10, mx: 0, my: 0, mz: 0 } } as any, + ], + }; +} + +// ─── Setup ───────────────────────────────────────────────────────────────── + +beforeAll(async () => { + await initSolver(); +}); + +beforeEach(() => { + fakeSolveCounts.solve2d = 0; + fakeSolveCounts.solve3d = 0; + clearSolveResultCache(); + setWorkerFactoryForTests(() => new FakeWorker()); +}); + +afterEach(() => { + setWorkerFactoryForTests(null); + destroyPool(); +}); + +// ─── Parity: pool path vs main-thread path ───────────────────────────────── + +describe('worker-pool solve parity', () => { + it('2D: pool solve returns the same results as the main-thread solver', async () => { + const sync = validateAndSolve2D(beam2D()); + if (typeof sync === 'string' || !sync) throw new Error(`sync 2D solve failed: ${sync}`); + + const viaPool = await validateAndSolve2DAsync(beam2D()); + if (typeof viaPool === 'string' || !viaPool) throw new Error(`pool 2D solve failed: ${viaPool}`); + + expect(fakeSolveCounts.solve2d).toBe(1); // really went through the pool + expect(viaPool.displacements).toEqual(sync.displacements); + expect(viaPool.reactions).toEqual(sync.reactions); + expect(viaPool.elementForces).toEqual(sync.elementForces); + }); + + it('3D: pool solve returns the same results as the main-thread solver', async () => { + const sync = validateAndSolve3D(frame3D()); + if (typeof sync === 'string' || !sync) throw new Error(`sync 3D solve failed: ${sync}`); + + const viaPool = await validateAndSolve3DAsync(frame3D()); + if (typeof viaPool === 'string' || !viaPool) throw new Error(`pool 3D solve failed: ${viaPool}`); + + expect(fakeSolveCounts.solve3d).toBe(1); + expect(viaPool.displacements).toEqual(sync.displacements); + expect(viaPool.reactions).toEqual(sync.reactions); + expect(viaPool.elementForces).toEqual(sync.elementForces); + }); + + it('string-error semantics are preserved on the async path', async () => { + // No supports → validation error string, same as the sync path. + const model = beam2D(); + model.supports = new Map(); + const sync = validateAndSolve2D(model); + const viaPool = await validateAndSolve2DAsync(model); + expect(typeof sync).toBe('string'); + expect(viaPool).toBe(sync); + }); +}); + +// ─── Memoization ─────────────────────────────────────────────────────────── + +describe('solve-result memoization', () => { + it('a second identical 2D solve hits the memo and skips the engine', async () => { + const first = await validateAndSolve2DAsync(beam2D()); + expect(fakeSolveCounts.solve2d).toBe(1); + + const second = await validateAndSolve2DAsync(beam2D()); + expect(fakeSolveCounts.solve2d).toBe(1); // engine NOT called again + expect(solveResultCacheSize()).toBe(1); + expect(second).toBe(first); // same finalized object + }); + + it('a second identical 3D solve hits the memo and skips the engine', async () => { + await validateAndSolve3DAsync(frame3D()); + expect(fakeSolveCounts.solve3d).toBe(1); + + const second = await validateAndSolve3DAsync(frame3D()); + expect(fakeSolveCounts.solve3d).toBe(1); + expect(solveResultCacheSize()).toBe(1); + if (typeof second === 'string' || !second) throw new Error('expected memoized results'); + expect(second.displacements.length).toBeGreaterThan(0); + }); + + it('2D and 3D memos are keyed independently; a changed input misses', async () => { + await validateAndSolve2DAsync(beam2D()); + await validateAndSolve3DAsync(frame3D()); + expect(solveResultCacheSize()).toBe(2); + + // Changed load → different wire → real solve, not a hit. + const modified = beam2D(); + modified.loads = [{ type: 'nodal', data: { id: 1, nodeId: 2, fx: 0, fz: -20, my: 0, caseId: 1 } } as any]; + await validateAndSolve2DAsync(modified); + expect(fakeSolveCounts.solve2d).toBe(2); + }); +}); + +// ─── Stale-response discipline (live-calc) ───────────────────────────────── + +describe('live-calc stale-response discipline', () => { + function buildStoreModel(): void { + modelStore.clear(); + const n1 = modelStore.addNode(0, 0); + const n2 = modelStore.addNode(5, 0); + modelStore.addElement(n1, n2, 'frame'); + modelStore.addSupport(n1, 'fixed'); + } + + it('writes results when the model does not change during the solve', async () => { + buildStoreModel(); + resultsStore.clear(); + await runLiveCalc('2d', 'rightHand'); + expect(resultsStore.results).not.toBeNull(); + expect(resultsStore.results!.displacements.length).toBeGreaterThan(0); + }); + + it('discards results when the model version moved while the solve was in flight', async () => { + buildStoreModel(); + resultsStore.clear(); + const pending = runLiveCalc('2d', 'rightHand'); + // User edited the model while the worker was still solving. + modelStore.bumpModelVersion(); + await pending; + expect(resultsStore.results).toBeNull(); + }); +}); + +// ─── Fallback without Workers ────────────────────────────────────────────── + +describe('no-Worker fallback (Node default)', () => { + it('async 2D solve falls back to the main-thread solver', async () => { + setWorkerFactoryForTests(null); // and typeof Worker === 'undefined' in vitest node + const sync = validateAndSolve2D(beam2D()); + const r = await validateAndSolve2DAsync(beam2D()); + if (typeof r === 'string' || !r || typeof sync === 'string' || !sync) throw new Error('solve failed'); + expect(fakeSolveCounts.solve2d).toBe(0); // pool never used + expect(r.displacements).toEqual(sync.displacements); + }); + + it('async 3D solve falls back to the main-thread solver', async () => { + setWorkerFactoryForTests(null); + const sync = validateAndSolve3D(frame3D()); + const r = await validateAndSolve3DAsync(frame3D()); + if (typeof r === 'string' || !r || typeof sync === 'string' || !sync) throw new Error('solve failed'); + expect(fakeSolveCounts.solve3d).toBe(0); + expect(r.displacements).toEqual(sync.displacements); + }); +}); diff --git a/web/src/lib/engine/live-calc.ts b/web/src/lib/engine/live-calc.ts index 17f319f0..f50fd34d 100644 --- a/web/src/lib/engine/live-calc.ts +++ b/web/src/lib/engine/live-calc.ts @@ -34,26 +34,42 @@ function formatSolveTiming(timings: any): string { const VALID_2D_DIAGRAMS = ['deformed', 'moment', 'shear', 'axial', 'colorMap', 'axialColor'] as const; const VALID_3D_DIAGRAMS = ['deformed', 'momentY', 'momentZ', 'shearY', 'shearZ', 'axial', 'torsion', 'axialColor', 'colorMap'] as const; +// ─── Stale-response discipline ───────────────────────────────────────────── +// Solves are async now: while a solve is in flight the user can keep editing +// (bumping modelStore.modelVersion) or trigger a newer solve. Each solve +// request captures the model version and a monotonically increasing request +// id; results are written to the stores only if both are still current. +let solveRequestSeq = 0; + +function nextSolveGuard(): () => boolean { + const versionAtStart = modelStore.modelVersion; + const requestId = ++solveRequestSeq; + return () => modelStore.modelVersion !== versionAtStart || requestId !== solveRequestSeq; +} + // ─── Live Calc (reactive $effect) ───────────────────────────────────────── /** * Execute live calculation (auto-solve on model change). * Called from the $effect in App.svelte when liveCalc is enabled. - * Sets results/errors directly on the stores. + * Sets results/errors directly on the stores, unless the solve went stale + * (model edited or a newer solve started while it was in flight). * * @param analysisMode Current analysis mode ('2d' | '3d' | 'edu') * @param axisConvention3D Current 3D axis convention string * @param prevDiagram Diagram type the user was viewing before clear() — restored after solve */ -export function runLiveCalc(analysisMode: string, axisConvention3D: string, prevDiagram?: string): void { +export async function runLiveCalc(analysisMode: string, axisConvention3D: string, prevDiagram?: string): Promise { // Skip if model is incomplete (e.g., mid-example-load after clear but before fixture applied) if (modelStore.nodes.size < 2 || modelStore.elements.size < 1) return; + const isStale = nextSolveGuard(); try { if (analysisMode === '3d' || analysisMode === 'pro') { - liveCalc3D(axisConvention3D); + await liveCalc3D(axisConvention3D, isStale); } else { - liveCalc2D(); + await liveCalc2D(isStale); } + if (isStale()) return; // Restore the diagram type the user was viewing before clear() reset it to 'none'. // Only restore if it's a valid diagram for the current mode. if (prevDiagram && prevDiagram !== 'none') { @@ -64,11 +80,11 @@ export function runLiveCalc(analysisMode: string, axisConvention3D: string, prev } } } catch (err: any) { - uiStore.liveCalcError = err.message ?? t('error.unknown'); + if (!isStale()) uiStore.liveCalcError = err.message ?? t('error.unknown'); } } -function liveCalc3D(axisConvention: string): void { +async function liveCalc3D(axisConvention: string, isStale: () => boolean): Promise { if (!isWasmReady()) { initSolver().catch((err) => { console.error('[liveCalc3D] WASM initialization failed:', err); @@ -77,7 +93,8 @@ function liveCalc3D(axisConvention: string): void { return; } const isPro = uiStore.analysisMode === 'pro'; - const r = modelStore.solve3D(uiStore.includeSelfWeight, axisConvention === 'leftHand', isPro); + const r = await modelStore.solve3DAsync(uiStore.includeSelfWeight, axisConvention === 'leftHand', isPro); + if (isStale()) return; if (typeof r === 'string') { uiStore.liveCalcError = r; return; @@ -92,8 +109,9 @@ function liveCalc3D(axisConvention: string): void { resultsStore.setResults3D(r, true); } -function liveCalc2D(): void { - const r = modelStore.solve(uiStore.includeSelfWeight, uiStore.drawPlane2D); +async function liveCalc2D(isStale: () => boolean): Promise { + const r = await modelStore.solveAsync(uiStore.includeSelfWeight, uiStore.drawPlane2D); + if (isStale()) return; if (typeof r === 'string') { uiStore.liveCalcError = r; return; @@ -107,7 +125,8 @@ function liveCalc2D(): void { resultsStore.setResults(r, true); - // Auto-solve combinations if defined + // Auto-solve combinations if defined (sync single WASM call — the model + // cannot drift while it runs, so no extra stale check is needed here) if (modelStore.model.combinations.length > 0) { const combo = modelStore.solveCombinations(uiStore.includeSelfWeight, uiStore.drawPlane2D); if (combo && typeof combo !== 'string') { @@ -126,16 +145,18 @@ function liveCalc2D(): void { * Handles 2D and 3D, combinations, toasts and mobile panel. */ export async function runGlobalSolve(): Promise { + // Supersede any in-flight solve (live calc or an earlier manual solve). + const isStale = nextSolveGuard(); if (uiStore.analysisMode === '3d' || uiStore.analysisMode === 'pro') { await ensureWasmReady('runGlobalSolve'); - await globalSolve3D(); + await globalSolve3D(isStale); } else if (uiStore.analysisMode === 'edu') { // Edu mode handles its own solve via edu-solver.ts (registered listener). // This branch is a no-op safety fallback — the edu module's listener // fires first on the same 'stabileo-solve' event. return; } else { - globalSolve2D(); + await globalSolve2D(isStale); } } @@ -167,14 +188,15 @@ function isMechanismError(msg: string): boolean { || lc.includes('mechanism') || lc.includes('hypostatic') || lc.includes('unstable'); } -async function globalSolve3D(): Promise { +async function globalSolve3D(isStale: () => boolean): Promise { const isPro = uiStore.analysisMode === 'pro'; const leftHand = uiStore.axisConvention3D === 'leftHand'; const hasCombos = modelStore.model.combinations.length > 0; const t0 = performance.now(); - const runSingleSolve = () => { - const r = modelStore.solve3D(uiStore.includeSelfWeight, leftHand, isPro); + const runSingleSolve = async () => { + const r = await modelStore.solve3DAsync(uiStore.includeSelfWeight, leftHand, isPro); + if (isStale()) return null; if (typeof r === 'string') { uiStore.toast(r, 'error'); return null; @@ -196,6 +218,7 @@ async function globalSolve3D(): Promise { const runComboSolve = async () => { const comboResult = await modelStore.solveCombinations3DParallel(uiStore.includeSelfWeight, leftHand, isPro); + if (isStale()) return null; if (typeof comboResult === 'string') return comboResult; if (!comboResult) return t('results.emptyModelError'); @@ -236,12 +259,12 @@ async function globalSolve3D(): Promise { const comboError = await runComboSolve(); if (comboError) { console.warn('[globalSolve3D] Combination solve returned error in PRO, falling back to single solve:', comboError); - const fallback = runSingleSolve(); + const fallback = await runSingleSolve(); if (!fallback) uiStore.toast(comboError, 'info'); } } catch (e: any) { console.error('[globalSolve3D] Combination solving failed in PRO, falling back to single solve:', e.message); - const fallback = runSingleSolve(); + const fallback = await runSingleSolve(); if (!fallback) uiStore.toast(e.message, 'info'); } return; @@ -252,23 +275,24 @@ async function globalSolve3D(): Promise { if (comboError) { console.warn('[globalSolve3D] Combination solve returned error, falling back to single solve:', comboError); uiStore.toast(comboError, 'error'); - runSingleSolve(); + await runSingleSolve(); return; } } catch (e: any) { console.error('[globalSolve3D] Combination solving failed:', e.message); uiStore.toast(e.message, 'error'); - runSingleSolve(); + await runSingleSolve(); } return; } // No combinations — single solve only - runSingleSolve(); + await runSingleSolve(); } -function globalSolve2D(): void { - const r = modelStore.solve(uiStore.includeSelfWeight, uiStore.drawPlane2D); +async function globalSolve2D(isStale: () => boolean): Promise { + const r = await modelStore.solveAsync(uiStore.includeSelfWeight, uiStore.drawPlane2D); + if (isStale()) return; if (typeof r === 'string') { uiStore.toast(r, 'error', isMechanismError(r) ? 'kinematic' : undefined); return; diff --git a/web/src/lib/engine/solver-pool.ts b/web/src/lib/engine/solver-pool.ts index 52912150..446a0769 100644 --- a/web/src/lib/engine/solver-pool.ts +++ b/web/src/lib/engine/solver-pool.ts @@ -1,19 +1,40 @@ /** - * solver-pool.ts — Worker pool for parallel 3D solving. + * solver-pool.ts — Worker pool for off-main-thread structural solving. * * Pre-initializes a pool of Web Workers, each with its own WASM instance. - * Distributes solve_3d calls across workers for parallel execution. + * Serves both single solves (2D and 3D) and parallel 3D case-solving. + * Inputs/outputs travel as plain objects (structured clone), never JSON text. + * + * When Workers are unavailable (e.g. Node/vitest), `solve2DInWorker` / + * `solve3DInWorker` throw `PoolUnavailableError` so callers can fall back to + * the synchronous main-thread solver. */ import { getWasmBytes } from './wasm-solver'; +/** Thrown when the pool cannot be used (no Worker support, init failure). */ +export class PoolUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'PoolUnavailableError'; + } +} + +/** Minimal structural type so tests can inject an in-process fake worker. */ +export interface WorkerLike { + postMessage(msg: any): void; + terminate(): void; + onmessage: ((e: MessageEvent) => void) | null; + onerror: ((e: any) => void) | null; +} + interface PendingSolve { resolve: (result: any) => void; reject: (err: Error) => void; } interface PoolWorker { - worker: Worker; + worker: WorkerLike; ready: boolean; pending: Map; } @@ -22,6 +43,13 @@ let pool: PoolWorker[] = []; let initPromise: Promise | null = null; let nextId = 0; +/** Test seam: inject a factory producing in-process fake workers (null = real Workers). */ +let workerFactory: (() => WorkerLike) | null = null; +export function setWorkerFactoryForTests(factory: (() => WorkerLike) | null): void { + workerFactory = factory; + destroyPool(); +} + /** Maximum number of workers to create */ const DEFAULT_WORKER_COUNT = 4; const MAX_WORKERS = Math.min( @@ -32,12 +60,14 @@ const MAX_WORKERS = Math.min( ); /** Create a single worker and wait for it to become ready. */ -function createWorker(wasmModule: WebAssembly.Module): Promise { +function createWorker(wasmModule: WebAssembly.Module | null): Promise { return new Promise((resolve, reject) => { - const worker = new Worker( - new URL('./solver-worker.ts', import.meta.url), - { type: 'module' }, - ); + const worker: WorkerLike = workerFactory + ? workerFactory() + : new Worker( + new URL('./solver-worker.ts', import.meta.url), + { type: 'module' }, + ); const pw: PoolWorker = { worker, ready: false, pending: new Map() }; @@ -66,7 +96,8 @@ function createWorker(wasmModule: WebAssembly.Module): Promise { reject(new Error(`Worker error: ${err.message}`)); }; - // Compiled module is structured-cloneable: no byte copy, no per-worker compile + // Compiled module is structured-cloneable: no byte copy, no per-worker compile. + // (Fake test workers ignore it and share the test's in-process WASM instance.) worker.postMessage({ type: 'init', wasmModule }); }); } @@ -82,7 +113,8 @@ export async function initPool(numWorkers?: number): Promise { try { // Compile once on the main thread (bytes shared with wasm-solver's init, // so a single fetch); workers instantiate clones of the compiled module. - const wasmModule = await WebAssembly.compile(await getWasmBytes()); + // (Fake test workers skip the compile — they share the test's WASM instance.) + const wasmModule = workerFactory ? null : await WebAssembly.compile(await getWasmBytes()); const settled = await Promise.allSettled( Array.from({ length: count }, () => createWorker(wasmModule)), ); @@ -113,6 +145,52 @@ export function isPoolReady(): boolean { return pool.length > 0 && pool.every(w => w.ready); } +function workersAvailable(): boolean { + return workerFactory !== null || (typeof Worker !== 'undefined' && typeof WebAssembly !== 'undefined'); +} + +/** Initialize the pool or throw PoolUnavailableError. */ +async function ensurePool(): Promise { + if (!workersAvailable()) { + throw new PoolUnavailableError('Web Workers are not available in this environment'); + } + try { + await initPool(); + } catch (err: any) { + throw new PoolUnavailableError(err?.message ?? 'Worker pool initialization failed'); + } +} + +/** Route one solve job to the least-busy worker. */ +function runJob(type: 'solve' | 'solve3d', input: any): Promise { + const pw = pool.reduce((a, b) => (a.pending.size <= b.pending.size ? a : b)); + const msgId = nextId++; + return new Promise((resolve, reject) => { + pw.pending.set(msgId, { resolve, reject }); + pw.worker.postMessage({ type, id: msgId, input }); + }); +} + +/** + * Solve a single 2D case in a worker. + * @param input Plain-object wire form of SolverInput (see input2DToWireObject) + * @throws PoolUnavailableError when Workers are unavailable — caller should fall back to the sync solver + */ +export async function solve2DInWorker(input: any): Promise { + await ensurePool(); + return runJob('solve', input); +} + +/** + * Solve a single 3D case in a worker. + * @param input Plain-object wire form of SolverInput3D (see input3DToWireObject) + * @throws PoolUnavailableError when Workers are unavailable — caller should fall back to the sync solver + */ +export async function solve3DInWorker(input: any): Promise { + await ensurePool(); + return runJob('solve3d', input); +} + /** * Solve multiple 3D cases in parallel across the worker pool. * @@ -122,36 +200,14 @@ export function isPoolReady(): boolean { export async function solveParallel( cases: Array<{ id: number; input: any }>, ): Promise> { - if (pool.length === 0) { - throw new Error('Worker pool not initialized. Call initPool() first.'); - } + await ensurePool(); const results = new Map(); - - // Distribute cases round-robin across workers - const promises: Promise[] = []; - - for (let i = 0; i < cases.length; i++) { - const workerIdx = i % pool.length; - const pw = pool[workerIdx]; - const { id, input } = cases[i]; - const msgId = nextId++; - - const promise = new Promise((resolve, reject) => { - pw.pending.set(msgId, { - resolve: (result: any) => { - results.set(id, result); - resolve(); - }, - reject: (err: Error) => reject(err), - }); - pw.worker.postMessage({ type: 'solve3d', id: msgId, input }); - }); - - promises.push(promise); - } - - await Promise.all(promises); + await Promise.all( + cases.map(({ id, input }) => + runJob('solve3d', input).then(result => { results.set(id, result); }), + ), + ); return results; } diff --git a/web/src/lib/engine/solver-service.ts b/web/src/lib/engine/solver-service.ts index 9dc14423..53e99186 100644 --- a/web/src/lib/engine/solver-service.ts +++ b/web/src/lib/engine/solver-service.ts @@ -1,7 +1,7 @@ // Solver service — pure functions extracted from model.svelte.ts // Each function takes a ModelData parameter instead of accessing reactive store state. -import { solve as solveStructure, solve3D as solve3DEngine, analyzeKinematics, combineResults, combineResults3D, computeEnvelope, computeEnvelope3D, solveMultiCase2D, solveMultiCase3D, input3DToWireObject } from './wasm-solver'; +import { solve as solveStructure, solve3D as solve3DEngine, analyzeKinematics, combineResults, combineResults3D, computeEnvelope, computeEnvelope3D, solveMultiCase2D, solveMultiCase3D, input2DToWireObject, input3DToWireObject } from './wasm-solver'; import type { SolverInput, FullEnvelope, AnalysisResults } from './types'; import { computeLocalAxes3D } from './local-axes-3d'; import type { SolverInput3D, SolverLoad3D, AnalysisResults3D, FullEnvelope3D, Constraint3D } from './types-3d'; @@ -19,7 +19,7 @@ import { expandJoints3D, modelHasJoints3D, EMBED_XZ_DOF_PERMUTATION } from './ex import { expandShellOffsets, modelHasShellOffsets } from './shell-offsets'; import { enrichComboShellStresses } from './shell-combos'; import { constraintsTo2D } from './constraint-2d-remap'; -import { initPool, isPoolReady, solveParallel } from './solver-pool'; +import { initPool, isPoolReady, solveParallel, solve2DInWorker, solve3DInWorker, PoolUnavailableError } from './solver-pool'; import { t } from '../i18n'; import { get2DDisplayNodalLoadMoment, @@ -300,16 +300,22 @@ function preflightModel2D(model: ModelData): { error: string | null; connectedNo return { error: null, connectedNodes }; } +interface Solve2DPreparation { + input: SolverInput; + slidingHelperIds: Set; + modelNodeIds: Set; +} + /** - * Full 2D solve with all pre-solve validations. - * Returns AnalysisResults on success, an error string, or null. + * Validation + wire-input construction for a 2D solve (shared by the sync and + * async solve paths). Returns the preparation, or an error string, or null. * Also returns the KinematicResult via the optional `onKinematic` callback. */ -export function validateAndSolve2D( +function prepareSolve2D( model: ModelData, includeSelfWeight = false, onKinematic?: (k: KinematicResult | null) => void, -): AnalysisResults | string | null { +): Solve2DPreparation | string | null { if (model.nodes.size < 2 || model.elements.size < 1) { return t('svc.needNodesAndElements'); } @@ -631,16 +637,113 @@ export function validateAndSolve2D( ? expandSlidingJoints2D(input, model.elements) : new Set(); + return { input, slidingHelperIds, modelNodeIds: new Set(model.nodes.keys()) }; +} + +/** Prune ephemeral sliding-joint helper-node results (no-op without sliders). */ +function finalizeSolve2DResults(results: AnalysisResults, prep: Solve2DPreparation): AnalysisResults { + if (prep.slidingHelperIds.size > 0) { + return pruneHelperNodeResults(results as any, prep.modelNodeIds) as any; + } + return results; +} + +export function validateAndSolve2D( + model: ModelData, + includeSelfWeight = false, + onKinematic?: (k: KinematicResult | null) => void, +): AnalysisResults | string | null { + const prep = prepareSolve2D(model, includeSelfWeight, onKinematic); + if (prep === null || typeof prep === 'string') return prep; + try { const t0 = performance.now(); - const results = solveStructure(input); + const results = solveStructure(prep.input); const dt = performance.now() - t0; console.log(`Estructura resuelta en ${dt.toFixed(1)} ms — ${model.nodes.size} nodos, ${model.elements.size} elementos`); - if (slidingHelperIds.size > 0) { - const modelNodeIds = new Set(model.nodes.keys()); - return pruneHelperNodeResults(results as any, modelNodeIds) as any; + return finalizeSolve2DResults(results, prep); + } catch (err: any) { + console.error('Solver error:', err); + return t('svc.solverError').replace('{n}', err.message); + } +} + +// ─── Solve-result memoization (LRU) ───────────────────────────── + +/** + * Small LRU over finalized solve results, keyed by the serialized wire input. + * The wire already encodes every analysis-relevant option: includeSelfWeight + * (baked into loads), leftHand (wire field), drawPlane (model remapped before + * the call), constraints/connectors. The '2d:'/'3d:' prefix distinguishes + * analysis modes. Used by the async (worker) solve paths only. + */ +const SOLVE_CACHE_MAX = 6; +const solveResultCache = new Map(); + +function solveCacheGet(key: string): AnalysisResults | AnalysisResults3D | undefined { + const value = solveResultCache.get(key); + if (value !== undefined) { + // LRU touch: re-insert at the end. + solveResultCache.delete(key); + solveResultCache.set(key, value); + } + return value; +} + +function solveCacheSet(key: string, value: AnalysisResults | AnalysisResults3D): void { + solveResultCache.delete(key); + solveResultCache.set(key, value); + if (solveResultCache.size > SOLVE_CACHE_MAX) { + solveResultCache.delete(solveResultCache.keys().next().value!); + } +} + +/** Test hook: clear the solve-result memoization cache. */ +export function clearSolveResultCache(): void { + solveResultCache.clear(); +} + +/** Test hook: current number of memoized solve results. */ +export function solveResultCacheSize(): number { + return solveResultCache.size; +} + +/** + * Async 2D solve: runs the engine in a worker from the pool (UI stays + * responsive), with a small LRU memo (undo/redo and no-op edits skip the + * solve). Falls back to the synchronous main-thread solver when Workers are + * unavailable. Same result shape and string-error semantics as validateAndSolve2D. + */ +export async function validateAndSolve2DAsync( + model: ModelData, + includeSelfWeight = false, + onKinematic?: (k: KinematicResult | null) => void, +): Promise { + const prep = prepareSolve2D(model, includeSelfWeight, onKinematic); + if (prep === null || typeof prep === 'string') return prep; + + const wire = input2DToWireObject(prep.input); + const cacheKey = `2d:${JSON.stringify(wire)}`; + const cached = solveCacheGet(cacheKey); + if (cached) { + console.log(`Estructura resuelta (caché) — ${model.nodes.size} nodos, ${model.elements.size} elementos`); + return cached as AnalysisResults; + } + + try { + const t0 = performance.now(); + let results: AnalysisResults; + try { + results = await solve2DInWorker(wire); + } catch (e) { + if (!(e instanceof PoolUnavailableError)) throw e; + results = solveStructure(prep.input); } - return results; + const dt = performance.now() - t0; + console.log(`Estructura resuelta en ${dt.toFixed(1)} ms — ${model.nodes.size} nodos, ${model.elements.size} elementos`); + const finalResults = finalizeSolve2DResults(results, prep); + solveCacheSet(cacheKey, finalResults); + return finalResults; } catch (err: any) { console.error('Solver error:', err); return t('svc.solverError').replace('{n}', err.message); @@ -1428,7 +1531,11 @@ export function buildSolverInput3D( // ─── 3D: validateAndSolve3D ────────────────────────────────────── /** Solve the current model using the 3D solver. Returns results or error string. */ -export function validateAndSolve3D(model: ModelData, includeSelfWeight = false, leftHand = false): AnalysisResults3D | string | null { +/** + * Validation + wire-input construction for a 3D solve (shared by the sync and + * async solve paths). Returns the solver input, or an error string, or null. + */ +function prepareSolve3D(model: ModelData, includeSelfWeight = false, leftHand = false): SolverInput3D | string | null { if (model.nodes.size < 2 || model.elements.size < 1) { return t('svc.needNodesAndElements'); } @@ -1525,25 +1632,70 @@ export function validateAndSolve3D(model: ModelData, includeSelfWeight = false, const input = buildSolverInput3D(model, includeSelfWeight, leftHand); if (!input) return t('svc.emptyModel'); + return input; +} + +/** Post-solve 3D result enrichment: shell stresses + helper-node pruning. */ +function finalizeSolve3DResults(results: AnalysisResults3D, model: ModelData): AnalysisResults3D { + // PRO-only: post-process shell stresses + if (model.quads?.size || model.plates?.size) { + postProcessShellStresses(results, model.nodes, model.quads ?? new Map(), model.plates ?? new Map(), model.materials); + } + // Strip ephemeral helper-node results (member/shell offsets or 3D joints). + if (modelHasMemberOffsets(model.elements.values()) || modelHasShellOffsets(model.plates, model.quads) || modelHasJoints3D(model.elements.values())) { + return pruneHelperNodeResults(results, new Set(model.nodes.keys())); + } + return results; +} + +export function validateAndSolve3D(model: ModelData, includeSelfWeight = false, leftHand = false): AnalysisResults3D | string | null { + const input = prepareSolve3D(model, includeSelfWeight, leftHand); + if (input === null || typeof input === 'string') return input; try { const t0 = performance.now(); const results = solve3DEngine(input); const dt = performance.now() - t0; - if (typeof results === 'string') { - console.warn(`Solver 3D (${dt.toFixed(1)} ms): ${results}`); - } else { - console.log(`Estructura 3D resuelta en ${dt.toFixed(1)} ms — ${model.nodes.size} nodos, ${model.elements.size} elementos`); - // PRO-only: post-process shell stresses - if (model.quads?.size || model.plates?.size) { - postProcessShellStresses(results, model.nodes, model.quads ?? new Map(), model.plates ?? new Map(), model.materials); - } - // Strip ephemeral helper-node results (member/shell offsets or 3D joints). - if (modelHasMemberOffsets(model.elements.values()) || modelHasShellOffsets(model.plates, model.quads) || modelHasJoints3D(model.elements.values())) { - return pruneHelperNodeResults(results, new Set(model.nodes.keys())); - } + console.log(`Estructura 3D resuelta en ${dt.toFixed(1)} ms — ${model.nodes.size} nodos, ${model.elements.size} elementos`); + return finalizeSolve3DResults(results, model); + } catch (err: any) { + console.error('Solver 3D error:', err); + return t('svc.solver3dError').replace('{n}', err.message); + } +} + +/** + * Async 3D solve: runs the engine in a worker from the pool (UI stays + * responsive), with a small LRU memo. Falls back to the synchronous + * main-thread solver when Workers are unavailable. Same result shape and + * string-error semantics as validateAndSolve3D. + */ +export async function validateAndSolve3DAsync(model: ModelData, includeSelfWeight = false, leftHand = false): Promise { + const input = prepareSolve3D(model, includeSelfWeight, leftHand); + if (input === null || typeof input === 'string') return input; + + const wire = input3DToWireObject(input); + const cacheKey = `3d:${JSON.stringify(wire)}`; + const cached = solveCacheGet(cacheKey); + if (cached) { + console.log(`Estructura 3D resuelta (caché) — ${model.nodes.size} nodos, ${model.elements.size} elementos`); + return cached as AnalysisResults3D; + } + + try { + const t0 = performance.now(); + let results: AnalysisResults3D; + try { + results = await solve3DInWorker(wire); + } catch (e) { + if (!(e instanceof PoolUnavailableError)) throw e; + results = solve3DEngine(input); } - return results; + const dt = performance.now() - t0; + console.log(`Estructura 3D resuelta en ${dt.toFixed(1)} ms — ${model.nodes.size} nodos, ${model.elements.size} elementos`); + const finalResults = finalizeSolve3DResults(results, model); + solveCacheSet(cacheKey, finalResults); + return finalResults; } catch (err: any) { console.error('Solver 3D error:', err); return t('svc.solver3dError').replace('{n}', err.message); diff --git a/web/src/lib/engine/solver-worker.ts b/web/src/lib/engine/solver-worker.ts index c81d2f2b..90f82ebe 100644 --- a/web/src/lib/engine/solver-worker.ts +++ b/web/src/lib/engine/solver-worker.ts @@ -1,18 +1,37 @@ /** - * Web Worker for parallel 3D structural solving. + * Web Worker for structural solving (2D and 3D). * Each worker loads its own WASM instance and solves independently. * * Messages: * { type: 'init', wasmModule: WebAssembly.Module } → initialize WASM (pre-compiled module, structured-cloned) - * { type: 'solve3d', id: number, input: object } → solve and return results (plain objects, structured-cloned) + * { type: 'solve', id: number, input: object } → 2D solve (SolverInput wire object) + * { type: 'solve3d', id: number, input: object } → 3D solve (SolverInput3D wire object) + * Inputs/outputs are plain JS objects — structured-cloned both ways, no JSON text. */ import { assertFiniteWire } from './wasm-solver'; -let initSync: ((moduleOrBytes: any) => void) | null = null; +let solve_2d: ((input: any) => any) | null = null; let solve_3d: ((input: any) => any) | null = null; let ready = false; +function handleSolve(msg: any, solveFn: ((input: any) => any) | null): void { + if (!ready || !solveFn) { + self.postMessage({ type: 'result', id: msg.id, error: 'Worker not initialized' }); + return; + } + try { + // The finiteness guard preserves the old JSON-boundary semantics (NaN/Inf rejected). + assertFiniteWire(msg.input); + const result = solveFn(msg.input); + self.postMessage({ type: 'result', id: msg.id, result }); + } catch (err: any) { + // Engine errors cross the boundary as plain strings (JsValue::from_str), + // which have no .message — fall back to String() so they are not lost. + self.postMessage({ type: 'result', id: msg.id, error: err?.message ?? String(err) }); + } +} + self.onmessage = async (e: MessageEvent) => { const msg = e.data; @@ -20,10 +39,10 @@ self.onmessage = async (e: MessageEvent) => { try { // Dynamic import so the build doesn't fail when WASM files are absent const wasm = await import(/* @vite-ignore */ '../wasm/dedaliano_engine.js'); - initSync = wasm.initSync; + solve_2d = wasm.solve_2d; solve_3d = wasm.solve_3d; - initSync({ module: msg.wasmModule }); + wasm.initSync({ module: msg.wasmModule }); ready = true; self.postMessage({ type: 'ready' }); } catch (err: any) { @@ -32,22 +51,13 @@ self.onmessage = async (e: MessageEvent) => { return; } + if (msg.type === 'solve') { + handleSolve(msg, solve_2d); + return; + } + if (msg.type === 'solve3d') { - if (!ready || !solve_3d) { - self.postMessage({ type: 'result', id: msg.id, error: 'Worker not initialized' }); - return; - } - try { - // solve_3d takes and returns plain JS objects — structured clone both ways. - // The finiteness guard preserves the old JSON-boundary semantics (NaN/Inf rejected). - assertFiniteWire(msg.input); - const result = solve_3d(msg.input); - self.postMessage({ type: 'result', id: msg.id, result }); - } catch (err: any) { - // Engine errors cross the boundary as plain strings (JsValue::from_str), - // which have no .message — fall back to String() so they are not lost. - self.postMessage({ type: 'result', id: msg.id, error: err?.message ?? String(err) }); - } + handleSolve(msg, solve_3d); return; } }; diff --git a/web/src/lib/store/model.svelte.ts b/web/src/lib/store/model.svelte.ts index c1f3472c..f5ef857e 100644 --- a/web/src/lib/store/model.svelte.ts +++ b/web/src/lib/store/model.svelte.ts @@ -8,7 +8,7 @@ import { getFixture, is2DFixture, is3DFixture } from '../templates/fixture-index import { loadFixture } from '../templates/load-fixture'; import { inferLoadCaseType } from '../engine/combinations-service'; import { t } from '../i18n'; -import { validateAndSolve2D, buildSolverInput2D, validateAndSolve3D, buildSolverInput3D as buildSolverInput3DFn, solveCombinations2D, solveCombinations3D as solveCombinations3DFn, solveCombinations3DParallel as solveCombinations3DParallelFn } from '../engine/solver-service'; +import { validateAndSolve2D, validateAndSolve2DAsync, buildSolverInput2D, validateAndSolve3D, validateAndSolve3DAsync, buildSolverInput3D as buildSolverInput3DFn, solveCombinations2D, solveCombinations3D as solveCombinations3DFn, solveCombinations3DParallel as solveCombinations3DParallelFn } from '../engine/solver-service'; import { computeInfluenceLine as computeInfluenceLineFn } from '../engine/influence-service'; import { to2D, remapNodalLoad2D, remapMoment2D, type DrawPlane } from '../geometry/plane-projection'; import { pickElement3DMetadata, type Element3DMetadata, type MemberOffset } from '../model/element-3d-metadata'; @@ -2014,6 +2014,14 @@ function createModelStore() { return validateAndSolve2D(mapped, includeSelfWeight, (k) => { lastKinematicResult = k; }); }, + /** Async 2D solve via the worker pool (UI stays responsive). Same result + * shape and string-error semantics as solve(). */ + async solveAsync(includeSelfWeight = false, drawPlane: DrawPlane = 'xy'): Promise { + const mapped = remapModelForPlane(drawPlane); + if (typeof mapped === 'string') return mapped; + return validateAndSolve2DAsync(mapped, includeSelfWeight, (k) => { lastKinematicResult = k; }); + }, + /** Build a SolverInput from the current model state (no validation). Returns null if model is empty. */ buildSolverInput(includeSelfWeight = false, drawPlane: DrawPlane = 'xy'): SolverInput | null { const mapped = remapModelForPlane(drawPlane); @@ -2138,6 +2146,21 @@ function createModelStore() { ); }, + /** Async 3D solve via the worker pool (UI stays responsive). Same result + * shape and string-error semantics as solve3D(). */ + async solve3DAsync(includeSelfWeight = false, leftHand = false, isPro = false): Promise { + if (this.hasSlidingJoints()) return t('advanced.sliding3dUnsupported'); + return validateAndSolve3DAsync( + { nodes: model.nodes, elements: model.elements, supports: model.supports, + loads: model.loads, materials: model.materials, sections: model.sections, + plates: isPro ? model.plates : undefined, + quads: isPro ? model.quads : undefined, + constraints: isPro ? model.constraints : undefined, + connectors: isPro ? model.connectors : undefined }, + includeSelfWeight, leftHand, + ); + }, + /** Solve load combinations for 3D analysis (mirrors 2D solveCombinations). * Shell elements are only included when isPro=true. */ solveCombinations3D(includeSelfWeight = false, leftHand = false, isPro = false): { perCase: Map; perCombo: Map; envelope: FullEnvelope3D } | string | null {