1
0

Shadows land on the building, and the sky layers stop repeating themselves

**Shadows were the right size and pointed at nothing.** Last round fixed the
missing `updateProjectionMatrix()`, so the frustum finally became the size
every caller asks for — but nothing aimed it, and `sun.target` sits at the
world origin. A pack's origin is the **north-west corner of its slab**, so
for lumbridge-hq the box was off-centre by half the building: 14.4 m of a
48 m plate, about a third of the floor, fell outside the frustum and
neither cast nor received. Invisible while three's broken ±5 default made
shadows useless everywhere; obvious the moment they started working.

`SceneKitOptions` takes a `shadowTarget` now, both callers pass one, and
the light's target is added to the scene — which is the part that actually
matters, because `LightShadow.updateMatrices` reads `target.matrixWorld`
and an unparented `Object3D` is never reached by the traversal that
updates it. The sun is also placed relative to the target rather than the
origin, so light-to-target is exactly `sunDistance` for every direction,
which is the invariant each caller's `shadowNear`/`shadowFar` were chosen
against.

**`flights.ts` could not be tested, and that is why it was untested.** It
used a TypeScript parameter property — the one piece of TS syntax that
*emits code* rather than annotating a type — so Node's type stripping
refused the whole module. The bundler never cared, so nobody found out
until the first `node --test` file tried to import it. The module carrying
the worst bug this project has shipped was, by construction, the one
module that could not have a test. It has eleven now, including one that
fails if the live-aircraft repeat-skip is removed.

**Robots are on the plan panel** — a turned marker with a bow for heading,
in the one hue left that is neither the people-blue nor the camera-amber.

Review findings cleared across the four new sky/robot modules: a real
24 mm void at the ankle and an 8 mm hole through each forearm, a
per-frame allocation in the robot heading picker, a per-frame sort in the
starlink ranking, `uTime` growing unbounded until the cloud breath
quantises, and `DAY_REFERENCE`'s derivation which did not reproduce.

`createStarlinkMeshLayer` now takes a **board** radius — the same unit its
sibling takes — instead of a dome radius with nothing in the types to tell
them apart. That is the exact confusion that has already caused one real
bug here. `DOME_RADIUS_FACTOR` has one owner and is imported, not copied:
the points and the meshes must be on the same dome or a satellite that
grows geometry also jumps.

Several comments were wrong rather than merely stale — a fabricated claim
about `Object3D.clone`, a fabricated attribution to `Plan`, an inverted
`DoubleSide` argument, a triangle ledger citing a function that no longer
exists, and a defensive-call rationale that contradicted the paragraph
above it. In a codebase where the comments are the design record, those
are defects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 02:50:25 -07:00
parent af0d4a7d57
commit 51979feea0
13 changed files with 1760 additions and 166 deletions
+369 -65
View File
@@ -28,11 +28,21 @@
* 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.
* 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.
* 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.
@@ -66,8 +76,9 @@
* 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 a flat box and not a rounded one: it is
* drawn in the dark material, so it may not be an extrusion.
* 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
@@ -112,7 +123,13 @@
*
* ### What it costs
*
* Eighteen meshes and about 7,600 triangles per figure. `buildOptimus` is
* 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
@@ -289,9 +306,16 @@ 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 runs right through and out both sides, so there is dark structure
// visible in the gap between the pelvis shell and the top of each thigh.
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.05);
// 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);
}
/**
@@ -340,40 +364,205 @@ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void {
y: shoulderY - 0.078,
size: [0.13, 0.156, 0.15],
});
// The ring the arm turns in, poking out beyond the cap.
barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.092, 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 a plain `box` 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 soft edge does not get one.
* 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.
*
* It sits about 3 mm *into* the flat middle of the face and about 1.5 mm *proud*
* of the curve at the edges, because the shell's corners round away in Z while
* the visor stays flat. That is not a compromise, it is the effect: a panel let
* into a face and wrapping round the sides of it. Sunk flush all the way across
* — which was the first version — it reads as a black rectangle painted on. The
* two small yawed side pieces finish the wrap.
* **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.120.22 to 0.0550.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 - 0.215;
bin.add(P.roundedBox(0.08), s.shell, { y: base, size: [0.165, 0.215, 0.185] });
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],
});
const visorY = base + 0.082;
bin.add(P.box(), s.frame, { y: visorY, z: -0.079, size: [0.132, 0.075, 0.02] });
for (const side of [-1, 1]) {
bin.add(P.box(), s.frame, {
x: side * 0.072,
y: visorY,
z: -0.062,
size: [0.05, 0.072, 0.018],
yaw: side * 0.62,
});
emitVisorPanel(bin, P, s, side, FACE_FLAT, FACE_MID, visorY, visorHeight, seam);
emitVisorPanel(bin, P, s, side, FACE_MID, FACE_TEMPLE, visorY, visorHeight, seam);
}
}
@@ -392,41 +581,112 @@ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void {
* 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.0900.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 ankle, dark, in the gap the shin shell leaves above the foot.
bin.add(P.box(), s.frame, { y: -drop + 0.005, size: [0.072, 0.03, 0.085] });
// 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],
});
}
/** An upper arm. Drawn in the shoulder frame; the cap is on the torso. */
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.048, size: [0.088, drop - 0.098, 0.098] });
// 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] });
}
/**
* Forearm, wrist and a five-fingered hand. Drawn in the elbow frame.
* 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.
*
* The fingers are pale rather than dark, with only the knuckle bar in the frame
* material. At ten metres a hand reads from its outline, so what matters is that
* there are five of something and that they are separate — not what colour the
* gaps between them are, which is a decision worth about forty triangles of
* detail nobody will ever resolve.
* 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
@@ -434,29 +694,54 @@ function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void {
* 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] });
bin.add(P.box(), s.frame, { y: -drop - 0.012, size: [0.062, 0.024, 0.07] });
// 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] });
// Palm, then four fingers and a thumb set off to the side and turned in.
const palmTop = -drop - 0.012;
bin.add(P.roundedBox(0.075), s.shell, { y: palmTop - 0.078, size: [0.068, 0.078, 0.032] });
for (let i = 0; i < 4; i++) {
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: (i - 1.5) * 0.017,
y: palmTop - 0.148,
size: [0.014, 0.07, 0.024],
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.03,
y: palmTop - 0.088,
z: -0.02,
size: [0.017, 0.056, 0.026],
roll: side * 0.5,
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,
});
}
@@ -558,12 +843,27 @@ export function buildOptimus(ctx: AssetContext): OptimusRig {
/**
* Resolve the joints of a rig root by name.
*
* Exported because `Object3D.clone(true)` copies names but hands back plain
* `Object3D`s with no idea which of them is a knee, so a clone has to be
* re-resolved. 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.
* 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.
*/
export function optimusJoints(root: THREE.Object3D): OptimusJoints {
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);
@@ -692,10 +992,14 @@ export const robotOptimus = defineAsset<OptimusParams>({
footprint(p) {
const scale = p.height / OPTIMUS.height;
// Measured off the built figure rather than guessed: 0.534 m across the
// splayed hands and 0.279 m from toe to heel, rounded up. The arms are the
// widest part of a standing humanoid and the feet are the deepest — not the
// shoulders and not the chest, which is what you would reach for.
// 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 };
},