Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a8eb4ce
chore: scaffold calc-report module for PR [2]
Batuis Mar 29, 2026
af8d4de
feat: calc-book report v1 — model data, loads, reactions, displacemen…
Batuis Mar 29, 2026
1a4527b
feat: unified design-check normalization layer with multi-code adapters
Batuis Mar 29, 2026
c797b97
feat: PRO Design tab with multi-code member utilization table
Batuis Mar 31, 2026
351de92
fix: add manual dismiss button (×) to toast notifications
Batuis Mar 31, 2026
e9aa8d8
feat: PRO workflow overhaul, mobile support, loads/shells editing, ca…
Batuis Apr 3, 2026
a6d9dd2
Merge origin/main into PR45 stack base
Batuis Apr 19, 2026
e6a797d
Merge remote-tracking branch 'origin/main' into pr/2-next-product-ite…
Batuis Apr 19, 2026
d5a5908
Merge origin/main into PR #45 (Viewport3D hover: keep rAF scheduling …
Batuis Apr 20, 2026
233e1c2
Merge origin/main into pr/2-next-product-iteration
Batuis Apr 30, 2026
d0ccee9
Merge remote-tracking branch 'origin/main' into fix/pr2-propagate-typ…
diegokingston May 13, 2026
9e8d468
ci(engine): widen harmonic 5x5 plate timing gate from 15s to 30s
diegokingston May 13, 2026
2d5e44d
ci(engine): scope cargo bench to criterion targets
Batuis May 28, 2026
15fd807
fix(pro): correctness fixes from PR #45 review (design-check + loads …
diegokingston Jun 1, 2026
b82cdde
Merge pull request #53 from lambdaclass/fix/pr45-design-check-bugs
diegokingston Jun 9, 2026
377d811
fix(file): don't assign to derived uiStore.appMode on project load
diegokingston Jun 9, 2026
5e743d6
fix(autosave,pro): autosave reachability, self-weight opt-out, load d…
diegokingston Jun 9, 2026
2f76ae0
fix(selection): selectedLoads holds load data ids everywhere
diegokingston Jun 9, 2026
8b60dd0
fix(3d): enforce selectMode for viewport selection; shell/element id …
diegokingston Jun 9, 2026
45c7632
fix(design): no stale overlay across check runs, no empty-run success
diegokingston Jun 9, 2026
e841261
fix(report): truthful reaction-sum note, true row numbers, fresh proj…
diegokingston Jun 9, 2026
10e774b
fix(pro): stop x-button click bubbling to row select; plate/quad high…
diegokingston Jun 9, 2026
cceb5db
Merge fix/pr45-review-round-2: correctness fixes from PR #45 second r…
diegokingston Jun 10, 2026
e42acc8
Merge origin/main (PR #52 CI perf-gate contention fix) into pr/2-next…
diegokingston Jun 10, 2026
46a2a14
ci(engine): don't gate CI on quick-bench wall time
diegokingston Jun 10, 2026
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
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,15 @@ jobs:
working-directory: engine
run: cargo bench --no-run

# Informational only: criterion reports are uploaded as artifacts below.
# Benchmark wall time on shared runners must not gate CI (same rationale
# as the single-threaded perf gates above) — slow runners exceeded the
# timeout with no correctness signal in the failure.
- name: Run criterion benchmarks (quick)
working-directory: engine
run: cargo bench -- --sample-size 10 --warm-up-time 1 --measurement-time 3
timeout-minutes: 10
run: cargo bench --bench solver_bench --bench assembly_bench --bench workflow_bench -- --sample-size 10 --warm-up-time 1 --measurement-time 3
timeout-minutes: 15
continue-on-error: true

- name: Upload criterion reports
if: always()
Expand Down
471 changes: 402 additions & 69 deletions web/src/App.svelte

Large diffs are not rendered by default.

236 changes: 236 additions & 0 deletions web/src/components/CalcReportDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<script lang="ts">
import { modelStore, resultsStore, uiStore, verificationStore } from '../lib/store';
import { openCalcReport, type CalcReportData, type CalcReportConfig, type ResultProvenance, type AnalysisModeLabel } from '../lib/engine/calc-report';
import { t } from '../lib/i18n';

let { open = $bindable(false) }: { open: boolean } = $props();

let projectName = $state(modelStore.model.name || 'Structural Analysis');
let engineerName = $state('');
let companyName = $state('');
let notes = $state('');

// The dialog component stays mounted for the app's lifetime, so the $state
// initializer above only sees the startup model. Re-seed the project name
// from the current model each time the dialog opens (it remains editable).
$effect(() => {
if (open) projectName = modelStore.model.name || 'Structural Analysis';
});

const is3D = $derived(uiStore.analysisMode === '3d' || uiStore.analysisMode === 'pro');
const hasResults = $derived(is3D ? resultsStore.results3D !== null : resultsStore.results !== null);
const modeLabel = $derived<AnalysisModeLabel>(uiStore.analysisMode === 'pro' ? 'PRO' : uiStore.analysisMode === '3d' ? '3D' : '2D');

function deriveProvenance(): ResultProvenance {
const view = resultsStore.activeView;
if (view === 'envelope') {
return { kind: 'envelope' };
}
if (view === 'combo') {
const comboId = resultsStore.activeComboId;
const combo = comboId !== null
? modelStore.model.combinations.find(c => c.id === comboId)
: undefined;
return { kind: 'combo', comboName: combo?.name ?? `Combination ${comboId}` };
}
const caseId = resultsStore.activeCaseId;
const caseName = caseId !== null ? modelStore.getLoadCaseName(caseId) : undefined;
return { kind: 'single', caseName: caseName || undefined };
}

function generateReport() {
if (!hasResults) return;
const config: CalcReportConfig = {
projectName,
engineerName,
companyName,
date: new Date().toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }),
notes,
};

// Extract load descriptions from model
const loads = modelStore.loads.map((l, i) => {
const d = l.data as any;
let description = '';
let caseLabel = modelStore.getLoadCaseName(d.caseId ?? 1) || undefined;
if (l.type === 'nodal' || l.type === 'nodal3d') {
const parts: string[] = [];
if (d.fx) parts.push(`Fx=${d.fx} kN`);
if (d.fy) parts.push(`Fy=${d.fy} kN`);
if (d.fz) parts.push(`Fz=${d.fz} kN`);
// `||` (not `??`) so a present-but-zero component falls through to the
// axis that actually carries the moment (e.g. my=0, mz=5 → M=5).
if (d.my || d.mz) parts.push(`M=${d.my || d.mz} kN·m`);
description = `Node ${d.nodeId}: ${parts.join(', ') || 'zero'}`;
} else if (l.type === 'distributed' || l.type === 'distributed3d') {
// A distributed load stores its magnitude on whichever axis it acts;
// the off-axis fields can be present as 0. Use `||` so a 0 on one axis
// doesn't shadow the real value on another (qZI=0, qYI=5 → q=5).
const qI = d.qI ?? (d.qZI || d.qYI || 0);
const qJ = d.qJ ?? (d.qZJ || d.qYJ || 0);
description = `Elem ${d.elementId}: q=${qI}→${qJ} kN/m`;
} else if (l.type === 'pointOnElement') {
description = `Elem ${d.elementId}: P=${d.p} kN at ${d.a} m`;
} else if (l.type === 'thermal') {
description = `Elem ${d.elementId}: ΔT=${d.dtUniform}°C, ΔTg=${d.dtGradient}°C`;
} else {
description = `${l.type} on ${d.elementId ?? d.nodeId ?? '?'}`;
}
return { type: l.type, description, caseLabel };
});

// Build combination info
const combinations = modelStore.model.combinations.map(c => ({
id: c.id,
name: c.name,
factors: c.factors.map(f => ({
caseName: modelStore.getLoadCaseName(f.caseId) || `Case ${f.caseId}`,
factor: f.factor,
})),
}));

const data: CalcReportData = {
config,
is3D,
analysisMode: modeLabel,
provenance: deriveProvenance(),
hasDesignChecks: verificationStore.hasResults,
nodes: [...modelStore.nodes.values()],
elements: [...modelStore.elements.values()],
materials: [...modelStore.materials.values()],
sections: [...modelStore.sections.values()],
supports: [...modelStore.supports.values()],
loads,
loadCases: modelStore.model.loadCases ?? [],
combinations,
results2D: !is3D ? (resultsStore.results ?? undefined) : undefined,
results3D: is3D ? (resultsStore.results3D ?? undefined) : undefined,
};

openCalcReport(data);
open = false;
}
</script>

{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" onclick={() => open = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog" onclick={(e) => e.stopPropagation()}>
<h3>{t('calcReport.title')}</h3>
<div class="form">
<label>
<span>{t('calcReport.projectName')}</span>
<input type="text" bind:value={projectName} />
</label>
<label>
<span>{t('calcReport.engineerName')}</span>
<input type="text" bind:value={engineerName} placeholder={t('calcReport.optional')} />
</label>
<label>
<span>{t('calcReport.companyName')}</span>
<input type="text" bind:value={companyName} placeholder={t('calcReport.optional')} />
</label>
<label>
<span>{t('calcReport.notes')}</span>
<textarea bind:value={notes} rows="2" placeholder={t('calcReport.optional')}></textarea>
</label>
</div>
{#if !hasResults}
<div class="no-results-warning">{t('calcReport.noResults')}</div>
{/if}
<div class="actions">
<button class="btn-secondary" onclick={() => open = false}>{t('calcReport.cancel')}</button>
<button class="btn-primary" onclick={generateReport} disabled={!hasResults}>{t('calcReport.generate')}</button>
</div>
</div>
</div>
{/if}

<style>
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: #0d1b2e;
border: 1px solid #1a4a7a;
border-radius: 8px;
padding: 1.5rem;
width: 380px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.dialog h3 {
margin: 0;
font-size: 1rem;
color: #eee;
}
.form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.form label span {
font-size: 0.75rem;
color: #888;
}
.form input, .form textarea {
padding: 0.4rem 0.6rem;
background: #0f3460;
border: 1px solid #1a4a7a;
border-radius: 4px;
color: #eee;
font-size: 0.85rem;
}
.form textarea {
resize: vertical;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.btn-secondary {
padding: 0.4rem 1rem;
background: #12192e;
border: 1px solid #333;
border-radius: 4px;
color: #888;
cursor: pointer;
font-size: 0.8rem;
}
.btn-secondary:hover { background: #1a1a2e; color: #ccc; }
.btn-primary {
padding: 0.4rem 1rem;
background: #1a4a7a;
border: 1px solid #2a6ab0;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) { background: #2a6ab0; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.no-results-warning {
font-size: 0.8rem;
color: #e8a040;
background: rgba(232, 160, 64, 0.1);
border: 1px solid rgba(232, 160, 64, 0.3);
border-radius: 4px;
padding: 0.5rem 0.7rem;
text-align: center;
}
</style>
20 changes: 14 additions & 6 deletions web/src/components/MobileResultsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
}
</script>

<!-- Toggle button — always visible on mobile when panel is closed -->
{#if uiStore.isMobile && !uiStore.mobileResultsPanelOpen}
<!-- Toggle button — visible on mobile in Basic mode only (PRO has its own toolbar button) -->
{#if uiStore.isMobile && !uiStore.mobileResultsPanelOpen && uiStore.appMode === 'basico'}
<button
class="mrp-reopen"
style="top: {uiStore.floatingToolsTopOffset}px"
Expand All @@ -44,17 +44,17 @@
</button>
{/if}

<!-- Floating results panel -->
{#if uiStore.isMobile && uiStore.mobileResultsPanelOpen}
<div class="mrp-panel" style="top: {uiStore.floatingToolsTopOffset}px">
<!-- Floating results panel — Basic and PRO mobile -->
{#if uiStore.isMobile && uiStore.mobileResultsPanelOpen && (uiStore.appMode === 'basico' || uiStore.appMode === 'pro')}
<div class="mrp-panel" class:mrp-pro={uiStore.appMode === 'pro'} style="top: {uiStore.appMode === 'pro' ? 4 : uiStore.floatingToolsTopOffset}px">
<div class="mrp-header">
<span class="mrp-title">{t('mobile.results')}</span>
<button class="mrp-close" onclick={() => uiStore.mobileResultsPanelOpen = false}>&times;</button>
</div>
<div class="mrp-body">
<!-- Solve button — always present -->
<button class="mrp-solve" onclick={handleSolve} disabled={!hasModel}>
{is3D ? t('results.solve3d') : t('results.solve')}
{uiStore.appMode === 'pro' ? t('pro.solve') : is3D ? t('results.solve3d') : t('results.solve')}
</button>

{#if hasResults}
Expand All @@ -77,6 +77,9 @@
<button class="mrp-btn" class:active={resultsStore.diagramType === 'torsion'} onclick={() => resultsStore.diagramType = 'torsion'}>{t('results.torsion')}</button>
<button class="mrp-btn" class:active={resultsStore.diagramType === 'axialColor'} onclick={() => resultsStore.diagramType = 'axialColor'}>{t('results.axialColors')}</button>
<button class="mrp-btn" class:active={resultsStore.diagramType === 'colorMap'} onclick={() => resultsStore.diagramType = 'colorMap'}>{t('results.colorMap')}</button>
{#if uiStore.appMode === 'pro'}
<button class="mrp-btn" class:active={resultsStore.diagramType === 'verification'} onclick={() => resultsStore.diagramType = 'verification'}>{t('results.verification') !== 'results.verification' ? t('results.verification') : 'Verification'}</button>
{/if}
{/if}
</div>

Expand Down Expand Up @@ -182,6 +185,11 @@
animation: mrp-slide-in 0.2s ease;
}

/* PRO mode: tight upper-left anchoring under the PRO mobile toolbar */
.mrp-panel.mrp-pro {
left: 4px;
}

@keyframes mrp-slide-in {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
Expand Down
27 changes: 25 additions & 2 deletions web/src/components/StatusBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,34 @@

function getSelectionText(): string {
const nNodes = uiStore.selectedNodes.size;
const nElems = uiStore.selectedElements.size;
if (nNodes === 0 && nElems === 0) return '—';
const nSups = uiStore.selectedSupports.size;
const nLoads = uiStore.selectedLoads.size;
// Separate frame/truss elements from shell elements in selectedElements.
// Check shells first: if an ID exists in both plates/quads AND elements
// (different entity types with overlapping numeric IDs), shells take priority
// when the user is in shells selectMode; otherwise elements take priority.
const shellMode = uiStore.selectMode === 'shells';
let nElems = 0;
let nShells = 0;
for (const id of uiStore.selectedElements) {
const isShell = modelStore.plates.has(id) || modelStore.quads.has(id);
const isElem = modelStore.elements.has(id);
if (isShell && isElem) {
// Ambiguous — use selectMode to disambiguate
if (shellMode) nShells++; else nElems++;
} else if (isShell) {
nShells++;
} else if (isElem) {
nElems++;
}
}
if (nNodes === 0 && nElems === 0 && nShells === 0 && nSups === 0 && nLoads === 0) return '—';
const parts: string[] = [];
if (nNodes > 0) parts.push(`${nNodes} ${nNodes > 1 ? t('status.nodesPlural') : t('status.nodes')}`);
if (nElems > 0) parts.push(`${nElems} ${nElems > 1 ? t('status.elemsPlural') : t('status.elems')}`);
if (nShells > 0) parts.push(`${nShells} ${nShells > 1 ? t('status.shellsPlural') : t('status.shells')}`);
if (nSups > 0) parts.push(`${nSups} ${nSups > 1 ? t('status.supportsPlural') : t('status.supports')}`);
if (nLoads > 0) parts.push(`${nLoads} ${nLoads > 1 ? t('status.loadsPlural') : t('status.loads')}`);
return parts.join(', ');
}

Expand Down
24 changes: 21 additions & 3 deletions web/src/components/Toolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -479,18 +479,36 @@
return;
}
if (uiStore.selectedLoads.size > 0) {
const loadsToDelete = [...uiStore.selectedLoads];
// selectedLoads holds load data ids (the 2D viewport selects by data.id)
const ids = [...uiStore.selectedLoads];
modelStore.batch(() => {
for (const loadId of loadsToDelete) modelStore.removeLoad(loadId);
for (const id of ids) modelStore.removeLoad(id);
});
uiStore.clearSelectedLoads();
resultsStore.clear();
} else if (uiStore.selectedNodes.size > 0 || uiStore.selectedElements.size > 0) {
const nodesToDelete = [...uiStore.selectedNodes];
const elemsToDelete = [...uiStore.selectedElements];
const shellMode = uiStore.selectMode === 'shells';
modelStore.batch(() => {
for (const nodeId of nodesToDelete) modelStore.removeNode(nodeId);
for (const elemId of elemsToDelete) modelStore.removeElement(elemId);
for (const elemId of elemsToDelete) {
const isShell = modelStore.plates.has(elemId) || modelStore.quads.has(elemId);
const isElem = modelStore.elements.has(elemId);
if (isShell && isElem) {
if (shellMode) {
if (modelStore.plates.has(elemId)) modelStore.removePlate(elemId);
else modelStore.removeQuad(elemId);
} else {
modelStore.removeElement(elemId);
}
} else if (isShell) {
if (modelStore.plates.has(elemId)) modelStore.removePlate(elemId);
else modelStore.removeQuad(elemId);
} else if (isElem) {
modelStore.removeElement(elemId);
}
}
});
uiStore.clearSelection();
resultsStore.clear();
Expand Down
Loading
Loading