# World2 task API World2 runs ordinary JavaScript over one physical scene. Units are metres, seconds and kilograms; Y is up. It has five small pieces: - `src/engine.mjs`: bodies, contacts, attachments, fixed joints and recording. - `operations/`: ordinary imported functions such as drilling and fastening. - `parts/` and `skills/`: reusable object constructors and operating procedures. - `tasks//`: scene, program, work order and independent checker. - `server.mjs` and `web/`: local execution, agent access and replay. ## Task files `scene.mjs` exports `({variant}) => ({title,description,objects:[...]})`. `program.mjs` exports `async (world, {variant, goal}) => { ... }` (the second argument is optional). It receives a copy of the declared work order, so sizes can vary without a separate operating script. The independent checker receives `(world, goal, {variant})` and resolves its own criteria from the original goal. `goal.json` declares title, objective, `variants:[{id,label}]`, optional `maxSeconds`, `inspect:[objectIds]`, `fidelity:[{label,description}]` and task criteria. `check.mjs` independently measures the world and returns `{success,checks:[...]}`. The platform owner maintains the work order and checker; the task agent edits scene, program, notes and ordinary shared parts/skills. Changes to protected code are flagged. This is a trusted local workbench, not a hostile-code security sandbox. A new task does not need an engine, server or viewer branch. New physical abilities are ordinary operation modules, registered in `operations/index.mjs`. Keep their physical assumptions explicit. Reuse is a normal import: `import {bench} from '../../parts/workshop.mjs'` or `import {operator} from '../../skills/workshop.mjs'`. The existing stool task and three-stool factory share benches, drills, the carrier, material handling, hole drilling and the assembly procedure. Copy only task-specific choices into the task; extract a helper when another task actually needs it. Shared helpers take object IDs and observed positions. There is no separate catalogue service. A parts module may also export `catalogue = [{title, description, objects: () => [...]}]`. The **Parts catalogue** button in the viewer lays every entry out on one floor (`/api/catalogue`, reloaded on each open). Object IDs are prefixed per entry; references to an entry's own IDs are rewritten. Declared capabilities animate: conveyors run, tools plunge along their bit axis, rail carriages slide and serial arms sweep their joints. This is a viewer, not a physics run. Every entry is loaded in the engine by `test/catalogue.test.mjs`; serial arms must also make three 50 mm IK moves with link-collision checks. Catalogue records not shown by a parts entry sit in one static tray per category, drawn only from recorded dimensions (gears, whose face width is not recorded, are flat 1 mm tip-diameter discs; records with too few dimensions are listed, not drawn). Trays are drawn with one `THREE.InstancedMesh` per primitive (box, round and hexagonal cylinder, sphere) with per-instance colour, so thousands of records cost a few draw calls; picking uses the instance id, and a selected record is highlighted. The sidebar renders at most 150 rows per open category, with a count, a "show more" row and the filter. `node scripts/measure-catalogue.mjs [url] [runs]` measures load time, draw calls, frame time and sidebar filtering in a real browser and checks sidebar and 3D selection. Shared parts beyond the workshop basics: `backsaw` and `idealRack` (workshop.mjs), `conveyor`, `tabletopArm` (fixed-base FR3-style arm), `ur5e`, `kukaIiwa14`, `kinovaGen3` (7-axis, each with a Robotiq 2F-85 whose pinch point is the wrist frame, +Z approaching, and closing jaws) and `robotiq2f85Visuals`/`robotiq2f85Colliders`/`robotiq2f85Jaws`. `node scripts/import-menagerie.mjs ` imports a MuJoCo Menagerie model at a pinned commit into `parts/assets/-/` with its license, a SHA-256 receipt and `model.json` (meshes, and a `serialArm` chain for 5–7 hinge arms). It reproduces the hand-written UR5e chain. The 2F-85 jaws close on parts (see `jaws`); its four-bar finger path is drawn as a straight inward motion. The workbench and batch factory both use `skills/crosscut.mjs`. The four-leg assembly procedure exports `assembleFourLegFrame` with an `assembleStool` alias for existing tasks; IDs, leg offsets and preparation callbacks are parameters. The workbench independently checks different dimensions, retained offcuts and a released load. A reused procedure does not bypass its new work order. `skills/cut-list.mjs` exports `planOrder(goal, variant)`, an ordinary helper for the mixed furniture order. It expands quantities and dimensions, matches stock cross-sections and assigns part lengths to boards including kerf and retained remainders. It returns product descriptions and a part-to-source allocation; the program must still cut, carry, drill, assemble and deliver those parts. This small backtracking search suits the supplied few-board orders; it is not a general manufacturing planner or a large-scale nesting solver. The shared `crosscut` helper accepts an optional `cutLine`, a bench-local X position for one cut. It physically positions stock over that declared clear line, selects supports under the observed footprint, and aligns the held saw with the observed stock orientation. The native operations still check contact, clamping and clearance. Omitting it retains centred-stock placement. With `keepTool:true`, it parks the stopped saw in the operator's hand after cutting. The caller can reuse it across cuts, then call `op.putTool()` before changing tools; the ordinary two-slot carrying limit still applies. `skills/lateral-tools.mjs` provides ordinary `drillAt` and `fastenAt` procedures for an observed panel's local ±X faces. It rotates held tools/screws, approaches through the operator's front aisle and retracts before travel. The caller owns workholding, clear access and part placement; native reach and collision checks still apply. The shelf task uses the same helper for both sides and both sizes. ## Object data Each object has `id`, `label`, `shape` (box/cylinder/sphere/capsule), `size:[x,y,z]`, `position:[x,y,z]`, `body` (fixed/dynamic/kinematic), and `color`. Optional `mass`, `friction` and `material:{density,editable:true}` describe it. Editable box material has a measurable solid that operations can modify. Cylinders/capsules run along Y. Compounds use optional `visuals` and `colliders`: arrays of `{shape,size,position,color,rotation?}` in body-local coordinates; rotation is XYZ radians. Visible detail need not have separate rigid bodies. Optional `annotation` and `annotationOffset` provide viewer labels. A visual may be `{shape:'mesh',url:'/assets/…',color,position?,quaternion?,scale?}` (OBJ or STL under `parts/assets/`); meshes are visual only and need primitive `colliders`. A visual may set `metalness` and `roughness` (0–1) for its surface finish, and `motion:{scroll:[x,y,z],pitch}` for belt-surface detail that the viewer advances by the distance its conveyor has run (only between recorded convey events). Root objects can use `rotation:[x,y,z]` in radians or a `quaternion:[x,y,z,w]`. A holder declares `capacity` (default 1) and `reach` in metres. A tool declares `tool:{operations:['drill','fasten'],tip:[0,-0.25,0],diameter:0.003}`. Legacy `tool.kind:'drill'` also enables drilling. Upright screws declare `fastener:{length:0.065,diameter:0.006}`; tip is local Y `-length/2` and head is `+length/2`. Mass/density belongs to each object, not to a particular task. An anchored kinematic holder may also declare `workspace:{anchor:'fixed-base-id',radius:0.622}` and `payload_kg:3`. Every commanded holder or held-object move stays inside that fixed-base sphere; grasping checks the combined carried mass from measured editable-solid volume and density, declared mass for other objects, or collider mass as a fallback. This is an ideal reach envelope, not articulated robot kinematics or a torque limit. For a two-link arm preview, the holder may declare `articulation:{base:'fixed-base-id',shoulderOffset:[0,0.1,0],upperLength:0.32,forearmLength:0.32,elbowHint:[0,0,1]}`. The wrist must also be reachable by those two links; the viewer draws the shoulder, elbow and moving links from its recorded wrist pose. These links are visual and do not yet have joint collision, torque or six-axis control. A machine-mounted tool declares `quill:{axis:[0,-1,0],stroke:0.18}` (world axis, metres). It drills without a holder; every move must lie on that axis between its retracted scene position and the stroke, and it cannot be grasped. Drilling is otherwise unchanged. `parts/drill-press.mjs` exports `drillPress(id, {...})`: a frame, a slotted table (`_table`) and a quill spindle (`_quill`). A holder may declare parallel `jaws:{axis:[1,0,0],open:0.085,force_n:235,friction:0.8}` (holder-local closing axis). Its grasp casts from each pad inward through the grasp point: the part must be the first thing both pads meet, no wider than the open stroke, and held by 2·friction·force ≥ 2·weight. A free part off-centre is pushed to the centre. The grasp event reports `jaw_width_m` and `centred_by_m`; frames record the `jaw` opening, and visuals tagged `jaw:±1` close in the viewer. This is a static grip bound, not finger dynamics, deformation or slip. An optional `jaws.payload_kg` is the maker's rated payload: the grasp is refused when the gripped mass exceeds it, even where the friction bound would hold the part. Neither bound includes acceleration, emergency stops or off-centre moments. `parts/robotiq-2f85.mjs` exports the cited 2F-85 rating (5 kg, friction and form-fit) as `robotiq2f85RatedPayloadKg`, and `robotiq2f85Jaws`, fitted to the menagerie arms by default, carries it. An optional `jaws.min_m` is the narrowest part the fingers close on: a thinner part is refused. `parts/onrobot-2fg14.mjs` exports `onrobot2fg14({fingers})`, an OnRobot 2FG14 (datasheet: 14 kg force-fit payload, 280 N, 50 mm stroke, 1.5 kg) as `{pinchDistance,jaws,visuals,colliders,mass,size,label}`: `fingers:'inward'` (5–55 mm) or `'outward'` (55–105 mm) are the supplied positions, `[min,open]` an authored custom finger set (default 35–85 mm). `kukaIiwa14(id, base, {gripper})` (and `menagerieArm(..., {gripper})`) fit it in place of the 2F-85. A jawed holder's grasp is judged by its pads, not by `reach` from the part's origin: a panel can be gripped across its thickness at an edge. Moments from such an off-centre grip are not modelled. A holder may instead declare suction cups (`operations/vacuum.mjs`): `vacuum:{axis:[0,0,1],pads:[{position,seal_diameter_m,suction_force_n,lateral_force_n,stroke_m}], safety_factor,max_gap_m:0.001,circuit:'shared'|'valved'}`, with `axis` (holder-local) pointing from the cups to the work and each `position` the centre of a cup's lip. Its grasp is judged by the cups, not by `reach`: nine rays per cup (centre and rim at 95 % of the seal radius) must first meet the part (or its assembly) between `stroke_m` inside the lip plane and `max_gap_m` beyond it, within half the stroke of each other (a flat face). A hole, edge, step or other object under a lip leaks. On a `shared` circuit every cup must seal; on `valved` only the sealed cups count. The load R = m(a + g) acts at the centre of mass: each cup takes an equal share of pull and shear plus a linear (bolt-group) share of the peel moment, each cup's own seal disc adding (Ds/4)² to the second moment and |β|·Ds/2 to its rim pull; torsion becomes shear by distance from the pattern centroid. Every cup must keep S (pull/suction_force + shear/lateral_force) ≤ 1, checked at grasp and at every physics step with the acceleration of the load's centre of mass from its last three positions. An overloaded cup drops the load (released to gravity, `vacuum_slip` event) and fails with `VACUUM_LIMIT`; a cup that does not seal fails with `VACUUM_SEAL`. With `vacuum.air:{utility, consumer}` (an ejector consumer of a scene utility network) the cups do not engage while that supply is below the consumer's `min_bar`, and a held load is dropped (`vacuum_lost`) at the first step it is; both fail with `VACUUM_AIR` (holders without `air` are unaffected). The grasp event reports `vacuum:{cups_sealed,cups_leaking,mass_kg,com_local_m,lip_gap_m,utilisation_at_rest}` and the release `vacuum:{peak_utilisation,peak_acceleration_mps2}`. `cupLoads(spec,pads,comLocal,loadLocal)` is the exported rule. Fidelity: rigid part and gripper; constant rated (theoretical, dry, smooth) cup forces with no evacuation time, leakage or pump curve; no cup compression under load; angular acceleration is not included; the interaction and moment-share rules are authored engineering rules; no sheet flex or edge peel. `parts/vacuum-gripper.mjs` exports `vacuumCups(recordId, pattern)` (cited Schmalz SPB1 bellows cup records) and `offsetGripper({reach,drop,cups,safetyFactor,mass})`, a press-brake-tending gripper whose cups hang `drop` below an arm running `reach` out of the tool flange (TCP at the lip centre, +Z suction, +Y back toward the flange; four SPB1 30 cups in a 44 mm square, S = 2, 2.4 kg). `parts/combo-hand.mjs` exports `comboHand({jawRecord,length,jawOffset,cupOffset,cupBack,friction,mass,cups})`, a bending-robot hand with both grips on one wrist object: parallel jaws with rubber pads (stroke, per-finger force and form-fit payload from a gripper record, default Robotiq Hand-E) at the TCP (the pinch point, +Z the approach, jaws closing along X), the body axis and tool flange `jawOffset` to the cup side, and a block of `vacuumCups()` facing −X, `cupOffset` beside and `cupBack` behind the jaw tips (geometry, 0.8 pad friction and 3 kg authored). Grasp it with `{mode}`. `rigidGrip:true` makes a move of a held object move its holder and everything else held by that holder by the same displacement, preserving the grasp offset. Use it for robot grippers whose held tools must not drift away from the wrist. A fixed conveyor deck declares `conveyor:{direction:[1,0,0],speed_mps:0.4,max_load_kg:3}`. Keep its surface available for rigid-body contact; visible rollers/slats may be added as compound visuals. The direction is a world-space horizontal vector. Its body does not translate. `parts/conveyor.mjs` exports `conveyor(id,label,{from,to,z,top,width,color,driveSide})`: the same single deck box with a detailed visual-only frame, belt, legs, guides and gearmotor. ## Observing and operating - `get(id)`: current position, quaternion rotation, dimensions, bounds, body type, velocity, angular velocity, mass, attachment, clamp, holes, connected assembly IDs, source lineage and object metadata. Dimensions stay in the material's local frame; `bounds` reflects its current world orientation. - `observe()`: all current objects, time, supported operations and connections. - `note(text)`: phase description recorded with the run. - `await move(id,position,{speed:1,linear?})`: smooth collision-checked movement of a kinematic or held object. Moving a holder carries its attached objects. - `await grasp(holder,id,{offset?})`: adopt a nearby object into an ideal kinematic attachment. Its entire connected assembly occupies one carrying slot. Every member must be released/unclamped and detached from fixed fixtures. Optional offset moves it relative to the holder. A holder that declares both `jaws` and `vacuum` (a combined hand, `parts/combo-hand.mjs`) needs `{mode:'jaws'}` or `{mode:'vacuum'}`; the chosen grip's rules apply. - `await release(id)`: release the entire connected assembly to gravity and contacts. - `await rotate(id,[x,y,z],{speed:1})`: turn a held object/assembly or kinematic actuator to XYZ Euler radians. Collision samples bound surface travel to 1mm; this is ideal orientation control, not a joint-torque or arm-motion solver. - `await clamp(id,supportIdOrArray)`: released, settled material must touch its declared supports and have its centre over their combined footprint. Creates an ideal workholding constraint; it does not model clamp forces. - `await unclamp(id)`: return material to dynamic physics. - `actuate(tool,{rpm})`: spindle command. - `await drill(tool,stock,{depth,feed:0.015})`: held or fixed operating tool, clamped editable box stock. Feed follows the tool's rotated local negative-Y axis; both tool and stock may be turned. Align the observed tip 0–25mm outside the entry face. Depth is measured from that face along the tool axis. Actual swept bit geometry is subtracted in the stock's local material frame, with mesh/collider updates. Retract separately. No torque, heat, chips or tool wear. - `await cut(tool,stock,{axis:0,at,parts:[negativeId,positiveId],feed:0.04})`: near-upright box stock must be clamped. `at` is the stock-local X cut plane. The held/running saw declares `tool:{operations:['cut'],tip,kerf,span}` and has a matching straight box blade whose bottom edge is `tip`. Align the blade's orientation and entry point with the observed stock frame, including small settled tilts. The shared crosscut skill does this with a real held rotation. Its blade must span the stock and its handle must remain clear. A geometric stroke along the stock's local negative Y removes the kerf and creates two genuine solids with source lineage. The parent retires; outputs inherit ideal workholding until unclamped. IDs cannot be reused. Retract separately along that same observed frame. This is a simplified straight-edge saw, not a circular-blade sweep, cutting-force or wood-fracture model. - `await fasten(tool,first,second,{fastener:screwId})`: parts must physically touch along the screw axis and have aligned first-part clearance and second-part pilot holes. The running driver and screw must be held by the same operator. Stage screw tip 0–5mm outside the first entry face and driver bit within 12mm of its head. The driver and screw must point in the same direction (their rotated local negative-Y axes). A through clearance bore may be entered from either mouth, so a part bored from one face can be screwed from the other. The operation inserts both together, releases/anchors the screw, and creates a fixed Rapier joint. Thread engagement is ideal; there is no pull-out, bending or strength model. - `await pickScrew(tool,screwId)`: a held driver declaring `screwFeed:{max_diameter,max_length,pickup}` seats a free, resting screw on its bit. The screw must fit those limits, its axis must match the tool axis and its head must be within 3 mm of the bit tip; it is snapped coaxially onto the tip and then carried by the tool's holder as part of the same grasp (the holder still counts one object), so `fasten` accepts it. Retention is ideal: no vacuum or magnet force, drop or slip. `parts/feed-bit-driver.mjs` exports `feedBitDriver`. - `await wait(seconds)`: advance fixed-step physics, at most 30s per call. - `await convey(beltId,objectId,{to:[x,y,z]})`: drive one released dynamic object along a fixed conveyor's declared direction to a position still on that deck. The object must rest on the deck and fit its load limit. Its centre must stay over the deck (at the start and the target, 20 mm margin); a part may overhang the deck's sides or end, e.g. so a gripper can take an edge. The part advances through recorded Rapier steps, retaining its identity, gravity and contacts. It stops or reports a jam if blocked. Belt speed comes from the fixed scene; programs cannot request a faster rate. The drive is an ideal velocity source, without belt friction, motor power or roller dynamics. The velocity is set before each step and deck friction slows the part within it, so a part covers less than the declared speed (a sheet plate made 79 % of it over 2.8 m); the step budget is twice the nominal time plus 1 s. - `await laserCut(bedId,sheetId,{outline,holes,part,remainder})`: profile-cut flat sheet on a fixed bed declaring `laserCutter:{head,thickness_m:[min,max],materials, kerf_m,min_feature_ratio,feed_mps,rapid_mps}` (`operations/laser-cut.mjs`). The sheet is an editable box lying flat (local Y is its thickness), clamped to that bed only (or, when the spec names a `pallet_changer`, to that changer's pallet standing at the working position inside: a sheet on the pallet outside is refused with "exchange the pallets first"), inside its footprint, with a thickness in range and `material.family` (`steel`, `stainless`, `aluminium`, ...) in `materials`. `outline` is a closed simple polygon `[[x,z],...]` in sheet-local metres; `holes` are `{shape:'circle',center,diameter}`, `{shape:'slot',from,to,width}` or `{shape:'polygon',points}` inside it, non-overlapping, leaving one piece. Holes and slots narrower than `min_feature_ratio × thickness` are refused (a polygon hole must contain a disc of that size), as are webs thinner than that between two holes or between a hole and the outline (an authored rule, not a cited one; narrow outer tabs are not checked). Holes narrower than the kerf are refused. The outline plus kerf must lie inside the sheet and wholly in solid material, so parts can be nested in a remainder. A beam disc of `kerf_m` follows the outline offset outward and each hole inward by half the kerf (kerf compensation), holes first, while the machine's kinematic head traces that path at its fixed `feed_mps`. The swept band and hole slugs are subtracted from the real solid: `part` is a new solid centred on the outline's bounding box, `remainder` the rest of the sheet, both with source lineage and the inherited clamp. Circular holes are recorded as through `holes`, so `fasten` can use them as clearance holes (tested). Returns and emits measured areas and kerf/slug volumes. Circles are 96-gons; inside corners keep the half-kerf beam radius. No beam power, pierce, heat, dross, taper or head-height model; slugs are discarded, not bodies. The head collides with obstacles; material is removed only when the whole path completes, so a blocked cut leaves the sheet whole. `parts/laser-cutter.mjs` exports `laserCutter(id,{...})`, an authored 3.0 × 1.5 m, 0.5–10 mm, 0.2 mm-kerf machine (`_bed`, `_head`, `_frame`). `plateProfile(object)` in `src/measurements.mjs` slices a plate at mid-thickness and returns its material area and outer/hole contours for independent checkers. A released flat plate (material straight through its local-Y thickness: laser-cut parts, or plates with only vertical through holes) collides as full-thickness cuboid rows over its profile, at most 0.5 mm apart (exact along local X; at most 0.25 mm staircase along local Z at holes and slanted edges), not as a convex decomposition. Kinematic motion checks still confirm hits on the exact solid. The rows take the solid's mass through a density, so their inertia is the compound's own (a mass set directly left a narrow drilled strip resting tilted). A released piece that still reaches every face of its box and has lost at most 1% of its volume (drilled holes, pilots) collides as its outer cuboid, so a drilled stick rests flat instead of sinking on a convex decomposition. A carried edited solid keeps that approximate collider; a motion's candidate hit is then confirmed with the obstacle's primitive against the solid itself, so a fixture arm can still enter a notch or bore. - `await bend(bedId,partId,{line,angle,die?,base?,output})`: press-brake air bending (`operations/press-brake.mjs`) on a fixed bed declaring `pressBrake:{ram,axis_origin, length_m,max_force_n,dies:[{v_m,shoulder_radius_m}],punch_radius_m,die_angle_deg, placement_tolerance_m,ram_speed_mps,angle_deviation_deg?}` (`angle_deviation_deg`: formed minus commanded angle, one figure for every stroke or a list by stroke number; a machine condition a scene may set, reported as `commanded_angle_deg` on the bend). The die's V centreline runs along world X through `axis_origin` (its y is the die top). The part is an editable sheet solid (local Y = thickness: a raw sheet or a laser-cut part) clamped to that bed only, with `material.family` steel, stainless or aluminium and `material.tensile_strength_pa`; `bendable:false` is refused. `line:{point:[x,z], direction:[dx,dz]}` is in the part's flat-pattern metres (an unbent part's local X/Z). The region holding it must lie flat on the die top with the line within `placement_tolerance_m` of the V centreline and 0.5° of its axis; the bend is made where the die actually is and `placement_error_m` is returned. The flange rises toward world up; the side away from the part's `base` (flat [x,z], fixed at the first bend, default the middle of the larger side) moves and the rest keeps its world place. `0 < angle ≤ 180° − die_angle_deg`. The die defaults to the nearest to the optimum V (6T up to 3 mm, 12T over 10 mm; Wikipedia, linear between authored). The inside radius is the larger of the punch radius and 15.6 % (steel), 21 % (stainless) or 14 % (aluminium) of V (The Fabricator rule of thumb, read from search excerpts only: 15.6 %, 20–22 %, and 13–15 % or 14–16 %; the 21 % and 14 % picks authored) and must be at least 0.8 T (Wikipedia's bottoming minimum, applied as an authored floor) or the sheet's `material.min_bend_radius_ratio` × T. K is `material.k_factor` or Wikipedia's generic air-bending table by R/T: its Steel column (0.40 / 0.45 / 0.50) for steel and stainless (stainless as a steel is our reading), its Aluminum soft or medium column by `material.temper` for aluminium (default medium, authored; other aluminium tempers are refused without a `k_factor`). Bend allowance BA = A(π/180)(R + K·T) sets the developed length. Refused: a flange (flat, from the line to the nearest edge, hole or other bend, sampled along the chord) shorter than V/2 + R + T (Approved Sheet Metal's 4T + R, half a 6T die + R + a T margin, with the actual V/2 in place of 3T: authored) or than V/2 + shoulder radius; any hole within max(V/2, BA/2) of the line (SendCutSend: features within half the die width distort); material beyond the die length; a force above `max_force_n` by the Bystronic air-bend rule 575·t²/V tons per foot (inches) × UTS/60 ksi; bend zones that overlap, or moving sides that cross, within the part (add a corner relief); a line whose sides are joined outside the bend. The zone is cut into segments of at most 7.5° and mapped onto the arc (chords), the flange turned rigidly; bends compose from the stored flat pattern, so a part edited after bending (drilled, cut) is refused. The kinematic ram strokes down to the sheet and back with collision checks against everything but the part. `output` is a new part with lineage, the inherited clamp, its hole records moved with the material, and `sheetMetal:{thickness_m,base,flat_to_local,bends}` (`local = flat + flat_to_local` in the base region). Returns and emits (`press_brake_bend_completed`) the angle, radius, K, BA, bend deduction, bend length, flat and outside flange lengths, force, placement error and volumes. Not modelled: springback (the formed angle is the commanded one), thinning (zone volume changes by the K < 0.5 offset), strain hardening, cracking, flange or ram collision during the stroke, deflection. Springback, design note (not implemented; `press-brake.mjs` is being reworked for progressive bending): an air bend opens up by a springback factor K_s = α_final / α_bent (Gardiner-type relation, K_s a function of R/T and yield/E; to be cited when built). It would plug in as (1) optional sheet data `material.yield_strength_pa` and `material.elastic_modulus_pa` (or a measured `material.springback`), (2) a pure function `springbackAngle(material, R, T, commanded)` beside the bend-allowance helpers, applied after the zone mapping so the output part's segments are laid out at the sprung angle (bend allowance and developed length are computed at the loaded radius, which is what the punch forms), (3) the returned record gaining `commanded_angle_deg`, `formed_angle_deg` and `springback_deg`, and (4) an opt-in `overbend: true` (or a program choosing a larger commanded angle) to reach a target, still bounded by 180° − die_angle_deg. Checkers that measure angles would then measure the formed angle. `parts/press-brake.mjs` exports `pressBrake(id,{...})`, an authored 1.25 m, 350 kN machine with 6–25 mm dies and 88° tooling (`_bed`, `_ram`, `_frame`). `laserCut` refuses a bent part: cut the flat blank first. `parts/drawn-machines.mjs` stands catalogue drawings over these simulated machines: `drawnFiberLaser` (enclosed shuttle-table laser; its outside table is the laser bed, its drawn head rides the simulated head), `drawnCompactBrake` and `drawnCncBrake` (the drawn beam or ram strokes with the simulated ram), and `standing` (any drawing on the floor at y = height / 2 with authored main-mass colliders). A released bent part collides as one convex-hull collider per flat region and bend segment (`bentCollisionPieces` in `operations/press-brake.mjs`; hulls fill holes and notches within each piece, corners closer than 20 µm merge). Rapier's convex decomposition left a released 1.9 mm angle jittering about 1 mm per frame and walking 0.25 m in five minutes, and one compound of the hulls fell through a flat plate's row compound; kinematic checks still use the exact solid. Held (progressive) bends: instead of being clamped, the part may be held in a serial arm's closed jaws (`grasp(arm, part, {mode:'jaws'})`, no suction). The held side must be the `base` side (the base defaults to the grip point). After the ram reaches the sheet it presses at `press_speed_mps` (bed spec, default a tenth of `ram_speed_mps`): the bend angle follows the punch depth h by the air-bending geometry h = (V/2)·tan(A/2) − (R_A + T)(sec(A/2) − 1) with R_A kept at the final bend allowance, both wings rise by A/2 about the line, the solid is rebuilt every 3° and the arm follows the grip point with pose IK from its current joints every physics step (`trackArmStep` in `operations/serial-arm.mjs`: joint speed per step, workspace, link capsules, the hand and other carried bodies checked against the scene, the descending ram included). An IK failure, a joint over its speed or an IK error above `follow_tolerance_m` (default 0.5 mm) fails with `FOLLOW_LOST`; a collision fails as usual. The output stays held in the jaws at its bent pose (kinematic) and the report adds `held_by`, `follow_steps`, `max_follow_error_m`, `press_depth_m`, `press_speed_mps` and `wing_rise_deg`. With `backgauge:` in the spec, the die-side edge must lie within the placement tolerance of that object's front face (its bounds' max z), reported as `backgauge_gap_m`. Held mode steps the clock itself (NOT_CONCURRENT). Not checked during a held stroke: the part's own wings against the machine (the punch and die touch it at the line by design); no springback, friction or forces on the jaws. - `await installEquipment({objects, caption})` / `await retireEquipment({ids, caption})` (`operations/timeline.mjs`): equipment that appears or leaves during a run, for recordings that replay a factory's history. Installed objects are fixed and have no colliders (drawings at their new place, not working stations); they show in the replay from that moment. Each call records `equipment_installed` / `equipment_retired` with its caption and sets the phase text. Takes no simulated time. - `await exchangePallets(changerId)`: a two-level pallet changer (`operations/pallet-changer.mjs`) on a fixed object declaring `palletChanger:{pallets:[a,b],levels:[ya,yb],inside:[x,z],outside:[x,z],work_level, speed_mps,lift_mps}`. Each pallet is a kinematic object whose origin is its top face centre; pallet i runs between the outside station and the inside station on rail level `levels[i]` and is lifted to `work_level` inside. The exchange drops the inside pallet to its level, runs both at once past each other, and lifts the other to the working level, carrying everything clamped to or resting on each pallet (clamps kept). Every step of every pallet and load is collision-checked (the other pallet included); the moves are smooth ideal drives recorded in the replay; `pallet_exchange_started`/`_completed` are emitted. Returns `{inside, outside, loads}`. `palletAtWork(world, changer, pallet)` is the 1 mm test machines use. No chains, locating pins, load inertia or sliding. Sheet-metal secondary operations work on a part's recorded through holes, chosen by index in `get(id).holes` (laser-cut circles and drilled holes are recorded). Tapping, countersinking and hardware insertion need the part clamped to that station's table only, the hole axis vertical (within 1°), the hole's bore present in the solid (measured, not only recorded) and, where declared, the thickness range and `material.family`. Each station's kinematic head travels from its rest position to the hole (rapid, then the last 5 mm at its feed) and back, with collision checks; a blocked head leaves the part unchanged. A bent part (one with `sheetMetal`) is worked through its sheet thickness at the hole record, not through its bent envelope, so a bracket's base-leg holes can be tapped or take hardware after bending. `parts/metalwork-stations.mjs` exports authored generic stations: `tappingMachine`, `countersinkMachine`, `hardwarePress`, `weldingTable` (each `_frame`, `_table`, `_head`) and `powderBooth` (`_frame`, `_table`). A coated, unmasked hole fits smaller by twice its film for tapping and press-in. - `await tap(tableId,partId,{hole,thread:'M5x0.8'})` (`operations/tap.mjs`): table declares `tapper:{head,threads,thickness_m,materials,feed_mps,rapid_mps}`. ISO metric coarse M2–M12 only. The hole must be within ±0.05 mm (authored) of the cited tap drill (Engineers Edge chart: D − P, except 6.8 mm for M8 and 10.2 mm for M12) and the part at least one pitch thick (authored). The hole records `thread:{designation,major_diameter_m,pitch_m,tap_drill_m,length_m,hand,face}`. No thread geometry is cut: the bore keeps its tap-drill size. No torque or chips. - `await countersink(tableId,partId,{hole,diameter,angle:90})` (`operations/countersink.mjs`): table declares `countersinker:{head,angles_deg, max_diameter_m,min_edge_per_diameter,thickness_m,materials,feed_mps,rapid_mps}`. A cone of the included angle (90° metric ISO 10642; 82° inch) with outer diameter `diameter` at the upper face is subtracted from the solid. Refused when the cone would reach the far face ((D − d)/2/tan(angle/2) ≥ thickness), D is not larger than the hole, or material is missing within `min_edge_per_diameter` × D of the hole centre (measured; 0.5 only keeps the cone in material). The hole records `countersink:{angle_deg,diameter_m,depth_m,face,removed_m3}`; `face` says which side of the hole record ('entry'/'exit') was up. No cutting force or burr. - `await insertHardware(pressTableId,partId,hardwareId,{hole})` (`operations/press-in.mjs`): table declares `hardwarePress:{head,thickness_m, feed_mps,rapid_mps}`; the hardware declares `pressIn:{kind,part,thread,hole_m, hole_tolerance_m,min_sheet_m,min_edge_m,shank_m}` (`parts/press-in-hardware.mjs` `pressInNut(id,record,position)` builds it from a catalogue record). The hole must be `hole_m`…`hole_m + hole_tolerance_m`, the sheet at least `min_sheet_m`, and material present within `min_edge_m` of the hole centre (measured: edges, slots and other holes all count). Stage the hardware shank-down (local −Y) over the hole, coaxial within 0.5 mm and 0–2 mm above the face, resting or held (a held part is released at the end of the ram stroke). It is seated with its shank in the hole and joined by an ideal fixed joint: `world.connections` gets `{kind:'press-fit',hardware,part,thread,hole}` and the hole records `hardware`. No press force, sheet hardness or push-out strength. - `await weld(tableId,firstId,secondId,{seam:[[x,y,z],[x,y,z]],process?})` (`operations/weld.mjs`): table declares `welder:{head,processes:[{name,families}], fit_up_gap_m,seam_tolerance_m,standoff_m,feed_mps,rapid_mps}`. Both parts are clamped (each to the table, or to the other part or a fixture plate whose clamp chain reaches the table), share a `material.family` that a process lists, lie within the fit-up gap of each other (measured between the solids), and every 2 mm sample of the straight seam is within `seam_tolerance_m` of both solids and not buried in material. The torch head traverses the seam at its feed; then a fixed joint records `{kind:'weld',process,bead_length_m,seam,bead}`. A process declaring `filler:{wire_diameter_m,wire_feed_mps,efficiency,electrode_strength_pa,density_kg_m3}` deposits a bead (`operations/weld-bead.mjs`): cross-section A = wire area × wire feed × efficiency ÷ travel speed, laid as a cylinder along the seam less both parts, its radius set so its open cross-section is A (measured on the solid; iterated). An inside-corner seam (open fraction ≤ 0.4) is a fillet: legs r, effective throat r·cos(π·f) (0.707·r in a square corner), strength = throat × length × 0.30 F_EXX (AISC 360-16 Table J2.5, shear on the effective throat, ASD Ω 2.00; J2.2a). `bead` carries `joint`, `deposit_area_m2`, `open_fraction`, `leg_m`, `throat_m`, `volume_m3`, `filler_mass_kg`, `strength_n`; surface and butt seams get geometry but `strength_n: null` (penetration is not modelled); a process without filler gives `bead: null`. `weldStrength(world, a, b)` sums the welds joining two parts (null if any is unrated) for load checks. The bead is not added to the parts and the joint stays ideal and rigid in the physics. `weldingTable`'s MIG carries `MIG_FILLER` (ER70S-6, F_EXX 483 MPa; 0.9 mm wire at 5 m/min and 0.95 efficiency authored). No heat or distortion. Gap, tolerance and the open-space rule are authored. Arm-traced seams: the table may declare `welder.arm` (a serial wrist holding nothing, whose frame is the head's working point, e.g. a laser head's beam focus with +Z the beam) instead of `head`. The program first turns the head to the seam; the beam must meet the seam at 45° or more, and points 2 × tolerance, 5 mm and `standoff_m` back along the beam from the seam (about every 10 mm) must lie outside both solids (line of sight). The arm then moves (joint space) to `standoff_m` back along the beam from the seam start, feeds straight in, traces the seam in a straight Cartesian line at `feed_mps` with pose IK every step, backs out and returns; its links and head are checked against everything, the parts included. Events, result and connection carry `traced_by`. `parts/laser-weld-head.mjs` exports `withLaserWeldHead(arm,{flange,length,standoff,mass})` (an authored 2.2 kg head, 15 mm nozzle standoff, fitted to a UR5e-style wrist). - `await powderCoat(boothId,partId,{color,film,mask:[holeIndices]})` (`operations/powder-coat.mjs`): booth floor declares `powderCoater:{colors, film_m,min_hang_hole_m,materials,interior_m}`. The part rests on or is clamped to the floor, or hangs from a holder declaring `hanger:true` (a hanging cart's hooks; any other holder is refused), wholly inside the interior, and has an unmasked open through hole of at least `min_hang_hole_m` (measured) to hang by. A hung part is held ideally: the hook's place in the hole, swing and hook load are not simulated. `get(id).finish` becomes `{process:'powder-coat',color,film_m,area_m2,masked_holes,booth,hung_from,geometry_changed:false}` and unmasked holes record `coating:{film_m}`. Dimensions do not change: the film is recorded, not added to the solid, so measured sizes and mass are bare metal. No spray, cure or film variation. The hang hole is measured in any orientation (a part hanging edge-down), and a joined assembly (a weldment) may hang by a hole in another member. An optional booth `swatches:{colour:'#rrggbb'}` adds `display_color` to the finish and event; the viewer tints the part from the moment it was coated (replay only). - `await fdmPrint(printerId,{spool,part,shapes,at,layer_height_m,recipe?,label?})`: fused-filament printing (`operations/fdm-print.mjs`) on a fixed printer declaring `fdm:{build_origin,build_volume_m:[x,height,z],nozzle_diameter_m,layer_height_m:[min,max], filament_diameter_m,materials:{FAMILY:{max_flow_m3ps}},overhang_deg,spool_mount,spool_radius_m}` (origin and mount printer-local; the origin is the centre of the bed top). `spool` names an unheld object within `spool_radius_m` of the mount declaring `filament:{family,diameter_m,density_kg_m3,remaining_m}`; its family must be in `materials` and its diameter within 0.05 mm (authored band) of the printer's. `shapes` are primitives in part-local metres, Y up (the stacking direction): `{shape:'box'|'cylinder',size,position,rotation?,segments?,subtract?}`; cylinders run along local Y, `segments` sets the polygon count (6 gives a hex) and `subtract` cuts from everything before it. The geometry is sliced at mid-layer; each slice is extruded one layer high and the union of those prisms is the new solid `part` (height rounded to whole layers, layer staircase on sloped and curved faces). Refused: a layer height outside the printer's range; a part outside the build volume at bed-local `at:[x,z]` (its footprint centre) or a build space overlapping another object; any island of any layer that cannot contain a disc of the nozzle diameter; a layer reaching more than `layer_height / tan(overhang_deg)` beyond the layer below (flat ceilings, round horizontal holes and floating islands; a 45° teardrop prints); a part in several pieces; and too little filament. The spool's `remaining_m` drops by volume ÷ filament cross-section; the part's density is the filament's (solid infill). The physics clock advances volume ÷ `max_flow_m3ps` (the maximum volumetric flow as the average rate: a lower bound, without travel, acceleration, heat-up or layer changes) while the part appears on the bed in ten layer groups. The part is born clamped to the printer (ideal bed adhesion): `unclamp` removes it, without peel force. Lineage records `operation:'fdm-print'`, printer, spool, recipe, layer height and layer count; `print_started`/`print_completed` events report volume, filament, mass and duration. No toolpath or head motion, support material, bridging, warping, shrinkage, thermal or strength model; holes narrower than the nozzle are not refused. `parts/fdm-printer.mjs` exports `fdmPrintingMachine(id, position, spec?)` (the catalogue printer with its fixed bed and `printingSpec`: 300 × 45 × 300 mm build volume, 0.4 mm nozzle, 0.05–0.32 mm layers, 45°, PLA 12 and PETG 8 mm³/s; cited and authored values are listed there), `filamentSpool(id, printerPosition, filament)` and cited `filaments` (PLA, PETG). `parts/printable.mjs` exports recipes (`fingertip`, `bitHolder`, `lBracket`) returning `{recipe, shapes, envelope_m}` in print orientation. - `await mill(bedId,stockId,{paths,depth,stepdown,split?})`: CNC routing / milling with a flat end mill (`operations/mill.mjs`). The fixed bed declares `cncRouter:{spindle,travel_m:[x,z],z_travel_m,materials,feed_mps,plunge_mps,rapid_mps, spoilboard_overcut_m,safe_clearance_m?}`. Its kinematic spindle carries one end mill: `tool:{operations:['mill'],diameter,flute_length,reach,max_stepdown,tip}`, where `reach` is the stick-out below the collet. Start it with `actuate(spindle,{rpm})`. The stock must be an editable box lying flat (local Y up; it may be turned about Y), clamped to that bed only, inside the bed footprint, with a listed `material.family`. `paths` are tool-centre polylines in stock-local `[x,z]` metres: `[[x,z],...]` for one path, or `[{points,closed?},...]`. The tool's disc swept along them is subtracted from the top face down to `depth`, in equal passes of at most `stepdown`. That makes slots one diameter wide and pockets with flat floors and inside corners of the tool radius. Refused: a tool centre outside `travel_m` (about the bed centre); `depth` beyond the flute length or beyond thickness + `spoilboard_overcut_m`; `stepdown` over `max_stepdown`; a plunge longer than `z_travel_m` from the retracted tip; a retracted tip less than 5 mm above the stock. The spindle rapids at its retracted height and then `safe_clearance_m` (default 5 mm) above the stock, plunges at `plunge_mps` and feeds at `feed_mps`. At every step, its motor, collet and tool colliders are checked against everything but the stock and the bed. A blocked spindle (for example, a clamp in the path) leaves the stock uncut, because material is removed only when every pass completes. A cut that separates the stock must give `split:{part,at:[x,z],remainder}`: the piece under `at` becomes `part`, centred on its bounding box, the largest other piece becomes `remainder`, and any other pieces are discarded as scrap. Both outputs keep lineage and the clamp. It returns and emits `mill_completed` with passes, path length, removed and output volumes. Not modelled: cutting force, chip load, spindle power, deflection, heat or tool wear; helix or up/down-cut finish; ball-nose or V tools; tool change; ramping; tabs or part movement (cut-free pieces are held ideally). The spoilboard is not machined. `parts/cnc-router.mjs` exports `cncRouter(id,{...})`, an authored 1.3 × 0.9 m machine with 1.2 × 0.8 m travel and 150 mm Z travel (`_frame`, `_bed`, `_spindle`). Its default end mill has the cited geometry of LMT Onsrud 52-285 (6.35 mm, 25.4 mm length of cut). Its 1 × D depth per pass is an authored reading of Onsrud's hardwood data, which states chip loads for a 1 × D depth of cut. `skills/router-paths.mjs` gives `rectPocket`, `outsideProfile` and `roundedRect` tool-centre paths. `plateProfile(object,{y})` sections a solid at local height `y`, so a checker can measure an outline below a pocket and the pocket's mouth. `tasks/router-tray` mills a pocketed tray. Objects retain their identity through drilling and fastening; splitting creates new identities with explicit parent/source records and measured removed volume. Tool/fixture staging may be kinematic, but completed products and proof loads must be released if the work order calls for free-standing support. For example, `rotate(tool,[0,0,Math.PI/2])` points its working axis along +X; `-Math.PI/2` points along -X. Rotate only while held or kinematic, with room for the full tool sweep. Approach and retract along the same axis. Completion events record `axis_world` and drilling also records `entry_world`; the checker still measures the resulting solids and assembled screw poses independently. Independent checkers can reuse `src/measurements.mjs` for solid-bore inspection and released-load evidence. `releasedSupportSeconds` checks recent recorded poses, support geometry and holding state, capped at five seconds by default. Its result is the observed recent interval, not all elapsed time since release. Tasks separately check current physics contact, load mass and joint arrangement. ## Process plants: heat, flow and reactor kinetics A fixed object may declare `thermalNetwork` (`operations/thermal-loop.mjs`, fully documented at the top of that file): fluid volumes (`nodes`: fluid from `operations/fluids.mjs`, capacity or pipe geometry, fill or mass, temperature, wall mass, insulation UA, trace-heater power, structure limit, freeze valve), pumped `loops`, counterflow ε-NTU `exchangers`, conduction `links`, `sinks` (exchangers to a boundary stream, or passive conduction + radiation), gravity `drains` through freeze valves, gas-pressure `fills`, metered `transfers`, point-kinetics `cores` (`operations/point-kinetics.mjs`), scheduled `faults` and replay `display` bindings. - `plantSet(plant, commands)`: instantaneous commands: `pumps:{loop: speed 0–1 | 'trip' | 'reset'}`, `heaters:{node|'*': setpoint °C | null}`, `valves:{node:{cooling}}`, `fills:{id: bool}`, `transfers:{id: m³/s}`, `sinks:{id: demand W | false}`, `power:bool`. Returns the readings. - `plantRead(plant)`: measured readings (ideal instruments): node temperature, mass, level and liquid fraction, loop flows and pump speeds, core fission, decay and thermal power, reactivity (static, circulation loss and net), sink duties, valves, drains, fills, fired faults and limit counts. - `await plantRun(plant, seconds, {sample:10})`: advances the network and the world clock together, one recorded frame, history sample and display update per `sample` seconds. Rigid bodies do not move meanwhile, so it runs alone (`NOT_CONCURRENT`). Time other operations spend is caught up on the next plant call. The operation keeps its state on the object (`world.objects.get(id).plant`: the network with its per-step limit records, peaks and energy ledger, and the sampled history) for independent checkers; `energyBalance(net)` and `inventory(net)` are exported. Mass and energy close to rounding (tested). Fidelity: lumped well-mixed volumes, constant cp, sharp melting point, no pressure, expansion, natural circulation or boiling heat transfer; point kinetics without spatial effects, xenon or burnup; decay heat from the Way–Wigner fit. Not a design or safety tool. `tasks/onion-core-plant` uses it. `world.elapse(seconds)` (engine) advances the clock without stepping rigid-body physics for such slow processes: it runs alone, refuses while a dynamic body still moves (`BODIES_MOVING`), respects `maxSeconds` and records one frame. Replay display state: an operation may emit `display` events `{objects:{id:{tint?, scale?, label?}}, readout?}` carrying only what changed. The viewer shows each object's latest state at the playhead: `tint` recolours all its meshes (with a faint glow), `scale` scales it in its own frame about its origin (a level column drawn upward from its origin), `label` replaces its annotation text; `readout` is shown under the phase line (`web/display-state.mjs`). ## Factory utilities: compressed air and electrical distribution A fixed object may declare `utilityNetwork` (`operations/utility-network.mjs`, fully documented at the top of that file; pressures in gauge bar, air flows as free air m³/s): `air` with `compressors` (rated flow and input power, load/unload or VSD control, unloaded power fraction, run-on and start times, the electrical `load` it draws from), `receivers` (volume, initial pressure, relief, isolation valve `connected`), a tree of `segments` (Darcy–Weisbach pipes, dryer/filter drops, regulators) and `consumers` (`cylinder` bore/rod/stroke/count, `gripper` volume per double stroke, `ejector`, `blowoff`, `flow`, `leak`, each with an optional `min_bar` and a fixed `schedule`); `power` with `voltage_V`, a tree of `elements` (breakers with rating, B/C/D curve and thermal τ; cables by length and cross-section or busway resistance) and `loads` (phases, power factor, `modes:{name: W}`, motor `start` inrush); scheduled `faults` (`breaker_trip`, `compressor_trip`, `leak`) and replay `display` bindings. A consumer or load may `follows` the world, applied whenever the network catches up: an ejector on while a holder's cups hold (`{holder, grip:'vacuum'}`), a gripper stroking as a holder's jaws close and open (`{holder, grip:'jaws'}`), a nozzle on or a load in a mode between matching recorded events (`{events:[{on, off}]}`; a load's `follows:[{on, off, mode, part}]` also books that energy to the start event's part field). A leak with `on:false` opens only by a fault; a cylinder with `extended:true` starts closed. - `utilitySet(id, commands)`: instantaneous, and `delay_s` schedules later actions: `consumers:{id: 'on'|'off'|'extend'|'retract' | {on_s, delay_s} | {cycles, period_s, delay_s}}`, `receivers:{id:{connected}}`, `compressors:{id: 'on'|'off'|'reset'|{cut_in_bar, cut_out_bar, setpoint_bar}}`, `loads:{id: mode | {mode, part}}` (a part tag books the energy that follows), `breakers:{id: 'reset'|'open'}`. Returns the readings. - `utilityRead(id)`: store, receiver and drop pressures; per consumer pressure, active, ok, flow, strokes, cycles and on-time; compressor state, power and kWh; free air compressed, used, leaked and vented, leak energy; per element current, loading, thermal state and trip; per load mode, power, voltage and kWh; kWh per part. - `await utilityRun(id, seconds, {sample:1})`: advances the network and the world clock together (`world.elapse`, runs alone). Unlike `plantRun`, `utilitySet`/`utilityRead` do not step the clock: the network catches up with the world clock on every call, so it runs alongside rigid-body operations (time an arm spends moving is integrated on the next call, with every scheduled stroke and nozzle on-time resolved exactly). For other operations, `airOk(world, id, consumer)` returns `{ok, bar, min_bar, active}` and `loadPowered(world, id, load)` returns `{powered, voltage_fraction}`, so a gripper or ejector operation can refuse or drop its part on low pressure and a machine can refuse to start without power. `airPressure(world, id, {consumer|drop|receiver})` reads a gauge pressure, `utilityFault`/`utilityRepair(world, id, fault)` apply and repair a fault now, and `utilityDisplay(world, id)` emits the display bindings (the signals layer calls it every 0.5 s). The state is on the object (`world.objects.get(id).utilities.net`: low-pressure, starved, trip, undervoltage and capacity records tracked every internal step, ledgers, sampled `history`); `airBalance(net)` and `powerBalance(net)` are exported. Air mass and electrical energy close to rounding (tested). Fidelity: isothermal ideal gas, one store pressure for connected receivers and pipes, quasi-steady flow and voltage, strokes that do not slow at low pressure, single-time-constant breakers, conservative single-phase loading; no moisture, compressor thermodynamics, harmonics or short-circuit study. `tasks/utility-demo` uses it. ## Controls: signals, controllers and wiring Signals are physics (`operations/signals.mjs`): an object owns named signals in `device.signals` and may declare `device.sensors` computed after every physics step from the simulation — `photo_eye` (ray casts), `proximity` (sphere overlap), `field` (a light curtain's protective plane), `position` (a body at a pose), `vacuum` (a holder's actual seal), `zone_clear` (tool bounds outside a box), `door`, `button` (operator only), `shutter` (an actuator's feedback from its command and enables) and, from a utility network, `air_ok` (a consumer's supply at its minimum), `air_pressure` (an analog gauge-bar value) and `powered` (a load's breakers closed) — each with a `response_s`. A reader sees a signal it does not own only over an intact netlist cable (`operations/wiring.mjs`: objects with `cable: {ends, signals, conductors, kind, cross_section_mm2, max_length_m, min_bend_radius_m, route, drag_chain?}`); otherwise it reads 0. A cabinet's `safetyRelay` (dual-channel inputs, discrepancy latch, monitored reset) drops its outputs; the world then decelerates every commanded kinematic motion to a standstill over `stop_delay_s` (`world.holdMotion(reason, {ramp_s})`, `motion_hold` / `motion_stopped` events) and ramps it back after reset. An enabled host that declares `device.power:{utility, load}` and loses that power opens the relay like an input (monitored reset once it is back). Scene `faults` (`intrusion` by an `actor`, `estop`, `stuck`, `wire_break`, and the utility faults `compressor_trip`, `breaker_trip`, `leak` with a `utility` id, with event or time triggers and operator responses — `repair_after_s`, `reset_after_s` — counted from the scene's `alarm` lamp) are applied by the platform. Cables and `lamp` objects are tinted in replay through `display` events; the PLC's readout line is shown. - `api.startControllers({plc, jobs, enables})` (untimed): a PLC `{id, host, scan_s, inputs:{TAG: signal}, outputs:{TAG: signal}, program:{init?, scan(io, mem)}, complete, shutdown, readout?}` scanned on the simulated clock (io.in, io.out, io.now, io.fault, io.state), and job controllers `{id, host, prefix, ops, inputs?, jobs:{n: async (api, {arg, arg2, io})}}` answering `prefix.busy/done/fault/ready` to `prefix.job/arg/arg2/start/reset`. Returns `{finished}` → `{state: 'complete' | 'safe_stop', faults}`. Afterwards the program's own non-read calls throw `CONTROLLED_CELL`; each controller may call only its `ops` (recorded as `api_call`). - `electricalReport(scene, limits)`: routed lengths (+10 % slack), drag-chain radius and length, 24 V drop per device (copper 0.0172 Ω·mm²/m), supply loading. - Engine hooks used: `world.onStep(fn)`, `holdMotion`, `abortHeld(error)`, `keepClock(onError)` (the clock steps while controllers wait), `apiGate`/`apiCaller`, and actions flagged `untimed`. `tasks/rmfg-plc-cell` uses them. ## Perception: cameras, 3D cameras, lidars Sensors are carried by scene objects (`operations/perception.mjs`): an object's `sensors: [spec, ...]`, each with an `id`, a `kind` (`rgb`, `depth`, `lidar2d`, `lidar3d`), the maker's figures and a `mount: {position, quaternion, link?}` in the carrier's frame (or, with `link`, in that serial-arm link's frame). The sensor frame is optical: +Z looks out, +X image right, +Y image down (a lidar scans its X–Z plane; a 3D lidar spins about −Y). `parts/perception-sensors.mjs` `sensorHead(product, id, {position | opticsAt, quaternion, link, overrides})` gives a real product's drawing, housing collider and spec (Zivid 2+ M60, Photoneo MotionCam-3D M+, RealSense D435 and D405, Basler ace 2 + 8 mm lens, SICK Visionary-T Mini, TiM781, microScan3, Ouster OS0-128; records and sources in `catalog/records/perception-sensors.json`). Every measurement comes from the simulation at the sensor's pose, never from program intent. Two modes, chosen per call (`{mode}`) or per sensor (`spec.mode`): - `synthetic` (fast): depth and points are Rapier ray casts against colliders, exact edited meshes and arm link capsules; objects declaring a `class` are detected from the ray-cast instance buffer by a labelled synthetic detector (authored recall, miss model for size, occlusion and glare, box noise, false positives). - `real`: the sensor's image is rendered headless by a software rasterizer (`operations/sensor-render.mjs`) from the same drawings the viewer shows (`web/shapes.mjs`, mesh files, posed arm links): colour with shot and read noise, metric depth with the sensor's noise, drop-outs (random, grazing, projector shadow, specular), and a ground-truth instance image. Colour, 16-bit depth (0.1 mm), a depth preview and the instance image are stored beside the run (`/api/artifacts//`, listed in `run.artifacts` and in each capture's `images`). Detection and registration run in the Python sidecar (`perception-py/sidecar.py`: OpenCV, Open3D; `npm run perception:setup`); without it real-mode calls fail with `PERCEPTION_UNAVAILABLE`. Surfaces may declare `optical: {specular: 0..1}` (a polished part saturates where the sensor sees it near its mirror direction). - `api.capture(sensorId, {mode, models, region, detect})` → a camera's `{capture, sensor, kind, mode, time, pose, intrinsics, depth, points, detections: [{class, box [u0, v0, u1, v1] (full-resolution px), confidence}], blurred, motion_m}` (real mode also `image: {width, height, rgb, intrinsics}`); a lidar's `{ranges, beams: [{az, el}], points}`. Takes the sensor's capture time, waits for its frame rate, and blurs (drops most points) if the sensor moved more than 1 mm meanwhile. Real-mode detection segments the depth above the support plane (inside `region`, a world box) and classes each region by the `models`' footprints; `detect: false` skips it. - `api.detect(sensorId, options)` → the detections of a new capture. - `api.estimatePose(capture, model, {region, maxRms_m, minFitness, minCoverage})` → `{plane, clusters, poses: [{position, quaternion, rms_m, inlier_fraction, coverage, points, detection}]}` for a model `{class, parts: [{shape, size, position?, rotation?|quaternion?}]}` (the part's catalogue geometry, e.g. `parts/machined-bracket.mjs` `bracketModel`). Synthetic: RANSAC plane, footprint clusters, multi-start ICP then exact point-to-surface ICP (`operations/point-cloud.mjs`); real: Open3D plane and DBSCAN, OpenCV point-pair features and resting-face starts, Open3D ICP against the CAD mesh. Takes no simulated time. - `api.findFiducials(capture, {marker_m, dictionary})` → ArUco / AprilTag markers in a real-mode colour image with world poses (`parts/fiducials.mjs` draws DICT_4X4_50 markers). - `api.findContours(capture, {canny, min_area_px})` → edge contours (flat sheet parts). - `api.sensors()`, `api.sensorPose(id)`: specifications and current poses (reads). - `api.graspAt(holder, {mode})` → closes jaws on whatever lies between the pads and returns its id (`GRASP_EMPTY` if nothing): picking a part found with a sensor. - `api.armCanReach(arm, position, {rotation})` → whether pose IK succeeds (no motion). - `api.dockTool(arm)` / `api.mountTool(arm, toolId)` (operations/tool-changer.mjs): tool changes on an arm with a `toolChanger` (parts/tool-changer.mjs `fitToolChanger`, `toolRack`). Dock: the mounted tool's TCP within 3 mm / 3° of its rack slot's parked pose (`api.get(rack).toolRack.slots`); mount: the bare changer at `bareMountPose(changer, toolId, slotPose)`. Each takes half the changer's tool-change time; the arm must hold nothing. Swaps the wrist's TCP, colliders, `jaws`, `vacuum` and `payload_kg` (arm rating − changer − tool); tool objects are drawn `toolMountedOn` the arm (engine `followMounts`) or parked. Events `tool_docked`, `tool_changed`. - `api.armClearance(arm, position, {quaternion|rotation, posture, moved: {id: [dx,dy,dz]}, ignore, margin})` → `{reachable, angles, clear, distance_m, hits}`: planning query (no motion, no events): pose IK as `armPose`, then the arm's link capsules and everything the wrist carries against every other collider, optionally with obstacles displaced (`moved`, e.g. a ram at the bottom of its stroke). - `api.fitOutline(capture, templates, {region, edges: 'depth' | 'rgb', plane?, step_m})` → flat parts of known 2D outline on a support plane in one real-mode capture: each template `{label?, outer: [[x, z], ...], holes?, thickness_m, init: {x, z, yaw}, search: {position_m, yaw_deg, raised_position_m?, raised_yaw_deg?}, accept_coverage?, nest_prior?, surface?: 'support'}` is chamfer- matched (OpenCV distance transform) to depth-step edges (a kerf, a part lying on another, a sheet's edge) or colour edges back-projected onto the plane (the height then from the depth inside the outline). A part standing above the plane gets its top plane fitted (tilt). `nest_prior`: a part still in its opening keeps its init pose as a prior (and that pose when too little of its outline is seen). Returns `{plane, templates: [{label, found, state: 'flush' | 'raised', evidence, position (mid-thickness centre), quaternion, yaw, tilt_deg, coverage, rms_m, samples}]}`; records `outline_fit` with each fit's error against the true pose of the object its label names. - `api.measureBends(capture, {region, thickness_m, label?, max_planes})` → bend angles and outside legs of a sheet-metal part from a real-mode depth capture: RANSAC planes (Open3D) with least-squares refits away from the bend zone, pixel rays intersected with the planes, mould lines from the outer faces, the sheet's end seen past a free edge taken off. Returns `{planes, flanges: [{angle_deg, leg_m}], base_leg_m?, web_m?}`; records `bend_measurement`. - `api.capture(sensorId, {mode: 'real', points: false})` returns the depth image without building the world point list (fitOutline and measureBends read the depth). A scene object with `concealed: true` is hidden from the program: `observe()` omits it and any call naming it first (`get`, `move`, `rotate`, ...) throws `CONCEALED` (only `release`, `clamp`, `unclamp` may name it); `concealed: 'until-held'` lifts once a holder has grasped it (event `concealed_revealed`), and a laser-cut part or skeleton inherits its sheet's `concealed` (its new pose must be measured again). Checks read the world directly: each `sensor_capture` event records its detections with ground truth, misses, a decimated point cloud and stored image names; each `pose_estimate` records poses with their ground-truth error. `tasks/vision-pick` uses all of this; `npm run perception:dataset` writes domain-randomised COCO training data from the same renderer. ### Scene disturbances `scene.disturbances: [{object | target: {event, nth, field}, after: {event, match?}, shift: [dx, dy, dz], rotate_deg: [rx, ry, rz], settle_s?, reason}]` (`operations/disturbances.mjs`): hidden changes the platform makes during a run: at the first `after` event once the object exists (or the object the `field` of the nth `event` named), it is taken out of its clamp, moved once, left to settle under gravity and contact (kept awake `settle_s`, default 2 s), then held where it rests by the support it was clamped to. Events `disturbance`, `disturbance_settled` (a program cannot read events). `tasks/rmfg-vision-cell` knocks cut parts out of the skeleton with it. ## Mobile robots, carriers and drop points `operations/amr.mjs` drives kinematic vehicles (AMRs, autonomous pallet trucks) on a painted lane network. Scene data: one fixed object with `laneNetwork: {nodes: {id: [x, z]}, edges: [[a, b, {speed_mps?}]], docks: {node: {heading, headings?, exit?: 'reverse'|'forward'|'through', approach?: 'reverse'}}}` (two-way lanes; a route passes a dock only at its ends; a drive-through dock on a lay-by, `exit: 'through'` with `headings` either way along it, is entered from either side and left straight ahead, never reversing or turning in it); kinematic objects with `vehicle: {record, footprint: [w, l], centre, pivot, speed_mps, reverse_mps, dock_mps, accel_mps2, decel_mps2, stop_mps2, turn_radps, turn_accel_radps2, payload_kg, lift: {stroke_m, speed_mps, clearance_m, centre?, tolerance_m}, battery: {soc, runtime_loaded_h, runtime_empty_h, standby_h, charge_ratio, reserve}}`; dynamic carriers with a `tag` and `carrier: {bin: {min, max}}` (the local box parts ride in); fixed drop points `dropPoint: {slots: [{id, node, role}]}` whose `device.sensors` are a photo eye (`.present`) and a `tag_reader` (`.tag`, operations/signals.mjs: the nearest tagged body within range); chargers `charger: {node}`; and `scene.equipmentFaults` (`vehicle_fault` with `recover_after_s`/`manual_mps`, `machine_down`, triggered at a time or by the nth event of a kind). `parts/spine.mjs` builds all of these (`underRideAmr`, `palletTruckAmr` from catalogue records, `carrierCart`, `dropPoint`, `chargingStation`, `supermarketFrame`, `laneNetwork`). - `await vehicleRoute(id, node)`: plans the shortest-time route (turns counted; around vehicles out of service) and drives it: straight runs on a trapezoidal profile (collinear stretches merged, lower limits braked for), turns on the spot about the pivot, the first edge out of a dock reversed unless it is left forward. Every step of the vehicle and what it lifts is collision-checked by the engine. The route is granted by an area-and-time reservation table: each stretch (about 1 m) is booked for the time the profile sweeps it, the end outline from arrival on; the vehicle books the earliest start (within 120 s) at which everything keeps 0.1 m and 1 s from other vehicles' bookings and from people's areas, then waits and drives. Fails `AMR_FAULT` if the vehicle is out of service (a protective stop at `stop_mps2` first), `ROUTE_BLOCKED` if a vehicle that failed later stands in the way (it stops at the node before; plan again), `BATTERY_LOW` below the reserve except to a charger. - `await vehicleLift(id, carrier)` / `await vehicleDrop(id)`: raise the carrier the vehicle stands under (or the pallet on its forks; within `tolerance_m` and 3°) by the stroke less the clearance, after checking carrier plus contents against the payload; set it down (feet 0.5 mm above the floor, released). Events `carrier_lifted` / `carrier_dropped` list the contents measured in the bin and, at a drop point, the stand's `present` and `tag_read`. - `await moveCarrier(id, carrier, {from, to})`: route, lift, route, drop (slot ids or dock nodes): the standard `MOVE_CARRIER` action's operation. - `await vehicleCharge(id, {seconds ≤ 30, to})` at a charger; `vehicleStopCharging(id)`; `fleetStatus()` (untimed: nodes, poses, state of charge, loads, faults, machines down); `readDropPoint(id)` (untimed: per slot present and tag); `routeEstimate(id, from, to)` (untimed); `await vehicleRecover(id, to, {park})`: after `recover_after_s`, a technician drives a failed vehicle by hand at `manual_mps` to `to`, sets down what it carries, then to `park`. - `await reserveArea(holder, shapes, {tag, for_s})` / `releaseArea(holder, tag)`: people (any non-vehicle holder) hold areas (`{circle}`, `{rect}`, `{poly}`, `{segment, r}`, each with an optional `for_s`) so no vehicle is booked through them; they wait for vehicles already booked there. People do not block each other; a parked vehicle does not block them. Parts in a carrier ride as ordinary bodies on straight runs; during a turn they are turned with it as an ideal load (Rapier friction on a turning kinematic body let resting parts drift about 1.6 cm per quarter turn at any rate). Battery state of charge is a linear estimate. Fidelity limits: exact lane following (no slip, localisation error or path smoothing), on-the-spot turns, an ideal vertical lift, a reservation table rather than a vendor fleet manager. `test/amr.test.mjs`; `tasks/rmfg-virtual-spine` uses it with `src/spine-runtime.mjs`. **Safety laser scanners** (opt-in: `vehicle.safety_lidar`, built by `parts/spine.mjs` `safetyLidar(scannerRecord, robotRecord, motion)` or `underRideAmr(..., {lidar: scannerRecord})`; `operations/safety-lidar.mjs`). Scanners at the robot's drawn corners (`scanners: [{mount, yaw_deg}]`, the record's scan angle and angular resolution) scan the horizontal plane at their height while the vehicle drives a straight run (`scan_rate_hz`; ray casts against the scene's colliders, the vehicle's own body occluding beams beyond its corner notch, what it carries excluded; no ray is cast when no collider meets the field). Fields are rectangles ahead in the direction of travel (behind when reversing), as wide as the body (empty) or the loaded outline (carrying) plus `width_margin_m`, from speed-banded tables `bands.lane` / `bands.dock` (`{max_mps, protective_m, warning_m}`; the `dock` set on runs into or out of a dock). A return in the warning field caps the speed at `slow_mps` (braking at `decel_mps2`), in the protective field brakes to a stop at `stop_mps2`; each request acts `response_time_s` after the scan that saw it and lapses the same time after the first clear scan. While a request acts, the rest of the route is held open-ended; once clear, its held areas end the accumulated delay (plus 1 s) later than booked. Events: `safety_field {vehicle, level: clear|slow|stop, previous, field_set, band_mps, protective_m, warning_m, speed_mps, obstacle, distance_m, points}` on each change; `vehicle_route_started.planned_s` and `vehicle_route_completed.safety_lidar {scans, cast_scans, rays, slow_s, stop_s}`. Limits: no scanning during on-the-spot turns; preset fields chosen by speed band, no field muting beyond the small dock set; the record's reflectance-dependent range is not modelled. `test/amr-lidar.test.mjs`. ## Concurrent operations Every `await`ed operation above advances one shared simulated clock. An operation started while others are still running runs at the same time as them, through the same physics steps: ```js await Promise.all([armPose('arm_a', p, opts), convey('belt_1', 'leg', { to })]); await parallel(() => stationA(), () => stationB()); // like Promise.all over async functions ``` - **Clock.** Each running operation computes and checks its next step, sets its bodies' next kinematic poses and parks; the world steps once every running operation has parked, then all continue. Time advances only after every branch that can proceed without time passing (grasps, releases, clamps, the next operation's start) has done so, so operations begun "at once" start at the same instant and events carry the true simulated time. Replay frames are recorded once per step for all bodies. Ordering is deterministic: the same program gives the same events, frames and results. - **Collision checks between movers.** Each operation checks its next step against every other body where it is at the start of that step (another operation's body that already moved this step is checked at its latest commanded pose where it is an arm's links). Serial-arm links are also checked against other arms' links and against bodies another arm carries (capsules between joint origins, 3 mm penetration). A pair of links already overlapping before a step (arms authored intersecting at their home poses) may move apart; contact onset fails with `ARM_COLLISION`. Consequence: a collision between two movers is caught at the step in which it begins, with up to one step (1/120 s) of their relative travel as penetration, and is reported by whichever operation checks first in that step. - **Conflicts.** An operation driving a body claims it (an arm with what it carries, a belt and the part on it, a moved or rotated group, a rail carriage). A second operation driving a claimed body, or a program `grasp`, `release`, `clamp` or `unclamp` of one, fails with `CONCURRENT_CONFLICT` naming the operation that holds it. A grasp of an object another holder already holds fails with `ALREADY_HELD`. - **Failures.** A failed operation rejects only its own promise; the others keep running, so a program may catch the failure and carry on. `parallel` (like `Promise.all`) rejects on the first failure. When the program returns or fails, any operation still running is stopped; a program that returns without awaiting an operation fails with `UNAWAITED_OPERATION`. - **What runs concurrently.** Serial-arm motion (`armPose`, `move`, linear moves and every arm-held tool feed: `drill`, `cut`, `fasten`), `convey`, `slide`, `rotate`, `coMove`, `carryTogether`, `wait` and an arm-carried `weld`. Operations that still advance the clock on their own (press-brake `bend`, `fdmPrint` and the machine-head operations `laserCut`, `mill`, `tap`, `countersink`, `insertHardware` and a station `weld`) run alone: started while another operation is running, they fail with `NOT_CONCURRENT`. - **Fidelity limits.** Concurrency changes timing only; each operation's own physics, checks and limits are those described above. Two dynamic parts driven at once interact through Rapier contact as usual; a kinematic mover is not pushed back by anything. There is no controller bandwidth, shared power or network latency model, and no automatic path replanning: a program coordinates its branches itself (e.g. with promises it resolves when a piece is handed over). `tasks/chair-line/pipelined.mjs` runs the chair line's three arms and two belts this way. ## Direct development `node scripts/run.mjs TASK --variant VARIANT` Use `--program baseline.mjs` to run another task-local entry against the same goal, scene and independent checker. The HTTP equivalent is `POST /api/run {task,variant,program:"baseline.mjs"}`. An entry can export an `approach` label for the viewer. Each recording retains all task-local `.mjs` sources as well as the selected entry, so imported helper changes are recorded. A task may declare an `executionProfile` with fixed `actor_mps`, `handling_mps`, `rotation_radps`, `cut_feed_mps` and `drill_feed_mps`. The runner applies those rates to its API before passing it to the task program; requested speeds cannot improve the score. Normal tasks without a profile retain their existing behavior. Optional `sceneHashes[variant]` pins the complete initial scene before physics runs, including inventory, tools, capacities, colliders and layout. The same path is available through `POST /api/run {task,variant}` and the browser. Each run imports current task code and shared modules without restarting the host. Scene previews also use a fresh lightweight worker so shared imports do not go stale. Runs retain task source, the shared parts/skills source, source hashes, events, geometry revisions, checks and replay states. Failures remain visible. Small summary files keep history browsing independent of full replay size. The viewer uses task metadata, including labels and checks. New recordings use lossless `recording_format:'state-pool-v2'`. States live in `state_pool` (unchanged states share one entry). A frame lists only the objects whose pool index changed since the previous frame, as `frame.objects[id]`. A value of `null` means the object retired. `phase` is present only when it changed. Frames 0, K, 2K… (K = `keyframe_every`, 600) are full frames. The older `state-pool-v1` listed every object in every frame. The faults run of rmfg-virtual-spine was 410 M characters in v1 and is 183 M in v2. `src/run-store.mjs` `readRun()` expands v2 frames to the v1 shape by default (`{expand:false}` keeps them compact). `compactFrames`/`expandFrames` convert between the two. The viewer reads v2, v1 and earlier recordings. Internal checkers still receive full physics frame objects, not encoded indices. Physics precision and sample rate are unchanged. Physical movement and replay make no model calls. The optional coding agent edits the task and reads the actual run result; it does not control each timestep. ## Parts and cost of a work order `GET /api/cost?task=ID&run=RUN_ID` (add `&format=csv` for a spreadsheet; without `run`, the latest passing run) returns the work order's equipment list and the cost of that recording, computed by `src/work-order-cost.mjs`; the viewer shows it in the Parts & cost drawer. Scene objects link to catalogue records by, in order, an object's own `record` field (an id or ids), the task's `tasks/ID/cost.json` `records` map (`{objectId: recordId | [ids] | {record, qty}}`; qty 0 marks a simulated sub-object of a unit counted elsewhere; null sets an object aside), a `catalog/machines/*.json` bill of materials derived from the task, or a label that contains a record's name. Unlinked objects are listed, not priced. Unit prices are the records' `price` (published with URL, or estimate with range) or cited `cost`. The cost file also names `labour` (`people: [ids]` or `operators` with a basis), `energy` (a check and regex giving the run's own kWh), `output` (which checker list or verdict counts accepted units), `ignore` rules and an optional `sale_price`. Rates (labour, electricity, ownership, materials, consumables per recorded event quantity, exchange rates) are cited in `catalog/cost-rates.json`. Cost per unit divides by units the checker accepted; failed units never count. ## Standard actions `operations/actions.mjs` is the standard action vocabulary (full list: `docs/actions.md`): CUT_METAL_2D, CUT_PANEL, CROSSCUT, BEND, DEBURR, DRILL, TAP, COUNTERSINK, PRESS_IN, WELD, COAT, CURE, PRINT_3D, INSPECT, PICK, PLACE, LOAD_SHEET, UNLOAD_PARTS, EXCHANGE_PALLETS, MOVE_CARRIER, CONVEY and PACK. An action is plain data `{type, id, part? | parts? | carrier?, machine?, params, requires?, at_s?}`; lengths are metres, angles degrees, times seconds. `ACTIONS[type]` states parameters with units, the capability (a catalogue process id where World2 enforces one), preconditions, effects (geometry, location, carrier) and the World2 operations that perform it. - `validate(action)` returns problems (`[]` when valid); `assertValid` throws `code: 'ACTION_INVALID'` with `problems`. - `capable(record, action)`, `whyNot(record, action)` and `capability(record, action)` → `{ok, simulated, reasons}` accept a catalogue record or a scene object (its `laserCutter`, `pressBrake`, `welder` ... spec) and check stated limits (thickness range, bend length, force, payload, film, thread, ...). Specified catalogue machines are recognised by their capability fields with `simulated: false`. - `duration(action, record, calibration)` → `{seconds, basis, terms, source}`; basis `physics-calibrated` (entry `{type, machine?, fixed_s, per:{param: s per unit}, run}` — the run id is required), `published` (every number from the record) or `estimate` (an uncited `ESTIMATES` placeholder was used). - `await execute(api, action, bindings)` / `await executeAll(api, actions, bindings)` run actions in physics. `bindings[type](action, {api, run})` is the cell's own way (motion planning, grips); `run()` calls the operation on a part already in place (e.g. `laserCut` with `frame:'table'` outlines turned by the sheet's observed yaw). Types with no World2 operation (DEBURR, CURE, PACK, MOVE_CARRIER until an AMR operation exists) throw `ACTION_NOT_SIMULATED` / `ACTION_NEEDS_BINDING` without a binding. `executeAll` returns per-action start and end times; an error carries `.action` and `.done`. `tasks/rmfg-sheet-cell/plan.mjs` `actionList(spec, {blanks, layout, sheets, handler})` compiles that work order into actions; its program runs them with bindings for LOAD_SHEET, EXCHANGE_PALLETS, CUT_METAL_2D, BEND, PICK, PLACE and WELD. ## Cell synthesis `skills/cell-synthesis.mjs` designs a new cell from a capability need (plain functions; no physics run): ```js import { synthesizeCell } from '../../skills/cell-synthesis.mjs'; const out = synthesizeCell({ need: { process: 'press-brake-bending' | 'mig-welding', order /* sheet order */ | parts /* weldments */, throughput_per_week, batch_size?, shifts?, max_operator_h_per_week?, envelope: { max_footprint_m, near?, clearance_m? } }, factory: { building: 'rmfg-factory', budget, existing? } }); // → { cell: { id, process, machines: [record ids], robots, fixtures, handling, layout: { footprint, frame, units, ... }, // program: [standard actions], plc, netlist, harness, plan? }, // cost: { capital, vitamins, made, items, annual }, cycle_time_s_by_part, takt_s_by_part | steady_cycle_s_by_part, // throughput, verified: { runId: null, checks }, alternatives, refused?: { reason, nearest } } ``` - **Bending** (`order` in the sheet compiler's normalised format, `skills/sheet-plan/part.mjs`): every catalogue press brake (records with a bend length and a rated force) is planned with `planAtBrake(order, brake)` (`skills/sheet-plan/plan.mjs`: tooling, tonnage, length, a bend order clearing the tools, operator-supported); the cheapest priced one that plans every part is chosen. Handling: operator (23 kg lift, reach model) vs the robotic sheet cell, by annual cost (`catalog/cost-rates.json` ownership and labour) within the budget and one shift. - **Welding** (`parts`: weldments in `skills/fixture-design.mjs` format): a dedicated and a modular fixture per weldment, `chooseFixture` for the batches per year; configurations manual / robot welding + operator / robot welding + robot handling are compared by annual cost under the shift and operator-hour limits. The handling robot is the W2 gantry G2012 when its travel covers the stations, else the cheapest priced arm whose payload (less the gripper) and reach (97 %) cover the layout's station points. - **Layout**: units by construction in the cell frame, then `placeInBuilding(units, factoryContext(), {near, clearance})`: the nearest free origin (0.5 m grid, yaw 0/90°) inside the walls, out of the aisle and forklift lanes, `clearance` from every existing object and column of `tasks/rmfg-factory`. - **Controls**: `controlsFor(devices, cabinet)` → a PLC I/O list, a netlist routed over a 2.6 m tray and a harness from `operations/harness-make.mjs designHarness`. **Cost**: vitamins from catalogue prices (published or labelled estimates; `CONTROL_ESTIMATES` for controls without a public price), made items from steel mass and labour. - **Cycle time**: `CAL` — the operator bending model is fitted to a cell-synthesis-bend run (named in `CAL.bend.run`); walking and manual welding come from `flow/calibration.json`; the robot weld-cell terms are an authored estimate that the physics run tests. - **Refusals**: no brake plans the parts (the largest brake is named), over budget, over the envelope, no free place, no handling mode within the shift or operator hours, an unsupported process (the nearest existing equipment), or a configuration the generated scenes do not build (robot bending, manual welding; the design is still returned in `alternatives`). The synthesiser does not run physics. `skills/cell-synthesis-bend.mjs` (`bendCellScene`, `runBendCell`) and `skills/cell-synthesis-weld.mjs` (`weldCellScene`, `runWeldCell`) generate a scene and program from a synthesised cell; `tasks/cell-synthesis-bend` and `tasks/cell-synthesis-weld` write `cell.json` with `make-cell.mjs` and run them. Their checkers re-derive the part criteria (sheet-part-order's `measurePiece`, fixture-weld-cell's weldment checks), audit the layout in the building and in the cell (`tasks/cell-synthesis-bend/audit.mjs`), the robots' recorded reach and payload, the fence, the catalogue-priced budget, and the measured cycle times against the estimate; from `cell.json` they read only the estimate. To verify a new cell, write its `cell.json` and run the task (`node scripts/run.mjs cell-synthesis-bend --variant `); `verified.runId` is then the run's id. ## Failure tags Every failed run is tagged by `src/failure-taxonomy.mjs` with one primary and an optional secondary category (physics, shop practice, process knowledge, result analysis, circumvention, infrastructure; `unclassified` keeps the raw text) and the evidence (check id, error code, message, measurement). Rules read what the engine and checkers already record; see `docs/failure-taxonomy.md`. A checker may annotate a check with its own category, which wins: `{id, label, pass: false, category: "physics.thermal"}` (or just `"physics"`). `GET /api/failures?run=RUN_ID` returns one run's tags; `GET /api/failures?task=&agent=&category=&day=&since=&limit=` lists failed runs, newest first; `GET /api/failures/summary?since=&task=&agent=` returns counts by category per day, task and agent (WorldBench attempts). Tags are cached in `.world2/failures/index.json.gz` and backfilled incrementally; `node scripts/failures.mjs` prints the current distribution. The viewer shows the tags under a run's checks and a Failures dashboard in the header. ## Comparing recordings The viewer's Compare runs panel shows output, checks, simulated production time, material travel, compute time and operation counts. Improvement credit requires the same task/scenario, work-order hash, checker hash, runtime hash and usable output, with every check passing in both runs. Time versus distance trade-offs are labelled explicitly. Wall-clock compute alone is not a factory improvement. `GET /api/runs/{id}?summary=1` returns only metrics and provenance for comparison; the other scene's replay is loaded only when selected. `?task=...&run=...` links to an exact recording. Older failed runs and successful baselines remain stored. Add `&compare=OTHER_RUN_ID` to open a paired comparison directly. Tasks with a `cost_model` also show measured replay time separately from estimated handling/setup allowances and direct labor/material cost. Both runs must use the same assumptions. Those tasks rank direct cost after all quality checks pass; distance/time trade-offs remain visible. A win is withheld if the ranking reverses across the paired setup-time sensitivity estimates. These are estimates, not measured factory costs or structural certification. The same panel shows scenario coverage: first-attempt passes, passes using the selected program/scene/shared-skill versions, repeat runs and code versions tried. Earlier successful code cannot substitute for an untested changed skill. Coverage reads all small summaries, independently of the recent replay history window; it never loads all replay geometry. Older summaries without version information do not earn coverage credit. Repeat runs and code versions measure repair effort, not human interventions or model calls. ## Rail-mounted serial arms A kinematic carriage declares `rail:{anchor:'fixed-rail',origin:[0,0,0], axis:[1,0,0],min:-1.5,max:1.5,speed_mps:0.4}`. Origin is relative to the fixed anchor's world position; axis is a unit world vector. `await slide(carriageId, travelMetres)` moves the carriage plus every workspace anchored to it and their held loads. Ordinary move/rotate cannot bypass the rail. Plan arm stow poses before sliding: arm links and held loads are checked along the path. For a real serial arm, the kinematic gripper declares `serialArm`: ``` {base:'carriage',baseQuaternion:[x,y,z,w],initialAngles:[...], joints:[{position:[x,y,z],quaternion:[x,y,z,w],axis:[x,y,z], limits:[minRad,maxRad],speed_radps:3,collisionRadius:0.04}, ...], tip:{position:[x,y,z],quaternion:[x,y,z,w]}, visuals:[{link:0,meshes:[{url:'/assets/content-version/base.obj',color:'#888888'}]}, ...]} ``` Each joint origin is relative to its predecessor. Parent transform → joint origin translation/rotation → local-axis joint rotation. Links are indexed 0 (base), 1 (first moving link), etc. OBJ vertices are metres in that link frame. Put immutable mesh files and their source/license receipts in `parts/assets/`; keep the data constructor in an ordinary `parts/*.mjs`. A baseQuaternion maps vendor coordinates to Y-up. The initial joint angles determine the true wrist pose (scene position is replaced at initialization). `workspace` must anchor to the same base; `rigidGrip:true`, small local grasp `reach`, `payload_kg` and `capacity` apply normally. `get(wrist).jointAngles` exposes actual joint angles. `await armPose(wristId,position,{rotation:[rx,ry,rz],speed:0.3})` solves the full position/orientation with damped least squares, then interpolates joint angles with acceleration/deceleration and per-joint speed limits. Rotation is optional (keep current orientation). Intermediate TCP path can curve: this is joint-space motion, not a straight Cartesian stroke. Use clear approach, withdraw and stow waypoints. `move(wrist,position)` also uses this joint solver. A held object's move moves its serial wrist. To change orientation, rotate the wrist, not its attached load. The current joint state is the IK seed; if it cannot converge, the home configuration is tried. Unreachable requests reject. An optional `posture:[joint angles in radians]` is tried as the first seed: a redundancy hint that picks the arm's branch (for a 7-axis arm, its elbow and joint-1 turn) instead of whatever long runs of small moves have drifted to. The motion is still a checked joint-space move from the current joints. `move(id,position,{linear:true})` on a serial wrist or arm-held object is a straight Cartesian line at fixed orientation, with pose IK at every physics step. A path that needs a joint jump (a singularity) rejects. `drill()` and `fasten()` feed arm-held tools the same way, so an arm can drill; the stock being cut is exempt only from the carried tool's checks, never from the link capsules. IK solves to 0.1 µm / 1 µrad where it can and accepts 0.15 mm / 1 mrad; held objects keep a fixed grip offset for the whole grasp. A blocked arm move emits a `collision` event before rejecting. Self-collision: the arm's own base (its workspace anchor: column, pedestal or carriage) is an obstacle for every link capsule except the mount links that touch it in the home configuration (`mountLinks`), and for the gripper and everything the arm holds. Link capsules are also checked against what the arm carries at its next pose, except links already touching a carried body when it was gripped (the flange links around a gripper; `checkLinksAgainstCarried`). Such hits reject with `(its own base)` or `which the arm carries` and the collision event has `self:true`. A rail slide moves base and arm together and skips it. Carried tools are checked against drilled stock's real solid, so a bit may retract through the bore it cut. Two serial arms can carry one part together (`operations/shared-carry.mjs`), for example a part heavier than either arm's `payload_kg`. `await graspTogether([armA, armB], partId)` closes both arms' jaws where their pinch points stand: each must be empty and jawed, and the part (with anything joined to it) free. The weight is shared by the lever rule along the horizontal line joining the two pinch points; the centre of mass must project between them. Each share is checked against that arm's `payload_kg` and its jaws' friction bound, and the part must already be centred in each pair of jaws (±2 mm), since it cannot be pushed to the centre of both. The `grasp` event carries `shared:true`, `holders`, `mass_kg`, `shares_kg` and `moment_arm_m` (the centre of mass's distance from the grip line, which is not modelled). `get(part)` reports `heldBy` (the first arm) and `sharedBy` (both). `await carryTogether(partId, position, {rotation:[rx,ry,rz], speed:0.05})` moves the part along a straight line with a slerped orientation; every physics step both wrists are solved by pose IK in their fixed grasp pose relative to the part, and arm links, wrists and the part are checked against the scene. A path needing a joint jump rejects. `move(partId, position)` of a jointly held part is the same carry with its orientation kept. An arm sharing a load cannot `armPose`, feed or grasp anything else; `release(partId)` opens both arms' jaws. Fidelity: static, vertical load sharing only. Moments about the grip line, inertial loads, internal forces between the two grippers and compliance are not modelled, and the links of the two arms are not checked against each other. It was developed alongside `coGrasp`/`coMove` (Two-arm cooperative lifting, below), which is stricter: it also checks arm-to-arm and link-to-load clearance and counts the gripper's mass against the payload. The two are not interchangeable within one lift; an arm in either kind of lift is refused by the other's grasp. `skills/arm-tools.mjs` exports `armTools(world, armId, {clearance, speed})` with `ready(height)`, `pickTool(id)`, `drillAt(stockId, worldEntry, depth)`, `putTool()` and `travel(id, target)`. The gripper points down, with its yaw following the arm's bearing; travel arcs around the base as short joint moves; pickup, approach, feed and retract are straight lines. `parts/robot-spindle.mjs` exports `robotSpindle(...)`, a drilling tool whose origin is a 40 mm grasp knob. `pickTool(id, {toward, yaw})` grips with the down-facing yaw over `toward` (plus `yaw` radians; a round knob can be gripped from either side, which keeps the last joint clear of its limit) and keeps that orientation in transit until `putTool()`. `travel(id, target, {rotation})` holds a given XYZ orientation instead of the bearing yaw. `armTools(world, arm, {yaw: Math.PI})` turns every down-facing orientation so the jaws lie against the bearing; for a KUKA iiwa this keeps joint 7 near zero instead of near its ±175° limit (the default `yaw: 0` is unchanged). `sawAt(stockId, at, {parts, feed, gap, across, alignFirst})` crosscuts clamped stock at its local X `at` with a held saw: the wrist is commanded to stock rotation × inverse(grip rotation), because `cut()` compares the saw and stock orientations to 1e-6; the blade tip is lowered in a straight line to `gap` (default 10 mm) above the stock, the saw is started, cut, stopped and retracted along the stock's own axes. `across` offsets the blade centre along the stock's local Z (so a long blade can extend to one side of the stock), and `alignFirst` turns the saw to the stock frame before travelling instead of above the cut. `parts/arm-saw.mjs` exports `armSaw(...)`, a straight saw with a 40 mm grasp knob (2 mm kerf, 200 mm span, 150 mm blade depth): a geometric plunge crosscut with no reciprocation, cutting force or wood strength. The workshop `backsaw` has no knob a 2F-85 can close on. The shared `operator()`/`lateralTools()` skills remain specific to the carrier. Optional controller acceleration limits: `serialArm.accel_mps2` (tool point) and `joints[i].accel_radps2`; a rail's `accel_mps2`. Moves follow a smoothstep, whose peak acceleration is 6·travel/T², so `armPose`, straight moves and slides then last at least √(6·travel/limit). Chains and rails without them keep the speed-only timing. `parts/ur20.mjs` exports `ur20(id, base, {tool,...})`, a Universal Robots UR20 from its catalogue record (published DH link lengths on the UR5e joint layout, 20 kg, 1.75 m; drawn with the UR5e meshes scaled per link; authored 1.5 rad/s, 2.5 rad/s² and 2.5 m/s² limits); `tool` is the TCP in its flange frame. `parts/robot-track.mjs` exports `floorTrack(id, carriageId, {from,to,z,mountHeight,start,speed,accel,chainSide,chainBracket})`, a floor rail and carriage whose top face is an arm's mounting face (a seventh axis), drawn as a Güdel TMF-1 track (record `gudel-tmf-1`). The drawing alone is also exported: `robotTrackBase(record, {length, chainSide})` (fixed parts, floor at y = 0), `robotTrackCarriage(record, {chainSide, riser})` (origin on the carriage top, 0.288 m above the floor) and `trackChain(record, chainSide)`, the energy-chain tray's centreline z, width and the carriage bracket height for placing a `parts/controls.mjs` `dragChain` beside the track. Real mesh appearance and joint kinematics do not imply full dynamics. Link clearance uses approximate capsules between joint origins. Wrist and carried objects use their collider geometry. Self-collision, torque, gripper force, flexibility and validated hardware control are not implemented. Retain these limits in the task's notes. ## Arms from their makers' data sheets Any `robot-arm` catalogue record with a `kinematics` field (catalog/SCHEMA.md, "Arm kinematics") can be placed in a scene: ```js import { datasheetArm, armRiser } from '../../parts/datasheet-arm.mjs'; import { vacuumFrame } from '../../parts/vacuum-frame.mjs'; const frame = vacuumFrame(); // or any tool {position, quaternion, visuals, colliders, mass, com, inertia_kgm2} objects.push(armRiser('robot_base', 'yaskawa-motoman-gp400', [0, .05, 0]), datasheetArm('robot', 'robot_base', 'yaskawa-motoman-gp400', { tool: frame, vacuum: frame.vacuum })); ``` The tool's TCP is given in the flange frame (+Z out of the flange). Joint angles, limits and speeds are the maker's own (sign and zero from the record); `armPose`, `move(..., {linear: true})` and `grasp` work as for any serial arm. `grasp` first checks the wrist's rated load (operations/arm-load.mjs): tool plus load against the rated payload, and the static load moments and inertias about J4/J5/J6 against the published allowable values. A refusal throws code `PAYLOAD` and records an `arm_load_refused` event with the figures; a pass records `arm_load`. The appearance is generated from the link offsets, not the maker's CAD. ## Two-arm cooperative lifting `operations/cooperative-lift.mjs` lets two serial arms with `jaws` lift and carry one part (or connected assembly) together. - `await coGrasp([armA, armB], partId)`: both arms must be empty, with their pinch points already on the part. The weight (measured solid volume × density, as for `grasp`) is divided by the lever rule: the centre of mass (Rapier's, taken while the part is free) projected onto the grips' horizontal separation, each grip a pin carrying vertical force only. Grips must be at least 100 mm apart horizontally, and the centre of mass must lie between them. Each arm's share plus its gripper's declared `mass` must not exceed its `payload_kg` (code `PAYLOAD`). Both jaws close as in `grasp` (first contact, stroke, and the static friction bound against that arm's share). If the two jaws would centre the part differently by more than 2 mm, the grasp is refused. The part is held by `armA` (`heldBy`); `get(arm).coLift` names both arms and the part. The `co_grasp` event reports the mass, the shares, the payloads, the grip span and the lateral offset of the centre of mass. - `await coMove(partId, target, {rotation?, speed:0.1})`: the part's origin goes in a straight line to `target` (up to 0.5 m/s). An optional XYZ rotation is slerped, at up to 0.5 rad/s. At every physics step both wrists are placed from the commanded part pose and their fixed grasp offsets, and each arm solves pose IK warm-started from its last step. The move stops if either arm has no solution within its joint limits (`IK_UNREACHABLE`) or needs a joint jump (`JOINT_SPEED`). It also stops if a wrist leaves its workspace sphere, or if a link capsule touches the scene, the other arm's links, the other gripper or the load (`ARM_COLLISION`). So does a load share over a payload (`PAYLOAD`), or the load or a gripper hitting anything (`COLLISION`, with a `collision` event). The part keeps its grasp where it stopped. `co_move_completed` reports the worst IK residual (`max_grasp_error_m`) and the peak shares. - While the lift lasts, neither arm moves alone: `armPose`, `move`, linear moves, arm-held feeds, `rotate`, `grasp` and `slide` of a carriage carrying either arm are all refused (`COOPERATIVE_LIFT`). `release(partId)` ends the lift and opens both jaws. Fidelity limits: the load split is static and statically determinate. Grip moments, the roll moment of an off-line centre of mass (reported, not limited), and inertia are not modelled. Nor is the internal squeeze or tension that two position-controlled arms put into a rigid part. Payload-versus-offset load diagrams, joint torques, compliance, slip and part flexing are not modelled either. Counting the gripper's mass against the rated payload is an authored, conservative choice; single-arm `grasp` counts only the load. The part follows the commanded path exactly, and the wrists follow their IK solutions to 0.15 mm / 1 mrad. Link-to-link and link-to-load checks use the same approximate capsules as `checkArmLinks`. `test/cooperative-lift.test.mjs` triggers all three link checks: link-to-scene, link-to-load (a lug connected to the part) and arm-to-arm. `PAYLOAD` covers the arms only. A jaw's `payload_kg` is checked against that arm's share: the menagerie 2F-85 jaws carry 5 kg, so `tasks/two-arm-lift` fits OnRobot 2FG14 grippers (14 kg) for its 9.42 or 11.30 kg shares; its grips hold only with the authored pad friction 0.8. `tasks/two-arm-lift` uses it: two KUKA iiwa 14 arms carry a steel plate that is too heavy for either one. Objects can declare `availableAfter:seconds` to prevent grasping before a scheduled process/cooldown finishes. This only gates handling of an existing object; it does not simulate printing, cooling or manufacture new material (`fdmPrint` does print new material). A scene may set `view:{direction:[x,y,z]}` to choose the default Fit view side. Saved user camera positions still take precedence on reload. This changes only presentation, never object placement or physical evidence.