Skip to content

[5] PRO result-query layer — member queries, combo filters, and export#57

Merged
diegokingston merged 17 commits into
mainfrom
pr/6-pro-result-query
Jun 11, 2026
Merged

[5] PRO result-query layer — member queries, combo filters, and export#57
diegokingston merged 17 commits into
mainfrom
pr/6-pro-result-query

Conversation

@Batuis

@Batuis Batuis commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Adds a result-query / extraction surface to the PRO Results tab so engineers can interrogate solved 3D results instead of only scrolling tables.

What

  • New pure module lib/engine/result-query.ts: per-end query rows, max/min/abs-max extremes, abs-threshold filter, governing-combo lookup (preserves source-combo label), and CSV serialization. No store/DOM/WASM deps.
  • ProResultsTab "Consulta de resultados" section: scope (all / selection / typed IDs), source (active view or governing combo), component (N/Vy/Vz/T/My/Mz), governing-value card (clickable → selects element), threshold filter, and "export current query rows" CSV.
  • activeSourceLabel reads resultsStore.activeView (source of truth) so the label never claims "Envolvente" while single/all-loads data is shown.
  • i18n: pro.query* keys in en + es (other locales fall back to en).
  • .gitignore: anchor the Nix result/result-* rules to repo root so they stop silently ignoring source files like result-query.ts.

Not touched

No solver / engine / verification / design changes; no results.svelte.ts mutation. Purely additive read-only layer over existing result stores.

Verification

  • result-query unit tests: 17/17 pass (max/min/absmax, governing label preservation, filter predicate, empty/no-results, CSV).
  • Full suite green (73 suites); npm run build succeeds.
  • Manual browser QA on a 3D PRO model (pro-edificio-7p): governing value matches the internal-forces table (47.50 kN·m, Elem 34 j = both-ends table max); scopes/threshold/governing mode/click-select/CSV export all verified; 0 console errors. Post-solve source label confirmed correct after the label fix.

🤖 Generated with Claude Code

Batuis added 16 commits May 29, 2026 14:50
…Phase B)

First implementation slice of pr/5-basic-mode-overhaul. Schema + store +
solver wiring + persistence for the two existing solver primitives that
already model translational-release / sliding-bearing behavior.

Frame: joint/connection behavior. Connectors and eccentric connections
are exposed as solver primitives, NOT as a new top-level "Connectors"
object family. Translational releases stay off the typed Release
interface — they map to either an EccentricConnectionConstraint with
releases[] flags or a ConnectorElement with selective zero stiffness.

Schema (web/src/lib/engine/types.ts, types-3d.ts):
- ConnectorElement type: nodeI/nodeJ + kAxial/kShear/kMoment + 3D-only
  kShearZ/kBendY/kBendZ. Mirrors Rust ConnectorElement.
- EccentricConnectionConstraint: 5th variant of Constraint3D union.
  Carries masterNode, slaveNode, offsetX/Y/Z, releases[].
- SolverInput3D.connectors?: Map<number, ConnectorElement>.
- SolverInput (2D) gets parallel constraints?/connectors? for schema
  symmetry; runtime 2D wiring lands when Basic 2D needs it.

Store (web/src/lib/store/model.svelte.ts):
- StructureModel.connectors: Map<number, ConnectorElement>.
- nextId.connector counter.
- addConnector / updateConnector / removeConnector / clearConnectors.
  Map-reassignment pattern per CLAUDE.md "Reactivity with Maps".
- snapshot() / restore() round-trip connectors.
- clear() resets connectors.

Persistence:
- ModelSnapshot.connectors? added (history.svelte.ts).
- file.ts already round-trips constraints + connectors via the
  snapshot/restore chain — no direct edit needed there. Phase A audit
  initially flagged file.ts as dropping constraints; deeper inspection
  showed snapshot/restore covers them. Only connectors were missing,
  now wired.
- url-sharing.ts: connectors round-trip as `c.cx` (cn already taken by
  constraints, co by combinations).

Solver wiring:
- solver-service.ts buildSolverInput3D includes model.connectors.
- wasm-solver.ts serializeInput2D + serializeInput3D emit connectors
  (and constraints for 2D, which were also missing on the wire).

Verification (web/src/lib/engine/__tests__/connectors-eccentric.test.ts):
- ConnectorElement: kAxial/kShear/kMoment + 3D fields between two nodes,
  solve3D accepts payload, returns displacements, no error diagnostics.
- EccentricConnectionConstraint with translational release on ux,
  solve3D accepts payload, no error diagnostics.

Out of scope for this slice (per revised plan):
- UI surfaces (deferred to Phase C, after this slice is reviewed).
- 2D runtime wiring of constraints/connectors (deferred to Basic-mode UI
  slice).
