Skip to content
AcnologiaK2QPublic

About

Interactive beam mechanics workspace: drag a load, watch shear/moment/deflection re-solve live

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

VectorLab

Live demo →

An interactive workspace for beam mechanics. Drag a load along a beam and the shear, bending-moment and deflection diagrams re-solve on every pointer frame.

Built for engineering students, who normally meet these diagrams as static figures in a textbook and have to imagine what happens when the load moves.

VectorLab: dragging a point load along a simply supported beam while the shear, moment and deflection diagrams re-solve

Every frame above is solved independently. Watch the reactions redistribute, the shear step invert as the load passes midspan, and the deflection peak trail behind the load rather than sitting under it.

npm install
npm run dev      # http://localhost:3000
npm test         # 124 tests
npm run demo     # regenerate docs/demo.gif

The brief

Five to ten days, solo, to a portfolio-quality MVP: an engineering workspace that makes structural mechanics interactive rather than a page of formulas. The scope had to be narrow enough to actually finish and specific enough to be worth finishing. Beam bending was the one domain where "drag a load, watch the diagram redraw" produces both a genuine teaching aid and a fifteen-second demo that speaks for itself.

The build followed a fixed order, and the order was the actual decision:

  1. Solver first, with zero UI. A pure TypeScript module with no React and no DOM, verified against textbook closed forms before a single pixel was drawn. A beautiful interface over wrong math isn't demoable; a correct solver with no interface at all still proves the concept.
  2. Deflection second, closing the domain layer. Finished and frozen before the interaction layer touched it, so nothing built on top of it would ever need the math to change underneath.
  3. A static scene, then direct manipulation. The first visual checkpoint was deliberately inert (see the diagrams render before making anything draggable), followed immediately by the actual differentiator: drag a load, watch every diagram morph live.
  4. Undo, export, polish, edge cases, in that order, last. Each one assumes the layer under it is stable. Polishing an interaction that might still change underneath it is wasted work.

That ordering is why the last day of work was spent finding bugs by probing the running system rather than adding features. See Day 9 below, which is arguably the most representative section of this README.

Architecture

Three layers that do not leak into each other:

Layer Location Depends on
Domain / solver src/lib/solver nothing, pure TypeScript
Scene / geometry src/lib/scene domain types only
Presentation src/components both

The solver has no React or DOM imports at all. That is what makes it testable against textbook closed forms rather than through the UI, and it is why the interaction layer could be built on a frozen, already-verified core.

Stack: Next.js (App Router), TypeScript, SVG, Zustand, Tailwind, Vitest.

What it does

  • Direct manipulation. Loads and supports are dragged on the canvas. Point loads slide along the span with a tail grip for magnitude; distributed loads translate as a unit, resize from end grips, and take intensity from the top rail; supports slide and swap type.
  • Live solving. There is no "Solve" button. The solver runs on every model change, including every frame of a drag.
  • Crosshair readout. Hovering any diagram drops a crosshair through all three plots and the beam, reading V, M and δ at one position simultaneously.
  • Precision entry. Every dragged quantity is also typeable in the inspector. Snapping is on by default; hold Alt to place freely.
  • Keyboard nudging. Arrow keys move the selection by one grid step, Shift for ten, Alt for a tenth, so the canvas is usable without a mouse.
  • Undo/redo that coalesces a whole drag into one step, and SVG/PNG export of the scene.
  • Responsive, down to a 420px viewport: the inspector overlays instead of competing for width below xl, and the scene keeps a readable floor width rather than shrinking labels past legibility.

Shortcuts

Key Action
← → Move selection along the beam
↑ ↓ Adjust the selected load's magnitude
Shift + arrow Ten times the step
Alt + arrow One tenth of the step
Alt + drag Disable snapping
Ctrl/⌘ + Z Undo
Ctrl/⌘ + ⇧ + Z Redo
Delete Remove the selection
Esc Deselect

Engineering decisions

Deflection is integrated, not looked up

The obvious approach is superposition from tabulated formulas. That fails as soon as supports can sit at arbitrary positions: no closed-form table exists for an overhanging beam under arbitrary loading, and this model allows exactly that.

So VectorLab integrates EI·v'' = M(x) directly, and this is exact, not a numerical approximation:

  • Between breakpoints, M(x) is a polynomial of degree ≤ 2 (point loads give linear M, distributed loads parabolic, applied moments a step).
  • The quadrature used is exact through degree 3, so the first integration is exact. Its result is degree ≤ 3, so the second integration is exact too.
  • Integrating piecewise between breakpoints keeps every sub-integral inside a single polynomial piece.

One code path covers cantilevers, simply-supported and overhanging beams. Tests assert agreement with closed forms to 1e-9 relative.

Why Milne's rule instead of Simpson's

Both are exact through degree 3, so the choice looks arbitrary, until you hit a beam with an applied moment. M(x) steps discontinuously there, and the solver resolves breakpoints within a tolerance, so any endpoint sample lands on an ambiguous side of the step. Shrinking the probe offset does not help; it has to stay larger than the solver's own tolerance.

Milne's rule is the open Newton-Cotes formula: it samples only the interior quarter-points and never touches an endpoint. The ambiguity disappears instead of being tuned around.

This surfaced as a test failure with a relative error of exactly 1/144, too clean to be noise, which is what pointed at the endpoint sample rather than at accumulated drift.

Undo coalesces per gesture, not per frame

