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.
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.gifFive 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:
- 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.
- 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.
- 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.
- 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.
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.
- 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.
| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
setLengthcould 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.
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
renderToStaticMarkupand 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.
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.