- "Sliding bearing" preset / Option B sugar.
…PRO UI

Smallest UI slice that exposes a solver-backed translational-release
primitive end-to-end through the existing constraints workflow. Frame:
joint/connection behavior — extends the existing ProConstraintsTab
pattern, no new top-level surface, no Basic-mode panel overhaul yet.

Per the workstream rule, every translational-release-like behavior
remains an explicit visible solver primitive. No "fake hinge" magic, no
implicit translation between Release and EccentricConnection.

ProConstraintsTab.svelte:
- ConstraintKind union extended with 'eccentricConnection' (5th option,
  placed between equalDof and linearMpc).
- Form fields: master/slave node IDs, offsetX/Y/Z (m), and 6 release
  checkboxes labelled [ux, uy, uz, rx, ry, rz] in solver order.
- Inline hint that explicitly names what the released DOFs mean
  (sliding bearings = translational release; partial hinges =
  rotational release) and re-states the DOF order.
- constraintLabel(c) renders `Eccentric: M → S, offset (x,y,z),
  released [dofs]` with comma-separated released-DOF names so the
  table row stays scannable.
- DOF ordering protected with a reminder comment: the 3D 6-bool array
  MUST mirror engine/src/types/input.rs EccentricConnectionConstraint
  releases ordering. 2D ordering [ux, uz, ry] is documented but not
  used here (PRO is 3D-only).

i18n (en + es):
- pro.eccentricConnection, pro.constraintEcc, pro.eccentricNoRelease,
  pro.eccentricHint, pro.releases, pro.offsetX/Y/Z.

Verification:
- web/src/lib/engine/__tests__/connectors-eccentric-persistence.test.ts:
  3 tests covering snapshot/restore round-trip for the new constraint
  AND for ConnectorElement, plus the share-URL compress/decompress path.
- Existing connectors-eccentric.test.ts (Phase B) still green: solve3D
  accepts the new payloads.
- Headless Playwright (not committed): created an EccentricConnection
  via the form on RC Design Frame (master=1, slave=2, offsetY=0.5,
  release ux only), confirmed:
    * type select shows the new option in correct position
    * checkbox order is [ux, uy, uz, rx, ry, rz] (matches solver)
    * row appears in the constraints table after Add
    * row description reads "Eccentric: 1 → 2, offset (0, 0.5, 0),
      released [ux]"
    * subsequent Solve completes with 0 page errors and no tab error
- vite build clean. Full vitest sweep: +3 new pass, no new failures
  (same 5 pre-existing failures as the base, unrelated to this slice).
… wording

linearMPC was a real, end-to-end-broken path. Confirmed by driving the
existing UI through the live solver: every linearMpc constraint created
through PRO Constraints would fail solver parse with
`unknown variant 'linearMpc', expected ... 'linearMPC'`.

Three concrete shape mismatches against the Rust contract (no solver
edit — the Rust side is the contract):

1. Discriminator: TS `'linearMpc'` vs Rust serde rename `linearMPC`.
2. Term shape: TS `{nodeId, dof: string, coeff: number}` vs Rust
   `MPCTerm { node_id, dof: usize, coefficient: f64 }`. Camel-case
   serde rule covers node_id↔nodeId; the rest were JS-side renames /
   wrong types: `dof` was a string (e.g. "ux") and needed to be the
   integer DOF index 0..5; `coeff` needed to be `coefficient`.
3. `rhs` field exposed in the UI does not exist on
   `LinearMPCConstraint` — the constraint sums to 0 by definition.
   It was being silently dropped by serde (no `deny_unknown_fields`),
   which means users who typed an RHS got the wrong equation.

Fixes (web/src/lib/engine/types-3d.ts,
       web/src/components/pro/ProConstraintsTab.svelte,
       web/src/lib/i18n/locales/{en,es}.ts):

- ConstraintType / LinearMpcConstraint discriminator: 'linearMPC'.
- LinearMpcConstraint.terms: `{ nodeId, dof: number, coefficient: number }`.
  Comment in the interface marks the contract.
- addLinearMpc(): parse `dof` string → index via dofLabels.indexOf,
  emit `coefficient` (was `coeff`), emit `linearMPC` (was `linearMpc`),
  no `rhs` in payload.
- Removed `rhs` UI input + state + i18n in the row label.
- selectedKind comparison + addConstraint() dispatch updated.
- constraintLabel for linearMPC: `MPC: {n} terms` (no RHS).