A drag emits hundreds of model updates. Naive history would make one stroke cost hundreds of undo steps. The store stashes the model at pointerdown and records a single entry at pointerup; a gesture that changed nothing records nothing, so a click that merely selects does not consume a step. Discrete edits (palette insertions, deletions, typed values) are recorded immediately. Both paths share the same mutators, so the decision lives in one helper rather than at every call site.

Invalid states are prevented, not reported

Support drags clamp to a minimum gap instead of permitting coincident supports; promoting a support to fixed removes the others rather than silently producing an indeterminate model. What genuinely cannot be solved, a third simple support, surfaces the solver's own message and stays recoverable.

A related subtlety: the drag constraint classifies neighbours by where the support currently is, not where the pointer wants it. Using the target flips a neighbour to the far side the moment a drag crosses it, inverting the constraint window and freezing the handle instead of stopping it at the gap.

Magnitude drags need a frozen reference

Load arrow length is normalised against the largest load on the beam. Deriving a new magnitude from the rendered arrow length would therefore feed back into its own scale. Magnitude drags read a reference captured at pointerdown instead.

Screen-to-model uses the live CTM

Pointer coordinates are mapped through the SVG's getScreenCTM() rather than bounding-box arithmetic, so the mapping stays correct under viewBox scaling, page zoom and scroll with no manual maths.

Stiffness is optional, because the physics says so

For a statically determinate beam, reactions, shear and moment genuinely do not depend on EI. Only deflection and stress do. So section is optional on the model and deflection is absent from the solution without it. Requiring it would encode a dependency that does not exist.

Day 9: finding bugs by probing, not guessing

The last pass wasn't a checklist of plausible edge cases. It was a script that called the solver and the store directly with zero loads, negative (uplift) loads, loads landing exactly on a support, spans from 0.1 m to 1000 m, and rapid repeated insertions, then printed what actually came back. Four things were wrong:

  • Duplicate ids. The id generator was a module-level counter starting fresh at 0, so its first output, "P1", collided with every preset's hardcoded first load, which is also "P1". Clicking the point-load tool once on a freshly loaded preset produced a duplicate React key. Fix: derive the next id from whatever the model currently holds, which is unique by construction and survives undo, redo, and preset switches, not just the first click.
  • "L / Infinity". An unloaded beam, or a load landing exactly on a support, deflects by exactly zero. The span/deflection ratio divided by that with no guard and rendered the literal result.
  • Uplift loads drew as downward loads. A negative magnitude is physically real (uplift, suction) and reachable today since the inspector has no floor on it. The arrow still pointed into the beam regardless of sign; the only cue was a minus sign in small label text next to an arrow that looked identical to a positive load of the same size. Arrows now flip to point away from the beam when the magnitude is negative.
  • setLength could collapse two supports onto the same point. The floor was a flat 1 m. Shrinking an unevenly spaced pair (a 2 m/8 m split, say) past what their actual gap allows clamped both supports to the new end and handed the solver a mechanism, with no way back except undo. A naive fix (a floor based on support count) still fails here: proportional scaling of an uneven split can put the tightest pair under the minimum gap even when the count-based floor looks satisfied. The floor now derives from the tightest actual gap between supports, which is why it has its own regression test rather than a formula taken on faith.

One more thing surfaced during verification, worth including because telling it apart from a real bug was the engineering: after adding a load, the inspector's reaction numbers appeared to freeze at their old values while the diagram redrew correctly. document.hidden was true: the automation tab wasn't visible, and Chrome fully suspends requestAnimationFrame in hidden tabs rather than merely throttling it, so the eased-number tween (see the deflection engine's sibling feature, the animated inspector values) never got a frame to run. Refocusing the tab resolved it in one frame, confirming the tween's own overflow handling, clamping elapsed time to the end of the animation, was already correct. Not a bug; worth ruling out precisely rather than "fixing" a design that was fine.

Also fixed in the same pass: Vitest's test glob only matched *.test.ts, so a .tsx component test had been silently collecting zero tests since it was written. Vitest also had no path-alias configuration, so it couldn't resolve the same @/* imports the app uses. Both are the kind of bug a passing test suite hides: the file was green because nothing in it ever ran.

Testing

124 tests.

  • Statics against closed forms: PL/4, wL²/8, -PL, -wL²/2, plus global equilibrium checks on arbitrary multi-load configurations.
  • Deflection against closed forms: PL³/48EI, 5wL⁴/384EI, PL³/3EI, wL⁴/8EI, off-centre peak location (which is not under the load), slope boundary conditions, and superposition additivity.
  • Determinacy validation: mechanisms, indeterminate combinations, coincident and out-of-span supports.
  • Store invariants: drag clamping, gesture coalescing, redo invalidation, id uniqueness under repeated insertion and deletion, and an assertion that every undone state remains solvable.
  • Rendered output: the component tree is rendered through renderToStaticMarkup and the emitted SVG inspected directly, e.g. asserting an uplift load's arrow geometry actually points away from the beam, not just that its magnitude field holds a negative number.

The full pointer-to-diagram chain was additionally verified in-browser against hand calculations, not just against the unit suite.

The demo GIF is generated by scripts/make-demo.tsx, which renders the real <BeamScene> through renderToStaticMarkup and solves each frame with the same solver the UI uses, so the animation cannot drift from what the app actually shows.

Scope

Deliberately excluded: trusses and frames, statically indeterminate beams, persistence, collaboration, and any numerical FEA. The beam is the vehicle; the point is making the relationship between load, shear, moment and deflection immediate.

Known gap: verified in Chrome via the tooling above; not yet checked against Firefox or Safari.

About

Interactive beam mechanics workspace: drag a load, watch shear/moment/deflection re-solve live

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages