/** * A Tesla Optimus, procedurally, as a rig you can pose — plus the merged static * version for a pack that just wants one standing in a corner. * * This is the first asset in the library that is **not** a single merged mesh, * and the reason is the only reason that would justify it: something has to walk * it around the office (`interiors/robots.ts`), and a walk cycle needs limbs * that move independently. `parts.ts` says it plainly — anything that has to * move on its own belongs in its own object rather than in a bin — so the figure * is eleven small bins, one per articulating joint, each merged internally and * hung off the joint it belongs to. Eighteen meshes per robot rather than two. * That price is stated again, in draw calls, at the bottom of this comment and * in `robots.ts`, because it is the whole cost of the feature. * * ### The figure * * Gen 3 from reference knowledge, and the proportions are what carry it, because * ten metres is the distance this is normally seen from and at ten metres a * silhouette is all there is. What has to be right, in order of how much it * matters: * * 1. **1.73 m and slim.** Optimus is human-height and noticeably narrower than a * human — a 0.35 m shoulder span on a 1.73 m frame. Build it at human width * and it reads as a person in a costume. * 2. **The waist.** The single most identifying line: a wide pelvis, a wide * chest, and a genuinely thin dark column between them. It is 0.15 m across * where the pelvis is 0.28 and the chest is 0.34. Widen it and the whole * thing turns into a mannequin. * 3. **The visor.** A smooth black panel filling the front of a small pale head, * with no features on it at all. Two eyes, a mouth line, a "friendly" curve — * any of them and it stops being Optimus. It has to *follow the head*: a flat * plate on a rounded shell overhangs at its corners, and a face with the * corners of its visor hanging in the air beside it is the single most * conspicuous thing this figure can get wrong. See `emitHead`. * 4. **Pale shells over a dark frame.** Every limb is a light shell that stops * short of the joint, with dark structure showing in the gap. That gap is * what makes it read as a machine rather than as a white plastic doll, and it * costs nothing but a few millimetres of geometry. The ankle, knee, hip, * wrist and elbow all have one; the shoulder has a dark drum showing past the * edge of its cap instead, for a draw-call reason written out at the drum. * Whatever else changes, do not let two pale parts meet each other — and do * not let them merely *touch*, either. Every join in this file overlaps by a * few millimetres, because parts that share a face z-fight along it and parts * that stop at the same plane by arithmetic eventually stop a millimetre * short instead. Both of those have happened here. * 5. The knee actuator, the shoulder caps, the five-fingered hands. Detail, not * silhouette. Present because they are cheap, not because they are load- * bearing. * * ### Two materials, and why not three * * `paper` for the shells and `screenBezel` for the dark frame and the visor. * Both are borrowed — `materials.ts` has a closed role list with no robot in it, * and inventing a role is not on offer — so the borrow is chosen to survive a * self-hoster recolouring the palette. `paper` is the library's palest * untextured neutral, which is exactly what an Optimus shell is; `screenBezel` * is its near-black, and the visor genuinely *is* a bezel, so a self-hoster who * darkens their screen surrounds darkens the robot's face, which is the right * coupling rather than a coincidental one. * * `metalTrim` was tried as a third material for the joint barrels and dropped. * The office rig carries no environment map, so a `metalness: 0.85` role has * nothing to reflect and renders as a dull dark grey — indistinguishable from * `screenBezel` at ten metres — while costing another mesh in nine of the eleven * groups. Two materials, eighteen meshes. * * ### The indexed/non-indexed rule bites here harder than anywhere else * * `common.ts` warns that every part under one material must be all-indexed or * all-non-indexed or `mergeGeometries` silently drops the material. With * eighteen bins there are eighteen chances to get it wrong and the symptom is an * invisible shin, so the split is made structural rather than remembered: * * - **`shell` is drawn with `roundedBox()` and nothing else.** `ExtrudeGeometry`, * never indexed. Every pale part of the robot is a rounded box, which is also * what Optimus actually looks like — flat-sided shells with softened edges, * not tubes. * - **`frame` is drawn with `box()` and `cylinder()` and nothing else.** Both * indexed. This is why the visor is five flat boxes fitted round the head's * corner rather than one moulded shell: it is drawn in the dark material, so * it may not be an extrusion, so the curve has to be faceted by hand. * * Add a part and put it in the material whose primitive class it already * belongs to. If you cannot, you want the *other* material, and it will usually * turn out to look better there anyway. * * ### The joint frame * * The figure faces **−Z at yaw 0**, same as every other asset (`common.ts`), and * the origin is on the floor between the feet. Consequences worth writing down, * because getting a sign wrong here produces a robot that walks backwards * through its own knees: * * - **+X is the robot's right, −X its left.** Stand behind it looking the way it * faces — which is looking down −Z, the three.js default view direction — and * its right hand is on your right, which is +X. So `hipR` is at `x > 0`. * - **Every joint group's local axes are the root's axes.** No rotation is baked * into a joint; a rig with every rotation at zero is a figure standing to * attention. `OPTIMUS_REST` is applied on top of that, by `buildOptimus`, and * is what "standing still" means to the walk cycle. * - **`rotation.x` is the sagittal hinge and positive swings the limb forward.** * A limb hangs along −Y; `Rx(θ)` sends (0,−1,0) to (0,−cos θ,−sin θ), and −Z * is the front. So `hipL.rotation.x = +0.4` is a leg reaching forward and * `shoulderR.rotation.x = +0.4` is a hand swung forward. * - **A knee bends the other way, so `knee.rotation.x` must stay ≤ 0.** The shin * folds backwards, toward +Z. Positive values hyperextend it, and because * nothing clamps them, a sign slip in an animation shows up as a robot with * its knees on backwards rather than as an error. This is the asymmetry to * remember: hip, shoulder and elbow all bend positive; the knee is the one * that bends negative. * - **An elbow bends positive.** The forearm folds forward, toward the chest, * which is −Z. It shares its sign with the shoulder and not with the knee. * - **`rotation.y` is the transverse twist and positive turns left** * (counter-clockwise from above — the house `Yaw` sense, unconverted). Used on * `pelvis` and `torso` for the counter-rotation of a walk. * - **`rotation.z` is the frontal lean and positive leans left.** `Rz(θ)` sends * up (0,1,0) toward −X. Used on `torso` for sway and on `shoulder` for the * outward splay that keeps the hands off the hips. * - **`pelvis.position.y` is the body's height above the floor** and starts at * `OPTIMUS.hipY`. Everything else hangs off it, feet included, so lowering the * pelvis lowers the whole robot — which is what a walk's vertical oscillation * wants and is why the bob is applied there and nowhere else. * * ### What it costs * * Eighteen meshes and about 7,100 triangles per figure. The mesh count is the * one that matters and it is fixed by the eleven bins and the two materials; * the triangles are almost all `roundedBox`, which is 236 of them whatever size * it is drawn at and however tight its bevel. That flat rate is worth knowing * before adding shell detail: a 14 mm finger and a 340 mm chest cost the same, * which is why the fingers were a quarter of the figure until somebody counted * (see `DIGITS`). `buildOptimus` is * meant to be called **once**; `cloneOptimus` gives you another figure sharing * every geometry and both materials, which is how four robots cost four times * the draw calls but one times the memory. Seventy-two draw calls for a crowd of * four is real money against an office that draws in about thirty, and it is the * number to look at first if a floor starts dropping frames. * * The `tera:robot.optimus` asset is the other end of that trade: it flattens the * same rig into one mesh per material, so a pack that wants a robot standing * still gets two draw calls and no articulation. `furnishings.ts` instances per * mesh, so twenty static robots in a pack still cost those two. */ import * as THREE from "three"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import { defineAsset, type AssetContext } from "../kit.ts"; import type { SurfaceMaterial } from "../materials.ts"; import { MeshBin, type PartBin } from "../parts.ts"; /** * Every dimension the figure and the walk cycle both have to agree on, in * metres above the sole, in the rest pose. * * Exported because `robots.ts` needs `hipY` to bob the pelvis around and would * otherwise carry a second copy of a number that must not drift. Everything * else — shell widths, bevel radii, how far a finger sticks out — is local to * the emitters below and deliberately not part of the contract. */ export const OPTIMUS = { /** Sole to crown. Gen 3 is quoted at about 1.73 m. */ height: 1.73, ankleY: 0.085, kneeY: 0.5, hipY: 0.92, /** Top of the pelvis shell; the waist column starts here and the torso pivots here. */ waistY: 1.05, /** Bottom of the chest shell. The 0.10 m between this and `waistY` is the slim bit. */ chestY: 1.15, elbowY: 1.12, wristY: 0.865, shoulderY: 1.4, /** Top of the chest shell, where the neck column starts. */ neckY: 1.43, /** The head's pivot: the top of the neck, so a nod hinges where a neck does. */ headY: 1.505, /** Half the distance between the two hip pivots. A narrow stance; Optimus has one. */ hipHalf: 0.085, /** Half the distance between the two shoulder pivots. */ shoulderHalf: 0.175, } as const; /** * What "standing still" is, in radians, applied by `buildOptimus` and used as * the base every animated angle is added to. * * These are not zero because a figure with every joint at zero stands with its * arms welded to its hips and its legs locked straight, which looks like a * mannequin rather than like a machine that is idling. A few degrees of elbow * and a few of outward arm splay is the whole difference. * * `shoulderZ` is a magnitude: it is applied as `+` on the right shoulder and `−` * on the left, because positive `rotation.z` swings a hanging arm toward +X and * +X is the robot's right — so the same sign splays one arm out and tucks the * other one in. * * `hipX` and `kneeX` cancel, and that is not a coincidence: nothing here moves * the ankle, so the foot's pitch is the *sum* of the two, and any sum but zero * stands the robot on its heels or its toes. The first draft had a 0.01 hip and * a −0.05 knee and buried six millimetres of toe in the carpet. Change one of * these and change the other. The residue is that a bent leg is fractionally * shorter than a straight one, so the soles float about a millimetre — which is * a millimetre, and cheaper than a third joint to correct it. */ export const OPTIMUS_REST = { hipX: 0.04, /** Negative, and exactly −`hipX`. A knee only bends one way; see above. */ kneeX: -0.04, shoulderX: 0.03, shoulderZ: 0.075, elbowX: 0.16, } as const; /** * The joints a walk cycle drives, resolved once so an animation never has to * search the scene graph per frame. * * `pelvis` carries the legs and the body's height; `torso` is a child of * `pelvis` and carries everything above the waist, which is what lets the * shoulders counter-rotate against the hips without the legs coming with them. */ export interface OptimusJoints { /** Body height and hip twist. Pivots at `OPTIMUS.hipY`; the legs hang off it. */ pelvis: THREE.Group; /** Sway, lean and shoulder counter-twist. Pivots at the waist. */ torso: THREE.Group; head: THREE.Group; hipL: THREE.Group; hipR: THREE.Group; kneeL: THREE.Group; kneeR: THREE.Group; shoulderL: THREE.Group; shoulderR: THREE.Group; elbowL: THREE.Group; elbowR: THREE.Group; } export interface OptimusRig { /** Origin on the floor between the feet, facing −Z at `rotation.y === 0`. */ root: THREE.Group; joints: OptimusJoints; } /** The two materials the whole figure is drawn in. See the header for the borrow. */ interface Skin { shell: SurfaceMaterial; frame: SurfaceMaterial; } /** A point in whichever joint frame the emitter is drawing into. */ interface Anchor { x: number; y: number; z: number; } const JOINT_NAMES = [ "pelvis", "torso", "head", "hipL", "hipR", "kneeL", "kneeR", "shoulderL", "shoulderR", "elbowL", "elbowR", ] as const; // ---- Emitters ------------------------------------------------------------- // // Each of these draws one body part into a bin, in that bin's own frame, with // `a` naming the joint centre the part hangs from. They are written this way so // that the rig and the flattened asset are the *same* geometry rather than two // bodies that will drift apart the first time somebody widens a shin. /** * A dark joint barrel lying across the body, centred on `a`. * * A unit cylinder stands along +Y with its base at the placement point, so a * roll of +π/2 sends its length along −X — which means the placement has to sit * half a length to the *right* of where the barrel should end up. Getting that * offset wrong puts every joint on the robot half a barrel off-centre, which is * subtle enough to survive a first look and obvious enough to ruin the second. */ function barrel( bin: MeshBin, P: PartBin, material: SurfaceMaterial, a: Anchor, diameter: number, length: number, ): void { bin.add(P.cylinder(12), material, { x: a.x + length / 2, y: a.y, z: a.z, size: [diameter, length, diameter], roll: Math.PI / 2, }); } /** The pelvis block, and the hip axle the legs turn on. Drawn in the pelvis frame. */ function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void { // Straddles the hip line: down to 0.855 m and up to the waist at 1.05 m. The // width is what the slim waist above it is measured against. bin.add(P.roundedBox(0.07), s.shell, { y: -0.065, size: [0.28, 0.195, 0.19] }); // The axle has to be longer than the pelvis is wide or none of it is visible, // and for a long time none of it was: at `2·hipHalf + 0.05` it stopped at // ±0.11 inside a shell that reaches ±0.14, and the comment on this line // claimed it "runs right through and out both sides" on the strength of // nobody having measured it. `+ 0.14` puts it at ±0.155: 15 mm of dark disc // clear of the pelvis and 2.5 mm clear of the outer face of each thigh, which // is the gap rule 4 in the header is about and the one the hip did not have. // Shorten it again and the hip goes back to one unbroken pale mass from the // waist to the knee, which is what a mannequin looks like. barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14); } /** * Waist, chest, shoulder caps and neck. Drawn in the torso frame, whose origin * is the waist at `OPTIMUS.waistY`. * * The shoulder caps live here rather than on the arms on purpose, and it is not * only a draw-call saving: on the real machine the cap is bodywork bolted to the * torso and the arm swings inside it. Putting the cap on the arm makes the whole * shoulder rotate when an arm swings, which reads as a shrug. */ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void { const waist = OPTIMUS.chestY - OPTIMUS.waistY; const chest = OPTIMUS.neckY - OPTIMUS.chestY; // The waist. 0.15 m across between a 0.28 m pelvis and a 0.34 m chest, and it // is drawn in the dark material so the gap reads as structure rather than as // a robot that skipped lunch. bin.add(P.box(), s.frame, { y: -0.02, size: [0.15, waist + 0.04, 0.135] }); // Chest: a shell, then a plate a few millimetres proud of it. The plate is // what catches the light and gives the chest an edge at ten metres; without it // the torso is one flat pale slab. bin.add(P.roundedBox(0.06), s.shell, { y: waist, size: [0.34, chest, 0.205], }); bin.add(P.roundedBox(0.055), s.shell, { y: waist + 0.045, z: -0.098, size: [0.245, chest - 0.09, 0.022], }); // The neck column, dark, running from the top of the chest into the head. bin.add(P.cylinder(10), s.frame, { y: OPTIMUS.neckY - OPTIMUS.waistY, size: [0.072, OPTIMUS.headY - OPTIMUS.neckY + 0.012, 0.072], }); const shoulderY = OPTIMUS.shoulderY - OPTIMUS.waistY; for (const side of [-1, 1]) { // The cap. Rounded hard, because a square shoulder is the other thing that // makes a humanoid read as a costume. bin.add(P.roundedBox(0.09), s.shell, { x: side * OPTIMUS.shoulderHalf, y: shoulderY - 0.078, size: [0.13, 0.156, 0.15], }); // The drum the arm turns in. 0.135 across and 0.17 long against a cap that // is 0.13 wide, 0.156 tall and 0.15 deep, and every one of those numbers is // chosen against the cap's: the drum is buried inside the cap in Y and Z // and clears it only in X, so what shows is 20 mm of dark disc past the // outboard face of the cap and, inboard, a nub of the same 20 mm in the // 37 mm between the top of the chest and the top of the drum. The nub is // wanted — it is the only thing between the neck and the shoulder — but it // is the reason the drum's diameter is not free: take it past 0.16 and it // breaks out under the cap as well, and the shoulder turns into a dark // wheel with a pale plate stuck on it. // // That disc is the *only* dark structure at the shoulder, and it is here // rather than on the arm for a reason worth stating, because the obvious // fix looks better and costs more than it is worth. Rule 4 in the header // wants a pale shell stopping short of the joint with dark showing in the // gap; the knee and the elbow both do it. Doing it at the shoulder means a // dark part in the *shoulder* bin, which today draws in `paper` alone — so // it would go from one mesh to two, twice, on every figure: 20 meshes a // robot and 80 draw calls for a crowd of four instead of 72. Eight draw // calls for a 24 mm band of dark under a cap that already reads as a // separate piece is not the trade. A fatter drum is free. barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17); } } /** * The head shell's front-right corner, as fractions of its half-width and * half-depth, walking round from the middle of the face toward the temple. * * These are **measured off the built shell**, by casting rays at it, not * derived. They have to be, because the surface they describe is the offset * outline of a quadratic-Bezier corner that `ExtrudeGeometry` then bevels, and * writing that arithmetic out here would be a worse lie than a measurement: * it would look authoritative and it would be a re-implementation of three.js * internals. The visor is fitted to *these* numbers, so `x: 1` really is the * widest point of the head and `z: 1` really is the flat front of the face. * * **They are only valid for `roundedBox(0.08)`.** The radius is what sets where * the flat front stops — at 0.08 it stops 53% of the way out, and the corner * from there to the temple is a four-segment polyline, of which the visor uses * the first two. Change the head's bevel and these are wrong, and the symptom * is a visor whose outer corners hang in the air beside the head rather than * an error. Re-measure it: cast a ray down +Z at the shell at a series of x, * at a y inside the shell's straight middle band, and read off where it hits. * (An earlier visor was three hand-placed boxes and its outer pieces stood * 19 mm proud of a 165 mm-wide head, which is what that failure looks like.) * * Scaling with the head rather than in metres so that the two survive being * resized together; they do **not** survive being reproportioned, because a * corner is not similar to itself under a non-uniform scale. */ const FACE_FLAT = { x: 0.5312, z: 1.0 } as const; const FACE_MID = { x: 0.7146, z: 0.9738 } as const; const FACE_TEMPLE = { x: 0.8766, z: 0.8766 } as const; /** Head shell, metres. Named because the visor is fitted to all three. */ const HEAD_SIZE = { width: 0.165, height: 0.215, depth: 0.185 } as const; /** * How far the visor's outer face stands off the shell it is fitted to, and how * thick the panel is. * * 1.5 mm is deliberately below anything that can resolve — at ten metres it is * a seventh of a pixel — and it is not there to be seen. It is there so the * sign can never go the other way. A visor flush with the shell z-fights it * along the whole seam; a visor a millimetre *inside* it disappears in patches * as the figure turns, which reads as a face flickering on and off and is by * some distance the ugliest thing this asset has ever done. */ const VISOR_PROUD = 0.0015; const VISOR_THICK = 0.016; /** * One panel of the visor, laid flat on the chord between two points of the * head's front corner and pushed out until its outer face clears the shell. * * `from` and `to` are `FACE_*` fractions on the robot's right; `side` mirrors * the whole thing for the left. Two things in here are easy to get wrong and * neither of them shows up as an error: * * - **The chord is the surface, not an approximation of it.** The shell's * corner really is a polyline in this band — it is an extruded polygon, not * a curve — so a panel laid on one of its edges is parallel to the shell for * that edge's whole length and stands off it by exactly `VISOR_PROUD`. This * is the entire reason the corner is stored as measured points instead of as * a radius: fit an arc to it instead and the panel touches at two places and * sinks in between, which is the flickering failure `VISOR_PROUD` describes. * - **The box is pushed *in* by half its thickness, not out.** A box's front is * its −Z face, so its centre has to sit `VISOR_THICK / 2` behind where the * front is wanted. Add instead of subtract and the panel floats 16 mm off the * head — which is very close to what the version before this one did. * * `seam` lengthens the panel at its inner end only. The inner end runs under * the piece before it, which is what stops a hairline of shell showing through * the joint between two panels at a grazing angle; the outer end is left exact, * because past it the shell falls away and any overhang there is a ledge on the * one edge of the visor anybody can see. */ function emitVisorPanel( bin: MeshBin, P: PartBin, s: Skin, side: number, from: { x: number; z: number }, to: { x: number; z: number }, visorY: number, visorHeight: number, seam: number, ): void { const halfX = HEAD_SIZE.width / 2; const halfZ = HEAD_SIZE.depth / 2; const x0 = from.x * halfX; const z0 = -from.z * halfZ; const x1 = to.x * halfX; const z1 = -to.z * halfZ; const dx = x1 - x0; const dz = z1 - z0; const span = Math.hypot(dx, dz); // Outward normal of the chord: rotate its direction a quarter turn so it // points away from the head. On the robot's right that is front-and-right. const nx = dz / span; const nz = -dx / span; // Midpoint, dragged back along the chord by half the seam so the panel grows // inward only, then offset along the normal to put its front face proud. const offset = VISOR_PROUD - VISOR_THICK / 2; const cx = (x0 + x1) / 2 - (dx / span) * (seam / 2) + nx * offset; const cz = (z0 + z1) / 2 - (dz / span) * (seam / 2) + nz * offset; bin.add(P.box(), s.frame, { x: side * cx, y: visorY, z: cz, size: [span + seam, visorHeight, VISOR_THICK], // A box's front is −Z, which a yaw of θ sends to (−sin θ, −cos θ); solving // that for the normal is the whole of this line. Mirrored for the left, // because a mirrored normal is a negated yaw. yaw: side * Math.atan2(-nx, -nz), }); } /** * The head: a small pale shell and a black visor. Drawn in the head frame, whose * origin is the top of the neck. * * The visor is five plain `box`es because the dark material is the indexed one * — see the primitive-class rule in the header, which is why the one part of * this figure most deserving of a smooth curve is a faceted approximation of * one. A flat plate across the middle of the face, then two panels a side * following the shell's corner out to the temple, each sitting `VISOR_PROUD` * off the surface it is laid on. Five boxes is 60 triangles; the head shell * beside them is 236. * * **The visor tracks the shell, it does not overhang it.** That is the property * to preserve if anything here is touched, and the reason the corner is stored * as measured points at all. The version before this one was three hand-placed * boxes with a hand-picked yaw: its flat middle plate stood 3.7 mm proud at the * corners where it left the flat of the face, and its two side pieces stood * **19 mm** proud of a head 165 mm wide, which is a face wearing goggles a size * too big. The comment beside them said 1.5 mm, and had said so since before * the shell's bevel was retuned from 0.12–0.22 to 0.055–0.09 and moved every * number it was describing. Measurements that are not re-taken are guesses with * a decimal point on them. * * At five to fifteen metres a robot's head is fifteen to forty-five pixels * tall. Nothing about the panel's *surface* survives that, and the only thing * that does is its outline against the pale shell. So the outline is the thing * that is built accurately and the rest is not built at all. * * It wraps as far as `FACE_TEMPLE` and stops, leaving about 10 mm of pale shell * either side in a front view. Wrapping further gets a black head; stopping at * the flat front gets a rectangle painted on a box. */ function emitHead(bin: MeshBin, P: PartBin, s: Skin): void { const base = OPTIMUS.height - OPTIMUS.headY - HEAD_SIZE.height; bin.add(P.roundedBox(0.08), s.shell, { y: base, size: [HEAD_SIZE.width, HEAD_SIZE.height, HEAD_SIZE.depth], }); // Kept inside the shell's straight middle band — `roundedBox(0.08)` rounds // the top and bottom 17 mm of a 215 mm head away in Y, and `FACE_*` describes // the cross-section between those. Push the visor into either and it starts // sinking into a surface that is no longer where the measurements say. const visorY = base + 0.078; const visorHeight = 0.082; const seam = 0.005; // The flat middle of the face, stopping exactly where the shell stops being // flat so the first wrap panel meets it edge to edge rather than crossing it. bin.add(P.box(), s.frame, { y: visorY, z: -HEAD_SIZE.depth / 2 - VISOR_PROUD + VISOR_THICK / 2, size: [FACE_FLAT.x * HEAD_SIZE.width, visorHeight, VISOR_THICK], }); for (const side of [-1, 1]) { emitVisorPanel(bin, P, s, side, FACE_FLAT, FACE_MID, visorY, visorHeight, seam); emitVisorPanel(bin, P, s, side, FACE_MID, FACE_TEMPLE, visorY, visorHeight, seam); } } /** A thigh. Drawn in the hip frame; the shell stops short at both ends. */ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void { const drop = OPTIMUS.hipY - OPTIMUS.kneeY; // Two stacked shells rather than one, for the taper. A parallel-sided thigh is // the difference between "slim humanoid" and "stilts". bin.add(P.roundedBox(0.07), s.shell, { y: -drop * 0.52, size: [0.135, drop * 0.46, 0.165] }); bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.045, size: [0.112, drop * 0.5, 0.14] }); } /** * Shin, ankle and foot, plus the knee actuator. Drawn in the knee frame. * * The actuator is the one piece of detail on the legs that is worth its * geometry: a dark barrel across the front of the knee is the single most * recognisable thing about an Optimus leg, and it is one cylinder. * * ### The ankle is built from the foot upward, and that is not a style choice * * Every part below the knee used to be placed off `drop`, and the ankle block * came out at 0.090–0.120 m above the floor while the foot shell ended at * 0.066 — a 24 mm band of nothing, with the whole leg's weight visibly resting * on air, and `OPTIMUS.ankleY` at 0.085 sitting in the middle of the void it is * supposed to name. `robots.ts` measures the entire gait pendulum to that * landmark, so the one height on this figure that had to have geometry at it * was the one that had none. * * So the ankle is anchored to the top of the foot, which is a surface, rather * than to a landmark, which is a number. It reaches 7 mm down into the foot * shell and 13 mm up into the shin shell — overlapping at both ends on purpose, * because two parts that merely touch share a face and z-fight along it, and a * flickering seam is worse than a joint that is a few millimetres thicker than * it needs to be. What is left visible is 39 mm of dark between a pale foot and * a pale shin, which is rule 4 in the header and is what an ankle is for. */ function emitShin(bin: MeshBin, P: PartBin, s: Skin): void { const drop = OPTIMUS.kneeY - OPTIMUS.ankleY; barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125); bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] }); // The foot. The sole is a separate dark slab so the robot has something to // stand on that is not the same colour as its shins — a monochrome foot // dissolves into a pale floor. const sole = -OPTIMUS.kneeY; const footTop = sole + 0.066; bin.add(P.box(), s.frame, { y: sole, z: -0.035, size: [0.098, 0.014, 0.25] }); bin.add(P.roundedBox(0.06), s.shell, { y: sole + 0.014, z: -0.035, size: [0.106, 0.052, 0.243], }); // The ankle, dark, filling everything between the two of them. Narrower than // both in X and Z, so the gap reads as a joint rather than as a change of // colour partway up a column — but only just. It is 12 mm narrower than the // shin and 18 mm narrower than the foot, and both of those were 26 and 32 at // one point, which left a 16 mm slot each side of the ankle that you could // see the floor through. Narrow enough to read as a waisted joint, wide // enough not to be a window: the two constraints point opposite ways and // this is where they meet. bin.add(P.box(), s.frame, { y: footTop - 0.007, size: [0.088, 0.059, 0.096] }); } /** * An upper arm. Drawn in the shoulder frame; the cap and the joint drum are * both on the torso, for the reasons given at each of them. * * One shell, and it reaches 17 mm *into* the elbow barrel below it rather than * stopping level with it. At `-drop + 0.048` the shell ended at 1.168 m and the * barrel reached 1.167 — a one-millimetre ring of daylight all the way round * the elbow, which does not read as a gap at any distance but does catch the * background for a frame at a time as the arm swings, and chasing that kind of * flicker back to a millimetre of arithmetic costs an afternoon. */ function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void { const drop = OPTIMUS.shoulderY - OPTIMUS.elbowY; bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.03, size: [0.088, drop - 0.08, 0.098] }); } /** * The digits of one hand, medial first. `medial` is the offset **toward the * body**, not an X coordinate, so this table is the same for both hands and the * one sign flip that makes a left hand a left hand happens where it is placed * — the same discipline the thumb has always had, extended to the fingers so * that the two cannot disagree about which way is inward. * * ### Three, not four, and why the count is the wrong question * * The previous version drew four identical 14 mm slabs in a row with 3 mm * between them, on the argument that what a hand needs at ten metres is five of * something, separate. Half of that is right and the expensive half is not. At * ten metres this figure is about 170 pixels tall, so a 14 mm finger is 1.4 of * them and a 3 mm gap is a third of one: **the separations were never once * resolved on screen.** What is resolved is the shape the tips make, and four * equal slabs make a straight line across the bottom of the hand — a comb, not * a hand. So the gaps are not what the geometry is for; the *stagger* is. * * Three digits of different lengths give the curved tip line a hand has, and * cost three of these rather than four. That matters more than it looks: * `roundedBox` is 236 triangles whatever size it is drawn at, so the four * fingers alone were 944 triangles a hand and the two hands together were a * quarter of the entire figure — a quarter of the budget spent below the * resolution limit. Dropping one digit a hand is 6% off the whole robot. * * `curl` is a rotation about +X applied at the *tip*, because a placement * rotates a part about its own base and these are placed tip-down. The base is * therefore solved for: it is the knuckle, less the rotated length. Getting * that backwards pivots each finger about its knuckle-end and fans the hand out * like a card trick. */ const DIGITS = [ { medial: 0.022, width: 0.016, depth: 0.024, length: 0.068, curl: 0.26 }, { medial: 0.002, width: 0.018, depth: 0.025, length: 0.076, curl: 0.3 }, { medial: -0.021, width: 0.024, depth: 0.024, length: 0.064, curl: 0.34 }, ]; /** * Forearm, wrist and hand. Drawn in the elbow frame. * * The digits are pale rather than dark, with only the wrist block in the frame * material — see `DIGITS` for what a hand is actually made of at this distance. * * This is the one emitter that has to know which side it is on, because a thumb * is the only part of the figure that is not left-right symmetric. It goes * medial — toward the body — which is where a relaxed arm puts it, and which * means `side` flips its sign. Drawing both thumbs at a fixed `+x`, as the first * version did, gives a robot with two right hands and a bounding box 27 mm wider * on one side than the other. * * **Nothing here butts up against anything.** The wrist block straddles * `OPTIMUS.wristY` and overlaps the forearm shell by 6 mm; the palm reaches * 6 mm up into the wrist block; the digits reach 6 mm up into the palm. All * three of those were exact abutments and two of them were exactly coplanar, * which is a z-fight rather than a join — and the third was not an abutment at * all but an 8 mm hole between the bottom of the forearm shell at 0.885 m and * the top of the wrist block at 0.877, straight through the arm. Overlap costs * nothing: these are merged into one buffer and the interior faces are never * seen from outside. */ function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void { const drop = OPTIMUS.elbowY - OPTIMUS.wristY; barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088); bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] }); // Wider than the palm below it and narrower than the forearm above, in that // order. At 0.062 it was narrower than both, which put a 6 mm slot of // daylight down each side of the wrist between two parts that are meant to // be joined; a joint that reads as a taper cannot do that. bin.add(P.box(), s.frame, { y: -drop - 0.016, size: [0.072, 0.042, 0.07] }); const palmTop = -drop - 0.01; bin.add(P.roundedBox(0.075), s.shell, { y: palmTop - 0.082, size: [0.068, 0.082, 0.032] }); const knuckle = palmTop - 0.076; for (const digit of DIGITS) { bin.add(P.roundedBox(0.08), s.shell, { x: -side * digit.medial, y: knuckle - digit.length * Math.cos(digit.curl), z: -digit.length * Math.sin(digit.curl), size: [digit.width, digit.length, digit.depth], pitch: digit.curl, }); } // The thumb, also placed tip-first, and the only part of the figure that // needs two rotations: it runs up and *outward* from a tip set 12 mm medial // of the palm's edge, and forward of the palm's face, so the pad ends up // opposing the fingers rather than lying alongside them. The old one was a // single roll about its own tip, which swung the knuckle 23 mm clear of the // hand and left the thumb attached to the palm at one corner. bin.add(P.roundedBox(0.08), s.shell, { x: -side * 0.046, y: palmTop - 0.078, z: -0.03, size: [0.019, 0.058, 0.026], pitch: 0.33, roll: -side * 0.28, }); } // ---- The rig -------------------------------------------------------------- /** A named, empty joint at a position in its parent's frame. */ function joint(parent: THREE.Object3D, name: string, x: number, y: number): THREE.Group { const group = new THREE.Group(); group.name = name; group.position.set(x, y, 0); parent.add(group); return group; } /** Merge a joint's parts and hang them off it, if it has any. */ function attach(target: THREE.Group, bin: MeshBin): void { if (bin.size === 0) return; target.add(bin.build(`${target.name}.mesh`)); } /** * Build one posable Optimus. * * Call this **once** and `cloneOptimus` for every figure after the first: a * clone shares every geometry and both materials, so a crowd costs draw calls * and nothing else. Whoever built the original disposes it with * `disposeOptimus`, which frees the geometry the clones are all pointing at — * so dispose last, and dispose exactly once. * * The materials come from `ctx.materials` and belong to the registry. Nothing * here disposes them. */ export function buildOptimus(ctx: AssetContext): OptimusRig { const P = ctx.parts; const skin: Skin = { shell: ctx.materials.get("paper"), frame: ctx.materials.get("screenBezel"), }; const root = new THREE.Group(); root.name = "optimus"; // Pelvis, and the legs hanging off it. The legs are children of the pelvis so // that dropping the pelvis drops the whole robot — see the header note on // `pelvis.position.y`. const pelvis = joint(root, "pelvis", 0, OPTIMUS.hipY); const pelvisBin = new MeshBin(); emitPelvis(pelvisBin, P, skin); attach(pelvis, pelvisBin); const torso = joint(pelvis, "torso", 0, OPTIMUS.waistY - OPTIMUS.hipY); const torsoBin = new MeshBin(); emitTorso(torsoBin, P, skin); attach(torso, torsoBin); const head = joint(torso, "head", 0, OPTIMUS.headY - OPTIMUS.waistY); const headBin = new MeshBin(); emitHead(headBin, P, skin); attach(head, headBin); // `side` is −1 for the robot's left and +1 for its right, which is the sign of // X: see the joint-frame note. The two limbs are mirror images in position // only — the shells themselves are symmetric, so there is no mirrored // geometry and no wound-backwards triangles to worry about. for (const side of [-1, 1]) { const suffix = side < 0 ? "L" : "R"; const hip = joint(pelvis, `hip${suffix}`, side * OPTIMUS.hipHalf, 0); const hipBin = new MeshBin(); emitThigh(hipBin, P, skin); attach(hip, hipBin); const knee = joint(hip, `knee${suffix}`, 0, OPTIMUS.kneeY - OPTIMUS.hipY); const kneeBin = new MeshBin(); emitShin(kneeBin, P, skin); attach(knee, kneeBin); const shoulder = joint( torso, `shoulder${suffix}`, side * OPTIMUS.shoulderHalf, OPTIMUS.shoulderY - OPTIMUS.waistY, ); const shoulderBin = new MeshBin(); emitUpperArm(shoulderBin, P, skin); attach(shoulder, shoulderBin); const elbow = joint(shoulder, `elbow${suffix}`, 0, OPTIMUS.elbowY - OPTIMUS.shoulderY); const elbowBin = new MeshBin(); emitForearm(elbowBin, P, skin, side); attach(elbow, elbowBin); } const rig: OptimusRig = { root, joints: optimusJoints(root) }; restOptimus(rig.joints); return rig; } /** * Resolve the joints of a rig root by name. * * This used to be exported, on the stated grounds that `Object3D.clone(true)` * "hands back plain `Object3D`s" and so a caller holding a clone would need to * re-resolve it. **That is not true of three.js and never was.** `clone` is * `new this.constructor().copy(this, recursive)` (`three/src/core/Object3D.js`), * so cloning a `Group` gives a `Group`, and a cloned rig is structurally * identical to the original down to the class of every node. Nothing outside * this file ever called it — `robots.ts` takes its joints from the `OptimusRig` * that `cloneOptimus` hands back — so the export was justified by a wrong claim * and used by nobody, and it is now private. * * The function itself is still needed, for the reason the false one was * standing in front of: `clone` copies the *tree*, and `OptimusJoints` is a * flat record of references **into** the original tree. Those references are * not part of the tree and are not cloned with it, so a clone's joints have to * be found again, and the names are the only thing that survives the copy to * find them by. * * Throws rather than returning null: a root with no `kneeL` in it is not a rig, * and the caller has nothing useful to do about that at runtime. */ function optimusJoints(root: THREE.Object3D): OptimusJoints { const found = {} as Record<(typeof JOINT_NAMES)[number], THREE.Group>; for (const name of JOINT_NAMES) { const object = root.getObjectByName(name); if (!object) throw new Error(`optimus: rig has no joint named "${name}"`); found[name] = object as THREE.Group; } return found; } /** * Put every joint back to the idle stance. Called by `buildOptimus`, and by an * animation that wants a figure to stop moving without writing the eleven * assignments out itself. */ export function restOptimus(j: OptimusJoints): void { j.pelvis.position.y = OPTIMUS.hipY; j.pelvis.rotation.set(0, 0, 0); j.torso.rotation.set(0, 0, 0); j.head.rotation.set(0, 0, 0); j.hipL.rotation.set(OPTIMUS_REST.hipX, 0, 0); j.hipR.rotation.set(OPTIMUS_REST.hipX, 0, 0); j.kneeL.rotation.set(OPTIMUS_REST.kneeX, 0, 0); j.kneeR.rotation.set(OPTIMUS_REST.kneeX, 0, 0); // The splay is mirrored: positive `rotation.z` swings a hanging arm toward // +X, so the right arm needs `+` and the left arm `−` to both move outward. j.shoulderL.rotation.set(OPTIMUS_REST.shoulderX, 0, -OPTIMUS_REST.shoulderZ); j.shoulderR.rotation.set(OPTIMUS_REST.shoulderX, 0, OPTIMUS_REST.shoulderZ); j.elbowL.rotation.set(OPTIMUS_REST.elbowX, 0, 0); j.elbowR.rotation.set(OPTIMUS_REST.elbowX, 0, 0); } /** * Another figure sharing the first one's geometry and materials. * * `Object3D.clone(true)` copies the hierarchy, the names and the transforms and * *references* geometry and material, which is exactly the sharing wanted here — * so do not dispose a clone. Dispose the original, once, with `disposeOptimus`. */ export function cloneOptimus(rig: OptimusRig): OptimusRig { const root = rig.root.clone(true); return { root, joints: optimusJoints(root) }; } /** * Free a rig's geometry. * * Materials are the registry's and are left alone, exactly as every asset in * this directory leaves them alone. Call this on the rig `buildOptimus` * returned, never on a clone, and only once everything cloned from it is out of * the scene. */ export function disposeOptimus(rig: OptimusRig): void { rig.root.traverse((child) => { const mesh = child as THREE.Mesh; if (mesh.isMesh) mesh.geometry.dispose(); }); } // ---- The static asset ----------------------------------------------------- /** * Collapse a posed rig into one mesh per material. * * The rig's own geometries are baked at their world transforms and disposed, so * what comes back owns everything it points at and can go through `MeshBin`'s * usual life cycle. This is safe only because of the primitive-class rule in the * header: `paper` is all extrusions and `screenBezel` is all indexed * primitives, so neither merge can hit the mixed-index refusal that would drop a * material and leave a robot with no shells on it. */ function flatten(rig: OptimusRig, name: string): THREE.Group { rig.root.updateMatrixWorld(true); const byMaterial = new Map(); rig.root.traverse((child) => { const mesh = child as THREE.Mesh; if (!mesh.isMesh || Array.isArray(mesh.material)) return; const baked = mesh.geometry.clone().applyMatrix4(mesh.matrixWorld); const list = byMaterial.get(mesh.material); if (list) list.push(baked); else byMaterial.set(mesh.material, [baked]); }); disposeOptimus(rig); const group = new THREE.Group(); group.name = name; for (const [material, list] of byMaterial) { const merged = list.length === 1 ? list[0] : mergeGeometries(list, false); if (list.length > 1) for (const geometry of list) geometry.dispose(); if (!merged) continue; const mesh = new THREE.Mesh(merged, material); mesh.name = `${name}:${material.name || "material"}`; mesh.castShadow = true; mesh.receiveShadow = true; group.add(mesh); } return group; } type OptimusParams = { /** Sole to crown, metres. The whole figure scales; 1.73 is the real one. */ height: number; /** * The stance to bake in. `"rest"` stands to attention, `"stride"` freezes it * mid-step so a pack can put one in a corridor without it looking parked. */ pose: "rest" | "stride"; }; /** * One Optimus, standing still, merged. * * Two draw calls, no articulation, and `furnishings.ts` instances it per mesh — * so a pack with twenty of these still pays those two. Anything that has to walk * wants `buildOptimus` instead, and pays eighteen. * * There is no `colorKey` and no tintable part. A robot that comes in a team * colour is a mascot; this is a machine, and the two things it can be are the * colour of its shells and the colour of its frame, both of which belong to the * palette rather than to one instance. */ export const robotOptimus = defineAsset({ id: "tera:robot.optimus", label: "Optimus humanoid robot", defaults: { height: OPTIMUS.height, pose: "rest" }, footprint(p) { const scale = p.height / OPTIMUS.height; // Measured off the built figure rather than guessed, and measured off the // *baked vertices* rather than off `Box3.setFromObject`, which inflates the // box of anything rotated and had this at 0.534 for a while: 0.520 m across // and 0.279 m from toe to heel, rounded up. The feet are the deepest part, // not the chest, which is what you would reach for. The width is a near tie // between the shoulder drums at 0.520 and the hands at 0.516 — the arms of // a standing humanoid are as wide as its shoulders, which is the other // thing you would get wrong — so widening either one moves this number. return { width: 0.55 * scale, depth: 0.3 * scale, height: p.height, clearance: 0.35 }; }, build(p, ctx) { const rig = buildOptimus(ctx); if (p.pose === "stride") { // A single frame of the walk cycle, written out rather than shared with // `robots.ts`: this is a fixed pose for a static prop, that is a function // of distance travelled, and coupling them would mean a pack's decorative // robot changing shape whenever somebody retunes a gait. rig.joints.hipL.rotation.x = 0.36; rig.joints.hipR.rotation.x = -0.3; rig.joints.kneeL.rotation.x = -0.12; rig.joints.kneeR.rotation.x = -0.42; rig.joints.shoulderL.rotation.x = -0.26; rig.joints.shoulderR.rotation.x = 0.28; rig.joints.elbowL.rotation.x = 0.2; rig.joints.elbowR.rotation.x = 0.34; rig.joints.torso.rotation.y = -0.07; rig.joints.pelvis.rotation.y = 0.04; // Dropped by the amount the straddle costs, and no further: the pose is // hand-written, so nothing checks that the feet reach the floor except // measuring the result. At −0.022 the leading toe was 2 mm under it. rig.joints.pelvis.position.y = OPTIMUS.hipY - 0.019; } const group = flatten(rig, "robot.optimus"); if (p.height !== OPTIMUS.height) group.scale.setScalar(p.height / OPTIMUS.height); return group; }, });