Verification (Playwright against live preview):
- Linear MPC selected → only 1 input visible (terms), no RHS field.
- Add: rows 0→1, row description "MPC: 2 terms".
- Solve completes: tabErr=false, 0 page errors, 0 console errors.
  (Before the fix: solver returned `Parse error: unknown variant
  'linearMpc'` and the constraint was non-functional.)

Tightened EccentricConnection offset wording (same files):
- Labels: "Offset X / Y / Z (m)" → "Offset X / Y / Z from master (m)".
- Hint: prepended "Offset is measured from the master node to the
  connection point." so the form is unambiguous about who the offset
  is relative to.

Out of scope for this slice (reported separately for follow-up):
- rigidLink and equalDof constraints are broken with the SAME class of
  bug (rigidLink: `dofs: ["ux", ...]` vs Rust Vec<usize>;
  equalDof: discriminator 'equalDof' vs Rust 'equalDOF'). Discovered
  during the verification probe. Disciplined per task scope to
  "linearMpc only" — flagging for next slice rather than expanding here.
Both pre-existing constraint kinds were end-to-end broken in the same
class as linearMPC was: discriminator and/or DOF-array shape diverged
from the Rust contract in engine/src/types/input.rs. Verified via the
live solver before the fix:

- rigidLink → `Parse error: invalid type: string "ux", expected usize`
  (UI emitted dofs as name strings; Rust RigidLinkConstraint.dofs is
  Vec<usize>).
- equalDof → `Parse error: unknown variant 'equalDof', expected ...
  'equalDOF'` (Rust serde rename keeps the all-caps acronym), plus the
  same string-DOFs vs Vec<usize> issue.

Fix scope is intentionally tight: discriminator rename + DOF-index
encoding only. Same pattern as the prior linearMPC fix.

Files changed:
- web/src/lib/engine/types-3d.ts: ConstraintType union: 'equalDof' →
  'equalDOF'. EqualDofConstraint.type discriminator likewise.
  RigidLinkConstraint.dofs typedoc updated to spell out the integer
  index mapping (3D: 0=ux, 1=uy, 2=uz, 3=rx, 4=ry, 5=rz).
- web/src/components/pro/ProConstraintsTab.svelte:
  * ConstraintKind union and constraintKinds dropdown value: 'equalDOF'.
  * addRigidLink(): emit dofs as integer indices via
    dofLabels.map((_, i) => i).filter(i => rlDofs[i]).
  * addEqualDof(): same encoding + emit type 'equalDOF'.
  * selectedKind comparison and addConstraint() dispatch updated.
  * constraintLabel: handle 'equalDOF' (was 'equalDof'); shared
    dofIndicesToNames() helper renders integer indices back to readable
    names (ux,uy,...) for the table row, with a string-tolerant fallback
    to absorb any stale data persisted before this fix.

Verification (Playwright against live preview, all on RC Design Frame):
- rigidLink (master=1, slave=2, all 6 dofs): rows 0→1, desc
  "Rigid: 1 → 2 [ux,uy,uz,rx,ry,rz]", solve clean (0 errors).
- equalDOF (master=1, slave=2, ux+uy+uz): rows 0→1, desc
  "DOF =: 1 → 2 [ux,uy,uz]", solve clean.
- eccentricConnection regression: still solves clean.
- linearMPC regression: still solves clean.

Production build clean (12.67s). Full vitest sweep: same 5 pre-existing
failures as the base, no new regressions.
Surface ConnectorElement (joint/spring/bearing primitive between two
nodes) as a section under the existing PRO Constraints workflow. Per
the user's revised UI direction, NOT a separate top-level Connectors
tab. Sits below the constraints table with a visual separator so it
reads as its own surface inside the same right-side workflow.

Mental-model framing:
- Intro line above the form: "Joint / spring / bearing between two
  nodes. Not a structural member — does not appear in M/V/N diagrams
  or design checks." This sets the expectation explicitly that
  connectors are NOT iterated by the diagrams/verification paths.
- Hint line below the form: "Stiffness in each named direction
  (kN/m or kN·m/rad). A value of 0 means sliding / flexibility in
  that direction (e.g. kAxial = 0 with stiff kShear/kShearZ = sliding
  bearing along the connector axis)."
- No presets, no "fake hinge" sugar — the user types stiffness values
  for each of the six solver-shaped directions and sees exactly what
  the solver will receive.

Form fields (all 8 inputs, all explicit):
- Node i / Node j (validated against modelStore.nodes).
- Stiffness (in-plane): kAxial, kShear, kMoment.
- Stiffness (3D only): kShearZ, kBendY, kBendZ.
- Add-connector validator rejects all-zero connectors (those are
  guaranteed mechanisms and almost certainly user error).

