Static workbench — assembling benches and frames
The static workbench is the non-kinematic half of the engine. Instead of gears that spin, you place structural parts — steel tube, sheet, wood panel, angle, bolts — and join them into one rigid body. Nothing moves each tick; the work is all geometry. Under the hood it is the *same* engine: static parts carry a rigid port (empty state, nothing propagates), and the layout solver + validators do the heavy lifting.
Everything is in millimeters, and every part's transform.position is the center of its bounding box. Meshes are authored centered, so a position reads as "where the middle of the part sits".
The pieces
Components (category static, DSL namespace statics):
steelTubeSquare— square tube ("metalon"). Legs and uprights.steelTubeRectangular— rectangular tube. Frame rails and stretchers.steelTubeRound— round tube. Handrails, light legs and frames.steelChannelU— open U (channel) profile. Stiff cross members.steelFlatBar— solid rectangular bar ("barra chata"). Braces, straps, feet.steelSheet— flat steel plate. Metal worktops, gussets.woodPanel— OSB / MDF / plywood / pine board. Worktops and shelves.steelAngle— equal-leg L-profile. Corner braces and stiffeners.bolt— metric hex bolt (M5–M12), simplified. Fastened joints and the BOM.profile— a custom 2D polygon extruded a thickness (sketch-lite). Gussets, mão-francesa braces, cut plates. See "Custom profiles" below.genericSolid— neutral bounding-box block. The fallback the scene3d importer emits for parts that have no dedicated kind yet; prefer a specific kind when one exists.customPart— a part you drew in the sketch/body editor (your parts library) placed into the bench.statics.customPart({ id, partBodyId, bbox, weightKg, material }): the viewer fetches the saved body and renders its real evaluated mesh, while the embedded snapshot (footprint, weight, material) keeps the bench self-contained. Author it fastest with "Usar na bancada" on a saved part, which copies a ready call to the clipboard. It exposes a rigid body port on every instance, so it joins welds/rests and IS counted by the connectivity / envelope / interference / BOM passes like any other static part. A revolved (lathe-turned) body additionally exposes a rotational axis port on its revolve axis (local Z through the centred origin): couple it to a shaft or gear withmech.coupleand the turned part spins 1:1 with it (ω in = ω out, no ratio) — a flange or hub on a driven shaft. A prismatic part has no axis port and stays inert.
Accessories (also static):
casterWheel— swivel caster (rodízio): plate + fork + nylon wheel. Weld/bolt its plate under a leg so the bench rolls;brake: trueadds a lever.levelingFoot— adjustable foot (pé nivelador): base pad + threaded stud. Under a leg on an uneven floor.hinge— flat butt hinge (dobradiça). At a lid/door joint line.handle— tubular pull handle (puxador). On a drawer front or door.drawerSlide— telescopic drawer slide (corrediça). One per side of a drawer opening.
Junctions (also under statics):
weld(anchor, driven, params)— rigid face-to-face contact.params:face(preferred),align,gap,offset,rotationDeg(legacyaxis/sidestill accepted).bolted(anchor, driven, params)— a fastened joint. Same geometry asweld, but marks a bolted connection for the fastener BOM.rest(anchor, driven, params)— gravity rest: the driven part sits on the anchor's top surface (bottom face ↔ top face).params:face(default 'top'),align,gap,offset,rotationDeg.inset(anchor, driven, params)— an interlocking / snug-fit joint (a shelf let into a frame). v1 geometry is a flush face contact likeweld.
Placement hints — let the solver do the arithmetic
The junctions are backed by the assembly solver (plan §16): instead of computing the mating position by hand (a leg's top is at y = L, the rail is h/2 thick, so the rail center is at y = L + h/2…), you declare where the parts meet and the solver derives the exact flush position from each part's dimensions. Hand arithmetic is exactly where a few-millimetre error creeps in — a top hovering over its rails, a leg passing through a shelf. Hints remove it.
- `face` — which face of the anchor, *in the anchor's own local frame*, the driven part attaches to:
'top' | 'bottom' | 'front' | 'back' | 'left' | 'right'. The solver rotates that face normal by the anchor's rotation, so a standing leg (rotated[-90, 0, 0]) has its physical top on its local `'front'` (+Z) face. The driven part is placed flush against the face — zero gap by default. - `align` — in-plane alignment:
'flush'(default, centered on the face);'center'(concentric — the driven's center coincides with the anchor's center, ignoringface, e.g. a bolt run through a tube); or a numeric[x, y, z]extra world offset. - `offset` —
[dx, dy, dz]slide of the driven part along the joint face (mm). - `rotationDeg` — orient the driven part; it does not inherit the anchor's rotation (structural members meet at 90°).
Opt-in and back-compatible. A junction with no face/align hint behaves exactly as before (legacy axis/side for weld, top-alignment for rest), so existing benches are untouched. New benches should prefer hints.
Before / after
Placing a rail on a standing leg by hand — you must know the leg length, the rail section, and add them up:
// BEFORE — arithmetic; a wrong term leaves the rail floating or sunk
const leg = statics.steelTubeSquare({ id: 'leg', width: 50, wallThickness: 2, length: 700,
transform: { position: [25, 350, 25], rotation: [-90, 0, 0] } });
const rail = statics.steelTubeRectangular({ id: 'rail', w: 40, h: 60, wallThickness: 1.5, length: 700,
transform: { position: [400, 730, 25], rotation: [0, 90, 0] } }); // 730 = 700 + 60/2 by hand
statics.weld(leg, rail);
The same joint with a hint — the rail has no transform; the solver places it flush on the leg's end face:
// AFTER — declare intent; the solver computes the flush position
const leg = statics.steelTubeSquare({ id: 'leg', width: 50, wallThickness: 2, length: 700,
transform: { position: [25, 350, 25], rotation: [-90, 0, 0] } });
const rail = statics.steelTubeRectangular({ id: 'rail', w: 40, h: 60, wallThickness: 1.5, length: 700 });
statics.weld(leg, rail, { face: 'front', rotationDeg: [0, 90, 0] }); // flush, gap 0
The Mesa 800x500 (montagem por junção) preset is authored entirely this way: only the root leg has an explicit transform; every other part is placed by hints. Load it to see a full four-leg table where every joint is flush by construction.
Orientation convention
Profiles run along their local Z; panels lie flat with thickness along local Y.
- Stand a leg upright:
rotation: [-90, 0, 0]sends its length to +Y. - Run a rail along X:
rotation: [0, 90, 0]sends its length to +X. - Run a rail along Z: leave
rotation: [0, 0, 0]. - A panel with
rotation: [0, 0, 0]spreads length → X, width → Z, thickness → Y.
How to build a bench
Work bottom-up. This is the order that keeps every joint face-to-face and every part supported:
1. Legs. Place the vertical square tubes. A leg of length L standing on the floor has its center at y = L / 2 (so its base sits at y = 0). Space the leg footprints around the bench outline. Anchor one leg with an explicit transform; the rest can follow from hints. 2. Top frame. Weld the rails onto the legs with `face` hints — statics.weld(leg, rail, { face: 'front', rotationDeg: [0, 90, 0] }) places the rail flush on the standing leg's end face, gap 0, no height arithmetic. Weld a rail flush against a leg's side face (face: 'right') to run it toward the next leg. Rails stack face-to-face; they never overlap. 3. Worktop. rest the panel on the top rails — statics.rest(rail, top) aligns the panel's bottom face with the rail's top face automatically. 4. Lower frame + shelf. Repeat lower down for a shelf: weld the shelf rails beside the legs (offset in Z so the rail face touches the leg face instead of crossing through it), weld the cross rails on top, then rest the shelf board.
Declare the outer bounds once, so the envelope validator can check the assembly:
statics.envelope({ x: 2600, y: 900, z: 600 });
Repetition — array and grid
Four identical legs, five identical shelves, a row of ten balusters: writing each one out by hand is long, error-prone, and — for the model — a runaway risk. The repetition helpers emit many parts from one call.
- `statics.array(descriptor, options)` — replicate one kind
counttimes along a constant step vector.descriptoris{ id, kind, params?, transform? }:kindis a component kind's DSL name (a string, e.g.'steelTubeSquare'),idthe base id (each replica becomes${id}_${i}),transformthe base placement of replica 0.options:{ count, dx, dy, dz, startIndex? }— replicakis offset by[k·dx, k·dy, k·dz](mm). Returns an array of handles, so you can weld/rest each one. - `statics.grid(descriptor, options)` — replicate on a 2D lattice.
options:{ rows, cols, dx, dy, dz, startIndex? }— replica(r, c)gets id${id}_${r}_${c}and offset[c·dx, r·dy, r·dz](columns step along X, rows step along Y and/or Z). Returns handles in row-major order.
The handles combine with the placement hints above — array the legs, then weld a rail flush onto legs[0]:
// Four standing legs, 760 mm apart along X, then a rail welded flush on the first:
const legs = statics.array(
{ id: 'leg', kind: 'steelTubeSquare',
params: { width: 40, wallThickness: 2, length: 700 },
transform: { position: [25, 350, 25], rotation: [-90, 0, 0] } },
{ count: 4, dx: 760 });
const rail = statics.steelTubeRectangular({ id: 'rail', w: 40, h: 40, wallThickness: 2, length: 2320 });
statics.weld(legs[0], rail, { face: 'front', rotationDeg: [0, 90, 0] });
// A 2 x 3 grid of MDF shelves:
statics.grid(
{ id: 'shelf', kind: 'woodPanel', params: { material: 'mdf', width: 400, length: 600, thickness: 15 } },
{ rows: 2, cols: 3, dx: 650, dy: 400 });
Both helpers cap the total at 512 parts and fail fast on anything larger — a guard against an accidental count: 100000.
Custom profiles — profile
For a shape no stock kind covers — a triangular gusset, a mão-francesa shelf bracket, a cut plate — trace a 2D polygon and extrude it:
// A right-triangle gusset, 120 x 120 mm, 5 mm steel, welded into a corner:
statics.profile({
id: 'gusset',
points: [[0, 0], [120, 0], [0, 120]], // [x, y] mm, counter-clockwise, at least 3
thickness: 5, // extrusion depth along local Z
transform: { position: [0, 0, 0], rotation: [0, 0, 0] },
});
Points are [x, y] pairs in millimeters, authored counter-clockwise; do not repeat the first point (the outline closes on its own). The polygon lies in the local X/Y plane and extrudes thickness along local Z; rotate the whole part into the joint plane with the transform. v1 has no holes, and a self-intersecting or degenerate outline is rejected with E_STATIC_PROFILE — so you get a concrete error, not a silently wrong shape. Weight is area × thickness × steel density.
The three validators (your guardrail)
A static bench is checked at compile time, after layout, with three deterministic passes. Each finding is a { code, params } object you can act on:
- Connectivity (
E_STATIC_DISCONNECTED) — every part with a rigid port must belong to one connected body. Declared weld/rest junctions are the intended graph; when they alone already tie the assembly into one body, it validates clean. When they do not, bare geometric contact (bounding boxes touching within 30 mm) is used as a fallback so a bench placed purely by explicit transforms still connects — the "a fabricated body is one connected mass" rule the scene3d importer relies on. If, even with contact, a part floats away from the rest, it is reported. Fix: add a junction or move it into contact. - Contact without a junction (
W_STATIC_CONTACT_NO_JOINT, a warning, not an error) — when an authored bench relies on that bare-contact fallback (parts touch but nothing declares *how* they join), the compiler nudges you to add an explicitweld/rest. It never blocks the compile. Imported benches are exempt: the scene3d→mbl converter stampsstatics.importedFrom('scene3d')into the source, and any bench carrying that machine-import provenance accepts contact-only connectivity silently. - Envelope (
E_STATIC_ENVELOPE) — with a declared envelope, every part's world bounding box must fit inside it (tolerance 25 mm). The error carries the part id and its real bounds. Fix: reposition the part or grow the envelope. - Interference (
E_STATIC_INTERFERENCE) — no two parts may interpenetrate beyond the junction tolerance (30 mm). Parts that merely touch (a rail on a leg, a panel on a rail) have zero penetration and pass. The error names both parts and the depth. Fix: stack the parts face-to-face instead of overlapping them, or offset one aside. - Profile (
E_STATIC_PROFILE) — everyprofilepart must trace a simple (non-self-intersecting), non-degenerate polygon of at least three points. The error carries the failing reason (self-intersection,too-few-points,degenerate-area). Fix: reorder the points into a simple outline.
Touching is fine; crossing through is not. Almost every "interference" is really "I put the rail's centerline where the leg's centerline is" — offset it by half of each part's width so the faces meet.
Worked example
The Bancada 260x60 preset is the reference assembly: 6 legs, a top frame of 2 longitudinal + 3 transversal rails carrying an OSB worktop, and a lower frame of 2 longitudinal + 5 transversal rails carrying an OSB shelf, all joined by weld and rest. It compiles clean and passes all three validators. The worktop is left unanchored and placed by the rest solver on the center transversal, so its top face lands flush at exactly 900 mm by construction. Load it from the presets list, or read its source to see the full coordinate table and the height stack (legs 809 + rail 40 + rail 40 + OSB 11 = 900 mm to the worktop top).
Weight and the BOM
Every steel and wood kind reports a real weight from its cross-section and material density (steel 7850 kg/m³; OSB 640, MDF 750, plywood 600, pine 500 kg/m³). The compiler sums them onto document.meta.static.totalWeightKg, and computeBom({ document }) groups identical parts (same kind + same params) into a formal bill of materials — quantity, unit weight, total weight — carried in meta.bom when you export a .mbl. See the File format page for import/export.
Per-part descriptor and the materials list
Beyond the weight, every physical kind also answers describeItem({ params }) with a structured descriptor — the buyable identity of the part, derived entirely from its params (never typed):
{ material: 'steel', // steel · wood-osb · wood-mdf · nylon · …
format: 'rectangular-tube', // square-tube · round-tube · channel-u · flat-bar · angle · sheet · panel · bolt · caster · …
dims: { w: 30, h: 50, wall: 2, length: 1200 } } // mm, only the applicable keys
The fields are canonical and in English so code and the training corpus stay simple; a pt-BR shop label ("Tubo metalon retangular 30x50x2mm — 1200mm") is generated from the descriptor for display. computeBom carries the descriptor on every line, and it rides along inside meta.bom when you export a .mbl.
The Materiais panel in the workspace shows the pt-BR labels and offers Exportar CSV and Exportar JSON — a client-side download of the whole list (quantity, label, material, format, dimensions, length and weight). The same structured BOM is available to agents through the bench_bom MCP tool, the bridge that turns a bench into a material list to quote and buy (the MyBenchLab → BuscaMetal funnel).