Table column shape mirrors the constraints table:
- # / Nodes (i → j) / Stiffness summary / × delete.
- fmtStiff() folds large magnitudes to exponential notation so rows
  stay readable.

i18n (en + es): pro.nConnectors, pro.addConnector, pro.connectorIntro,
pro.connectorHint, pro.nodeI / nodeJ, pro.kInPlane, pro.k3D,
pro.thNodes, pro.thStiffness.

Solver-alignment audit (read-only sweep):
- Verified `model.connectors` is consumed ONLY by url-sharing.ts,
  solver-service.ts, wasm-solver.ts, and modelStore. NOT by viewport
  rendering, NOT by diagrams (compute_diagrams_3d), NOT by
  verification (cirsoc201.ts, station-design-forces). Connector UI
  introduction does not require touching any of those layers.

Verification (Playwright against live preview, on RC Design Frame):
- Connectors header reads "Connectors: 0" before any add.
- Intro text rendered verbatim.
- Form has 8 stiffness inputs in declared order.
- Sliding-bearing case: nodeI=1, nodeJ=2, kAxial=0, kShear=1e6,
  kMoment=0, kShearZ=1e6, kBendY=0, kBendZ=0 — added cleanly,
  rows 0→1, row description renders the full stiffness summary
  with kAxial=0 / kShear=1.0e+6 / etc.
- Subsequent Solve completes: tabErr=false, 0 page errors,
  0 console errors. Solver accepts the new payload without complaint.

Persistence/share path was already wired end-to-end in Phase B
(commit 0c9200e). The persistence test in
connectors-eccentric-persistence.test.ts exercises modelStore.
addConnector + snapshot/restore + compressSnapshot/decompressSnapshot
round-trip for the connector. Still green.

Production build clean. No regressions on existing constraints.
…light

Mirrors what the next commit does for constraints, applied here to
ConnectorElement.

Without this fix, the JS preflight in validateAndSolve (2D + 3D) and the
live model-diagnostics orphan check would flag any node coupled solely
through a ConnectorElement as disconnected — even though the Rust solver
itself accepts the model. The fix:

  - solver-service.ts: include connector endpoints in both the orphan-set
    and the graph-component BFS, in both validateAndSolve2D and
    validateAndSolve3D (4 sites total)
  - model-diagnostics.ts: same orphan-check extension for the live
    diagnostics warning panel
  - model.svelte.ts: remapModelForPlane now passes connectors through
    (was silently dropping them on the way to validateAndSolve2D)

Solver itself is untouched. No new UI surface — this is a correctness
fix for the existing ConnectorElement plumbing.
…eflight

Same class of false-positive the connector slice fixed, now extended to
constraints. The Rust solver already accepts constraint-only-coupled
nodes; the JS preflight (`validateAndSolve2D`/`validateAndSolve3D`) and
the live model diagnostics were running orphan-node + single-component
checks over `model.elements` only, so a node coupled solely through a
rigidLink / equalDOF / eccentricConnection / diaphragm / linearMPC was
incorrectly blocked at solve time.

All five existing constraint kinds now count as connectivity:
  - rigidLink:           master ↔ slave
  - equalDOF:            master ↔ slave
  - eccentricConnection: master ↔ slave
  - diaphragm:           master ↔ each slaveNode[i]
  - linearMPC:           every node in the term set ↔ every other

No constraint kind is intentionally excluded.

Logic lives in a new tiny `constraint-connectivity.ts` helper, consumed
by both the preflight (4 sites: 2D + 3D × orphan-set + BFS-adjacency)
and the diagnostics panel. New unit-test file covers all 5 kinds in
both 2D and 3D, plus a negative control to keep the orphan check honest
for genuinely free-floating nodes.

Solver itself is untouched.
…ements

User-reported bug: with snap-to-grid enabled, drawing an element to an
existing node that does NOT sit on a grid intersection silently fails —
the cursor warps to the nearest grid intersection and the element either
doesn't connect or attaches to the wrong place. Workaround was to disable
grid snap, which is the wrong UX.

Root cause: two sites in the snap pipeline searched for nearby nodes
*from the grid-snapped position* instead of from the raw cursor. If the
off-grid node was further than the 0.5m node threshold from the nearest
grid intersection, the search missed it even though the cursor was
directly on top of the node.

  - lib/viewport/spatial-queries.ts:snapWithMidpoint — the function's
    own doc comment says "priority: existing node > element midpoint >
    grid", but the implementation grid-snapped first, then searched
    nodes from the snapped point. Now searches nodes from raw coords;
    grid snap is the fallback when no node and no midpoint match.

  - components/Viewport.svelte (element click handler) — used the
    grid-snapped position as the node search center. Now uses raw
    world.x/world.y, matching what the support and load tools already
    do.

Snapping precedence rule after the fix (matches the doc comment):
  1. existing node within nodeThreshold (0.5m) of raw cursor → wins
  2. element midpoint within midpointThreshold (0.4m) of raw cursor
  3. grid snap (fallback for free placement)

Side effects:
  - Visual snap highlight in Viewport.svelte now lights up correctly
    when hovering an off-grid node, because the upstream worldX/worldY
    that drives the highlight comes from snapWithMidpoint and now
    points at the node coords as designed.
  - The node-creation tool would, when hovering near an off-grid node,
    place a duplicate node at the same coordinates — same buggy
    behavior it had for grid-aligned nodes already, just consistent
    now. Out of scope for this slice.

6 unit tests covering: cursor on off-grid node (was missed), proximity
pick between two competing nodes, fallback to grid when no node match,
and the no-regression on-grid case.

Solver itself untouched.
…ment

When the user has the node tool active and clicks on the interior of an
existing element, subdivide that element into two with the new node
on it — instead of placing a free-floating node on top of the bar.

Off by default. Lives under Settings → Model as
"Auto-split elements when placing nodes on them" (es: "Subdividir
barras al colocar nodos sobre ellas"). Hidden in 3D/PRO since it's
wired into the Basic 2D Viewport for now.

Implementation:
  - uiStore.autoSplitOnNodePlace ($state<boolean>(false)).
  - Viewport.svelte node-tool create-mode handler: when the flag is
    on AND no existing node is within 0.5m of the raw cursor AND
    findNearestElement returns a hit within 0.3m perpendicular AND
    the projected parametric position t ∈ [0.05, 0.95], delegate to
    modelStore.splitElementAtPoint(elemId, t). On success, select the
    new node, clear results, toast "Bar subdivided".
  - Otherwise (flag off, no element hit, near-endpoint, or click on an
    existing node), the legacy addNode path runs unchanged.

Load handling: splitElementAtPoint already redistributes distributed,
point, and thermal loads — same code path the hinge-mode subdivide
flow has been using in production. The Playwright probe verifies a
uniform q=−10 kN/m on a 6m bar yields two distributed-load segments
with conserved total (−60 kN exact) after a t=0.5 split. The unit
test pins the same conservation property + the type/material/section/
thermal preservation contract.

Endpoint guard: the click handler enforces t ∈ [0.05, 0.95] before
calling splitElementAtPoint (which has its own internal 0.01/0.99
guard). Clicks too close to either end fall back to plain addNode so
no zero/sliver elements get created. Clicks on/near an existing node
also bypass the split (the existing node is the user's intent).

Verified via 5-case Playwright probe (off+interior, on+interior,
on+near-endpoint, on+existing-node, on+loaded-element) — all pass
with zero console errors.

Solver itself untouched.
User-reported rough edge made more visible by the recent snap-precedence
work: clicking the node tool on top of an existing node would silently
add a second coincident node at the same coordinates.

Root cause: the node-tool create-mode handler resolved the snap target
via snapWithMidpoint (which now correctly returns the existing node's
coords when the cursor is within nodeThreshold of one), then unguardedly
called addNode at that resolved position — producing a stacked duplicate.

Fix is local to the click handler in Viewport.svelte: before the
addNode fallback, run findNearestNode against the RAW cursor coords
within the same 0.5m threshold snapWithMidpoint uses; if a node is
found, treat the click as "select that node" instead of placing a
second one. addNode's contract is unchanged (still creates a node at
the requested coords for programmatic / scripted callers).

The guard sits in the same fallback branch the auto-split path lands
in when its own !nearNode pre-check rejects, so:

  - click on existing node, auto-split off → select existing, no dup ✓
  - click on existing node, auto-split on → no split (existing-node
    pre-check), then no dup (this guard) ✓
  - click in empty space → legacy addNode unchanged ✓
  - element drawing to off-grid node (snap-precedence slice) → unaffected ✓
  - snap-to-grid off → still no dup (raw-cursor search) ✓

Verified via 7-case Playwright probe + re-run of the prior snap and
auto-split probes.

3D Basic node tool (handleNodeTool in Viewport3D.svelte) is unaffected
by this slice — it uses ground-plane raycasting and never resolves to
an existing node, so the duplicate path doesn't apply there. Out of
scope per "keep scope tight"; flagged as a follow-up.

Solver itself untouched.
… rule

Part A — Basic 3D regressions
─────────────────────────────

1. AxialColor / colorMap / verification not visible in wireframe mode.
   syncColorMap3D was applying colors to per-element groups via
   setGroupColor — but in wireframe render mode those groups are
   essentially empty (the visible primary is the shared LineSegments2
   batched mesh, which carries its own per-segment color buffer that
   nothing was updating). Result: pressing "Axial Colours" silently
   did nothing in Basic 3D wireframe.

   Fix: also push the chosen color into ctx.elementsBatched via
   setBaseColor + flush, in every branch (axialColor, colorMap continuous
   gradient, colorMap shellVonMises, verification) AND in the restore
   path when the user leaves color mode. ResultsSyncContext now carries
   an elementsBatched reference; init wires it from sceneCtx.

2. Color map hides during camera motion (orbit/pan/zoom).
   applyLowDetail in lod.ts hid `elementsParent` during orbit. In solid /
   sections render mode the result colors live on the cylinders /
   extrusions inside elementsParent, so hiding it made the visualization
   disappear every camera move. Fix follows the existing exception for
   resultsParent: applyLowDetail now takes an optional
   `{ resultsColoringActive }` flag and skips hiding elementsParent when
   it's true. Viewport3D.setLowDetail computes the flag from the active
   diagramType + presence of results3D. Two new lod.test.ts cases pin
   the new behavior and the no-drift-when-flag-is-false case.

3. Trusses indistinguishable from frames in wireframe.
   In wireframe mode the batched mesh carries the visual; eb.upsert
   initializes new entries with COLORS.frame regardless of element type.
   syncSelection later applies the per-type color, but it only runs on
   selection changes, not on initial load. Result: every example loaded
   showing trusses in the cyan frame color until the user clicked
   something.

   Fix: syncElements now calls eb.setBaseColor(id, ...) with the
   per-type wireframe-vs-solid base color directly after upsert, so
   trusses (yellow #f0b848) and frames (cyan #6cb4ff) are visually
   distinct from first paint.

4. "Elements 10+ disappear" — could not reproduce.
   Loaded hinged-arch-3d (12 elem), space-truss (53), space-frame (388),
   tower-3d-4 (96), industrial (633) — all rendered with full element
   counts in my probe. The most plausible explanation is that fix #3
   above (trusses showing as cyan default) made some elements look
   "missing" against a busy structure where they blended into adjacent
   frames. The truss-color fix should resolve the perceived issue. If
   the user still sees elements missing under a specific interaction
   sequence, that needs a fresh repro.

Part B — auto-split + snap-to-grid refinement
─────────────────────────────────────────────

1. autoSplitOnNodePlace default flipped to ON.
   The natural intent of clicking with the node tool on top of an
   existing bar IS "I want a node on this bar". Defaulting OFF was
   conservative for the first slice; with the slice accepted, the
   natural default is ON.

2. Setting moved above Units in Settings → Model.
   ToolbarConfig.svelte now renders the auto-split checkbox right
   below "Show loads" and above the Units group, so the modeling
   options live together (loads visibility + auto-split) before unit
   choice.

3. Auto-split + snap-to-grid combined rule.
   Previously the projection used the raw cursor position, so the new
   split node landed at an arbitrary unsnapped point. Now: when
   uiStore.snapToGrid is on, we project the GRID-SNAPPED cursor onto
   the chosen element line, so the split happens on the element AT
   a grid-aligned position. For axis-aligned bars (the common case)
   this puts the new node exactly on a grid intersection. For diagonal
   bars, the projection of a grid point is the closest reachable
   grid-anchored split point on the line. When snap-to-grid is off,
   `snapped` equals `world` so behavior matches the previous rule.

   findNearestElement still uses the RAW cursor (so the user can
   "point at" a bar even when the grid would warp the cursor away
   perpendicular to the line), but the projection input is the
   grid-snapped position. The endpoint guard t ∈ [0.05, 0.95] still
   applies; existing-node and duplicate-node guards still apply.

Verification (browser probes, all passing, zero console errors):
- 6 examples render full element counts
- axialColor mode applies blue/red colors visibly in wireframe
- 4-case grid-snap-autosplit probe confirms node lands on grid (3,0)
  when clicking off-grid (3.3, 0.05) on a horizontal bar
- prior snap-precedence + duplicate-node + autosplit probes still pass

Vitest: 2148 passing (+2 new lod tests), same 5 pre-existing failures.

Solver itself untouched.
…ger drags

User-reported bug: in Basic mode, while in element/select mode, click+
drag would silently move nodes. The user's mental model is that node
repositioning belongs to the node tool; the select tool should only
select; the element tool should only create elements.

Root cause: handleMouseDown's `currentTool === 'select'` branch (with
the default `selectMode === 'elements'`) ran:

    historyStore.pushState();
    draggedNodeId = nearNode.id;
    dragMoved = false;
    dragStartWorld = { x: snapped.x, y: snapped.y };

i.e. any time the user pressed mouse-down while a node was within 0.3 m
of the cursor, the next mousemove handler entered the drag branch and
moved the node. Users hit this constantly when just inspecting the model
because select+elements is the default UI state.

Fix is a tight tool-mode reassignment in Viewport.svelte:

  1. Select tool / elements selectMode: drop the historyStore.pushState
     + draggedNodeId set. The branch now only does selection.
     `uiStore.selectNode(nearNode.id, e.shiftKey)` — no more drag setup.
     Box-select still works (separate code path), shift-select still
     works.

  2. Node tool create mode: when the click effectively lands on an
     existing node (within nodeThreshold of the raw cursor), promote
     the previous "just select" behavior to "select + start drag" by
     setting draggedNodeId. This is now the ONLY entry point for node
     repositioning.

The existing mousemove drag block (gated on `draggedNodeId !== null`)
and the mouseup cleanup are unchanged — they keep working since they
depend only on the state, not on which tool set it. Touch handling
synthesizes a MouseEvent and routes through handleMouseDown so it
inherits the new gating automatically.

What still works (regression-checked via /tmp/probe-drag.mjs):
  A. Select tool drag attempt → node stays put (the fix).
  B. Select tool single click on a node → node selected, no dup.
  C. Element tool drag attempt → node stays put (was already correct).
  D. Node tool drag → node moves (legitimate repositioning).
  E. Node tool click on empty space → new node created.
  F. Node tool click on existing node → no duplicate, no movement.

3D sanity re-check:
  - axial colors still light up the arch in compression-blue post-solve
    in wireframe.
  - truss/frame distinction holds (space-truss yellow=527, space-frame
    cyan=2910 + truss-color pixels).
  - All 6 named examples render with full element counts.
  - LOD result-coloring exception still works (unit tests in lod.test.ts).

Vitest: 2148 passing, same 5 pre-existing failures, no new regressions.

Solver itself untouched.
Add a result-query / extraction surface to the PRO Results tab so users
can interrogate solved 3D results instead of only scrolling tables.

- New pure module lib/engine/result-query.ts: per-end query rows,
  max/min/abs-max extremes, abs-threshold filter, governing-combo lookup
  (preserves source-combo label), and CSV serialization. No store/DOM/WASM.
- ProResultsTab: scope (all/selection/typed IDs), source (active view or
  governing combo), component (N/Vy/Vz/T/My/Mz), governing-value card,
  threshold filter, click-to-select, and "export current query rows" CSV.
- activeSourceLabel reads resultsStore.activeView (source of truth) so the
  label never claims "Envolvente" while single/all-loads data is shown.
- i18n: pro.query* keys in en + es (other locales fall back to en).
- .gitignore: anchor the Nix result/result-* rules to repo root so they
  stop silently ignoring source files like result-query.ts.

No solver/engine/verification changes. 17 unit tests; full suite green.
- "Link with diagram" / "Vincular con diagrama" checkbox (on by default).
- When linked: query component drives resultsStore.diagramType
  (N→axial, Vy→shearY, Vz→shearZ, T→torsion, My→momentY, Mz→momentZ),
  and the current scope/threshold element set drives viewport highlight
  via uiStore.setSelection. Skips scope='selected' (selection IS the
  scope) and equal sets to avoid reactive loops. When unlinked, neither
  diagram nor selection is auto-changed.
- CSV export is now fully flat with self-contained metadata on every row:
  sourceKind, sourceId, sourceName, component, scopeMode, scopeIds,
  threshold, extremeMode, element, end, value, unit. Combo exports carry
  the real combo id+name; governing exports use sourceKind=governing with
  each element's own governing combo and end=governing. Export caption
  states exactly what is exported. "Export all active results" deferred.

No solver/engine/verification changes; results.svelte.ts untouched.
20 result-query unit tests; full web suite green; build passes.
The result query is now always linked to the active view:
- Remove the "Link with diagram" checkbox (linkage is permanent).
- Remove the Component selector — component derives from
  resultsStore.diagramType (axial→N, shearY→Vy, shearZ→Vz, torsion→T,
  momentY→My, momentZ→Mz) via new diagramTypeToComponent(). Non-force
  diagrams (deformed/colorMap/verification/…) show an explicit empty
  state ("Select a force diagram to query results") — no silent fallback.
- Remove the Source selector and the governing query mode from the panel.
  Source now follows the existing Case/Combo/Envelope view controls
  (resultsStore.activeView). Governing helpers stay in result-query.ts for
  a future explicit feature.
- Keep scope / extreme / |value|≥ filter / CSV export / click-to-select.
- Highlight stays linked (scope+filter drive selection); skips
  scope='selected' and non-force diagrams to avoid loops / wiping selection.
- CSV: flat per-row metadata incl. exact case/combo id+name; envelope uses
  sourceKind=envelope, sourceId="".
- Rename reactions toggle to "Show Reactions" / "Mostrar reacciones".

No solver/engine/verification changes; results.svelte.ts untouched.
23 result-query unit tests; full web suite green; build passes.
@Batuis Batuis force-pushed the pr/6-pro-result-query branch from f38fd3e to d793ab7 Compare June 7, 2026 18:21
@diegokingston diegokingston changed the base branch from pr/5-basic-mode-overhaul to main June 10, 2026 18:31
…ics, provenance

Correctness/UX (from the PR review):

- Selection link: the query→selection $effect tracked selectedElements,
  so any manual click (viewport, table row, or the governing card's own
  selectQueryElement) was instantly reverted to the full query set —
  click-to-select was self-defeating. The selection is now read inside
  untrack(): the effect pushes when the QUERY changes, manual selection
  sticks. An empty query (blank By-ID input) no longer wipes the
  selection.
- Envelope semantics: envelope view holds maxAbsResults3D (signed
  abs-max winners per field) — 'max'/'min' over it are not the true
  envelope extremes and could report +100 where the design minimum is
  -60. The mode is coerced to 'absmax' in envelope view and the other
  options are disabled.
- Provenance: handleSolve set only the local viewMode='envelope' while
  the store stayed activeView='single' — the Envelope toggle rendered
  active while the query card/CSV honestly said 'Case'. It now calls
  switchView('envelope') (syncs both). The label and the CSV metadata
  now read ONE derived activeSource (the two parallel branch chains
  could drift), and the all-loads single solve is labeled 'All loads'
  instead of a blank-id 'Case'.
- scope='selected' returns no rows in shells select-mode: selection ids
  are plate/quad ids there (colliding counters) and resolved as frame
  element forces the user never selected.
- Removed the $effect that rewrote uiStore.showLoads3D (and reset
  hideLoadsWithDiagram) on every mount/diagram change — it clobbered
  preferences owned by ToolbarConfig/ProLoadsTab/KinematicPanel and
  URL-restored state, and behaved differently depending on which tab
  was mounted. The hideLoadsWithDiagram pref already covers that UX.
- CSV: csvCell neutralizes spreadsheet formula prefixes (=+-@) on
  string cells — combo/case names are user-editable and shared via .ded
  files; numeric cells (negative values) are unaffected. Export uses
  the shared downloadText helper (in-DOM anchor; the inline copy was a
  detached-anchor no-op on some browsers). Test added.
- Render: keyed {#each} + 500-row render cap (export still includes all
  rows; note shown when capped) — scope='all' mounted 2 rows/element
  unbounded (20k <tr> on a 10k-element model).

Purge of the surface left behind by the simplify iteration: the
governing-query exports (governingForComponent, topGoverning,
governingToCsv, componentToDiagramType, FORCE_COMPONENTS) had zero
production callers — the PR body's 'governing combo source' does not
exist in the final UI; their tests, six orphaned i18n keys x 2 locales,
and the dead .col-src CSS are removed with them.
@diegokingston diegokingston merged commit 2922bfd into main Jun 11, 2026
3 checks passed
diegokingston added a commit that referenced this pull request Jun 11, 2026
…main)

main absorbed PRs #51 and #57 (including their review-fix commits).
Conflicts in lod.ts/lod.test.ts: main carried the pr/5 review fix
(shellsParent honors resultsColoringActive in the old always-strip LOD)
while this branch rewrote the LOD around the heavy-model policy.
Resolved keeping the heavy-model structure and porting the shell
exception into it: in the heavy fallback, shellsParent now follows the
same result-coloring exception as elementsParent (the shell Von Mises
heatmap lives on the shell groups). Tests merged accordingly.
diegokingston added a commit that referenced this pull request Jun 12, 2026
…to main)

main absorbed PRs #51/#57/#58 with their review fixes. Semantic
resolutions:
- scene-sync element signature: union of both extensions (tl + leftHand
  from main, offset from this branch).
- syncLocalAxes: main's always-mode cap / no-selection-read / shells
  guard combined with this branch's manual-selection semantics ("When
  selected" = manually selected only).
- ProResultsTab query-highlight effect: main's untrack design (fires on
  query changes only, empty query never wipes) combined with this
  branch's elementSelectionManual guard (a manual click survives query
  re-evaluations too).
- Triad $effect: selection/manual deps stay implicit (nested reads),
  preserving the always-mode rebuild fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants