2026 10 22 voxel half unit - xopherdeep/do-it-for-the-xp GitHub Wiki

Voxel Half-Unit Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the shared voxel mesher linear in boxes, cap the bevel so small boxes keep their colour, show build cost in the dev VoxelLab, then re-author the hero's head, hair, hats and held gear at half-unit detail inside their present bounds.

Architecture: voxel/mesh.ts keeps its buildMeshData(boxes, occluders) contract and output but finds culling candidates through the unit-cell bucket grid it already builds for occlusion. A new pure voxel/measure.ts meshes a look without three.js and reports boxes, quads, time and a per-part signature; a perf spec pins those signatures, the quad budgets and a 4x-to-8x build-time ratio. Art lands in a second PR against pinned bounds.

Tech Stack: TypeScript, three 0.186 (geometry only; no WebGL in jest), jest 27 via chemx test, Vue 3 for the dev page.

Spec: docs/superpowers/specs/2026-10-22-voxel-half-unit-design.md

Global Constraints

  • Two PRs: PR 1 branch feat/voxel-mesher (Tasks 1-5, auto-merge), PR 2 branch feat/voxel-half-unit from master after PR 1 merges (Tasks 6-8, auto-merge only after the owner's phone check).
  • Each PR is built in its own worktree under .claude/worktrees/feat+<branch-tail> with pnpm install --prefer-offline --frozen-lockfile; builders work only in that worktree and commit only their own paths (git commit -- <paths>), never git add -A, never git stash.
  • Tools: chemx read, chemx q, chemx patch; tests only as chemx test <spec path> (add --raw to see console output); no chemx typecheck, chemx verify, pnpm or raw jest from a builder. The gate runs once per wave (Task 5 and Task 8).
  • buildMeshData(boxes, occluders) keeps its signature and, for the same input, its exact output (same quads, same order, same attributes).
  • Head block bounds stay x ±3.5, y 8..15, z ±3.5; ears may reach x ±3.9; nothing on the head part extends past z = 4.0.
  • Budgets: measureHero(DEFAULT_LOOK).quads < 1200; every BENCH_LOOKS entry < 2000 quads (ruled up from 1,000/1,400 once the occluder-inclusive counts, 602 and 1,260, were known).
  • Half-unit floor in head.ts, hair.ts, hats.ts (whole files, Task 6): a box's two larger dimensions are >= 0.5; its smallest is >= 0.5 or <= 0.25 (paint). Gear and held items: no new box thinner than 0.5 except paint (<= 0.25); existing thin boxes may stay.
  • Commit messages end with Co-Authored-By: Claude Fable 5.1 <[email protected]>.

Review Focus

  • Occluders (the torso under the head) must still shade the head's underside and must never hide a face: pinned by the existing "lets a neighbouring part shade the contact without hiding any face" test and by the Task 1 signatures (Task 2 must keep them byte-equal).
  • A box with a zero or negative dimension in a part list must still be skipped, not crash the grid: Task 2 adds a test.
  • A face whose covering box ends exactly on the plane from the near side (a neighbour beside it, not behind) must stay visible: Task 2 adds a test.
  • A hero with both hands full and a wizard hat (the heaviest look) must stay under the 1,400-quad budget after Task 6 and Task 7: Task 1's budget test covers every bench look, which includes that look.
  • A covered hair style must stay under the hat rim after re-authoring, or hair pokes through hats in the builder: Task 1 pins max y <= 14.5 for every style with covered: true.

Task 1: Measurement helper, bench looks and the pinned baselines

Files:

  • Create: src/components/organisms/HeroModel/voxel/partMesh.ts
  • Create: src/components/organisms/HeroModel/voxel/measure.ts
  • Create: src/components/organisms/HeroModel/voxel/benchLooks.ts
  • Create: src/components/organisms/HeroModel/__tests__/fixtures/voxelBaselines.ts
  • Create: src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts
  • Modify: src/components/organisms/HeroModel/voxel/build.ts (the CONTACTS map and the occluder/centring logic inside toGeometry move to partMesh.ts)

Interfaces:

  • Consumes: assembleHero(look): Part[] from voxel/assemble.ts; buildMeshData(boxes, occluders): MeshData from voxel/mesh.ts; held(icon), GEAR_BY_ICON from voxel/held.ts; hat(style, color) from voxel/hats.ts; hair(style, color, options) and HAT_COVERS_TOP from voxel/hair.ts; head(skin, face, eye) and HEAD from voxel/head.ts.

  • Produces:

    • partMesh.ts: export const CONTACTS: Partial<Record<PartName, PartName[]>> (moved verbatim from build.ts) and export const partMeshInput = (part: Part, byName: Map<PartName, Part>): { boxes: Box[]; occluders: Box[] } (boxes and occluders re-centred on part.pivot, occluders from CONTACTS).
    • measure.ts: export interface PartMeasure { boxes: number; quads: number; signature: string }, export interface HeroMeasure { boxes: number; quads: number; ms: number; parts: Record<PartName, PartMeasure> }, export const meshSignature = (data: MeshData): string (${faceCount}:${sum.toFixed(3)} with sum = Σ positions[i] * ((i % 7) + 1)), export const measureHero = (look: HeroLook): HeroMeasure (no cache, no three.js), export const formatMeasure = (m: HeroMeasure): string returning "<boxes> boxes · <quads> quads · <ms> ms" with ms to one decimal.
    • benchLooks.ts: export const BENCH_LOOKS: HeroLook[], the four looks now inlined in src/app/Dev/VoxelLab.vue (looks), in that order, plus a fifth, the heaviest: { ...DEFAULT_LOOK, outfit: "armor", hat: "wizard", hair: "long", leftHand: "shield", rightHand: "sword" }. The lab shows all five.
    • voxelBaselines.ts: export interface Bounds { min: [number, number, number]; max: [number, number, number] }, export const BENCH_SIGNATURES: Array<Record<PartName, string>> (one record per bench look), export const HAT_BOUNDS: Record<HatStyle, Bounds | null> (null for "none"), export const HELD_BOUNDS: Record<string, Bounds> keyed by every Object.keys(GEAR_BY_ICON) icon plus "sword", "shield", "wand", "book", "axe", "hammer", "bow", "potion", "torch".
  • Step 1: Move CONTACTS and the centring into partMesh.ts and make build.ts use it

toGeometry becomes voxelGeometry(meshPart(partMeshInput(part, byName))) with meshPart taking the { boxes, occluders } pair. Run chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts. Expected: PASS (pure refactor).

  • Step 2: Write measure.ts and benchLooks.ts; point VoxelLab.vue at BENCH_LOOKS

measureHero times assembleHero plus buildMeshData for each of PART_NAMES (import the array from build.ts, or move it to shape.ts next to PartName if importing build.ts drags three into the test; either is fine) with performance.now().

  • Step 3: Write the perf spec's green tests
describe("measureHero", () => {
  it("counts the default hero's boxes and quads and times the build", () => {
    const m = measureHero(DEFAULT_LOOK);
    expect(m.boxes).toBe(46);
    expect(m.quads).toBeGreaterThan(400);
    expect(m.ms).toBeGreaterThanOrEqual(0);
    expect(Object.keys(m.parts).sort()).toEqual([...PART_NAMES].sort());
    expect(formatMeasure({ boxes: 46, quads: 489, ms: 9.46, parts: m.parts })).toBe("46 boxes · 489 quads · 9.5 ms");
  });
});
describe("budgets", () => {
  it("keeps the default hero under 1,000 quads and every bench look under 1,400", () => {
    expect(measureHero(DEFAULT_LOOK).quads).toBeLessThan(1000);
    for (const look of BENCH_LOOKS) expect(measureHero(look).quads).toBeLessThan(1400);
  });
});
describe("pinned geometry", () => {
  it("meshes every bench look to its recorded per-part signature", () => {
    BENCH_LOOKS.forEach((look, i) => {
      const parts = measureHero(look).parts;
      for (const name of PART_NAMES) expect(parts[name].signature).toBe(BENCH_SIGNATURES[i][name]);
    });
  });
  it("keeps every hat and held item inside its recorded bounds", () => {
    // within(actual, recorded, 0.05): actual.min >= recorded.min - 0.05 and actual.max <= recorded.max + 0.05 on every axis
    for (const style of HAT_STYLES) { if (style === "none") continue; expect(within(boundsOf(hat(style, "#aa0000")), HAT_BOUNDS[style]!)).toBe(true); }
    for (const icon of Object.keys(HELD_BOUNDS)) expect(within(boundsOf(held(icon)!.boxes), HELD_BOUNDS[icon])).toBe(true);
  });
});
describe("head rules", () => {
  it("keeps the head inside its block, ears to 3.9, nothing past z 4.0, paint at most 0.25 thick on the face", () => {
    for (const skin of Object.keys(SKIN_TONES)) for (const face of FACE_STYLES) {
      const boxes = head(skin as SkinTone, face, "#222233");
      const b = boundsOf(boxes);
      expect(b.min[0]).toBeGreaterThanOrEqual(-3.9 - 1e-6); expect(b.max[0]).toBeLessThanOrEqual(3.9 + 1e-6);
      expect(b.min[1]).toBeGreaterThanOrEqual(8 - 1e-6);    expect(b.max[1]).toBeLessThanOrEqual(15 + 1e-6);
      expect(b.min[2]).toBeGreaterThanOrEqual(-3.5 - 1e-6); expect(b.max[2]).toBeLessThanOrEqual(4.0 + 1e-6);
      for (const box of boxes) if (box.z >= HEAD.z - 1e-6 && box.d <= 0.25 + 1e-6) expect(box.z + box.d).toBeLessThanOrEqual(HEAD.z + 0.5 + 1e-6);
    }
  });
  it("keeps covered hair under the hat rim", () => {
    for (const style of HAIR_STYLES) for (const box of hair(style, "#aa5500", { covered: true })) expect(box.y + box.h).toBeLessThanOrEqual(14.5 + 1e-6);
  });
});

boundsOf(boxes: Box[]): Bounds and within(actual: Bounds, recorded: Bounds, tolerance = 0.05): boolean are helpers in the spec file.

  • Step 4: Record the baselines

Temporarily add it("prints", () => { expect(JSON.stringify({ sig: BENCH_LOOKS.map(l => measureHero(l).parts), hats: ..., held: ... }, null, 2)).toBe(""); }), run chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts, copy the printed JSON into voxelBaselines.ts (signatures as strings, bounds rounded to 3 decimals), delete the temporary test.

  • Step 5: Run the spec and the existing one

Run: chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts and chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts. Expected: both PASS. If the head-rules test fails on today's art, the rule in the plan is wrong: report the offending box instead of changing the art.

  • Step 6: Commit
git commit -m "test(voxel): measureHero, bench looks and pinned signatures, bounds and head rules" -- src/components/organisms/HeroModel/voxel/partMesh.ts src/components/organisms/HeroModel/voxel/measure.ts src/components/organisms/HeroModel/voxel/benchLooks.ts src/components/organisms/HeroModel/voxel/build.ts src/components/organisms/HeroModel/__tests__/fixtures/voxelBaselines.ts src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts src/app/Dev/VoxelLab.vue

Task 2: Linear hidden-face culling

Files:

  • Modify: src/components/organisms/HeroModel/voxel/mesh.ts (isHidden, visibleFaces, buildMeshData; bucketed is reused)
  • Test: src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts, src/components/organisms/HeroModel/__tests__/voxel.spec.ts

Interfaces:

  • Consumes: bucketed(boxes): (min: number[], max: number[]) => Box[] already in mesh.ts (boxes whose inside overlaps the region, each reported once).

  • Produces: visibleFaces(box: Box, others: Box[]): Array<[Axis, boolean]> unchanged for callers; internally const visibleFacesNear = (box: Box, near: ReturnType<typeof bucketed>): Array<[Axis, boolean]> is the one implementation, and visibleFaces is visibleFacesNear(box, bucketed(others)). buildMeshData builds const own = bucketed(boxes) once (own boxes only, no occluders) and calls visibleFacesNear(box, own).

  • Step 1: Write the failing ratio test

/** Every box split into n x n x n equal same-colour sub-boxes */
const subdivide = (boxes: Box[], n: number): Box[] => { /* as in the spec's measurement script */ };
const buildAt = (n: number): number => {
  const parts = assembleHero(DEFAULT_LOOK);
  const t0 = performance.now();
  for (const p of parts) buildMeshData(subdivide(p.boxes, n), []);
  return performance.now() - t0;
};
describe("linear culling", () => {
  jest.setTimeout(60000);
  it("builds an 8x hero in under twenty times a 4x hero (a whole-part scan takes over seventy)", () => {
    buildAt(2); // warm the JIT
    const t4 = buildAt(4);
    const t8 = buildAt(8);
    expect(t8 / t4).toBeLessThan(20); // minimum of three timings per size on prebuilt parts; the grid lands near 9, a whole-part scan over 70
  });
});

Also add to voxel.spec.ts "box culling":

it("ignores empty boxes and keeps a face whose neighbour sits beside it, not behind it", () => {
  const a = box(0, 0, 0, 1, 1, 1, "#ff0000");
  const beside = box(1, 0, 0, 2, 1, 1, "#ff0000"); // touches a's +x face from the far side: hides it
  const flush = box(0, 0, 1, 1, 1, 2, "#ff0000");  // shares a's +z plane only by its own -z face: hides a's +z, not a's +x
  const empty = { x: 0, y: 0, z: 0, w: 0, h: 1, d: 1, color: "#ff0000" };
  expect(buildMeshData([a, beside, flush, empty]).faceCount).toBe(14);
  expect(visibleFaces(a, [a, beside, flush, empty]).map(([axis, positive]) => `${axis}${positive ? "+" : "-"}`).sort())
    .toEqual(["0-", "1+", "1-", "2-"]);
});
  • Step 2: Run both specs to verify the new tests fail or pass as expected

Run: chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts -t "linear culling". Expected: FAIL on the ratio (about 20x or more; the run takes around ten seconds). Run: chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts -t "ignores empty boxes". Expected: FAIL with 13 faces, not 14: today's scan lets the zero-width empty box hide a's -x face (a sliver hiding a face would leave a hole); the grid, which already skips empty boxes, must not.

  • Step 3: Implement visibleFacesNear and wire buildMeshData to it

For a face on axis, positive, with plane p: query near over the face's u/v rectangle and the slab [p, p + 0.01] (positive) or [p - 0.01, p] (negative), then run the existing touching-or-swallowing-and-covering test on the candidates only (drop the box itself). The rule does not change; only the candidate list does.

  • Step 4: Run all three specs

Run: chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts (ratio under 12, signatures unchanged, the run now takes about a second), chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts. Expected: PASS. A signature mismatch means either the candidate region is too small (widen it) or a real part carried an empty box that wrongly hid a face (then the face is right to appear: say which part and box in the commit message, and re-pin only that signature). Never re-pin for any other reason.

  • Step 5: Commit
git commit -m "perf(voxel): cull hidden faces through the bucket grid, linear in boxes" -- src/components/organisms/HeroModel/voxel/mesh.ts src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts src/components/organisms/HeroModel/__tests__/voxel.spec.ts

Task 3: Bevel capped by the quad

Files:

  • Modify: src/components/organisms/voxel/voxelMaterial.ts (BEVEL_RIM_FRAGMENT, a new constant)
  • Create: src/components/organisms/voxel/__tests__/voxelMaterial.spec.ts

Interfaces:

  • Produces: const BEVEL_MAX_FRACTION = 0.2 in voxelMaterial.ts; the fragment line width = min(width, vec2(0.200 * min(size.x, size.y))); directly after the existing vec2 width = max(...).

  • Step 1: Write the failing test

const compile = (options?: VoxelMaterialOptions, precision = "highp") => {
  const material = createVoxelMaterial(options);
  const shader = {
    vertexShader: "#include <common>\n#include <begin_vertex>",
    fragmentShader: "#include <common>\n#include <color_fragment>\n#include <aomap_fragment>\n#include <opaque_fragment>",
  } as THREE.WebGLProgramParametersWithUniforms;
  material.onBeforeCompile!(shader, { capabilities: { precision } } as unknown as THREE.WebGLRenderer);
  return shader;
};
it("caps the bevel at a fifth of the quad's shorter side", () => {
  expect(compile().fragmentShader).toContain("width = min(width, vec2(0.200 * min(size.x, size.y)));");
});
it("compiles no bevel at all for the simple variant or on low precision", () => {
  expect(compile({ simple: true }).fragmentShader).not.toContain("min(size.x, size.y)");
  expect(compile(undefined, "lowp").fragmentShader).not.toContain("min(size.x, size.y)");
});
  • Step 2: Run it to verify it fails

Run: chemx test src/components/organisms/voxel/__tests__/voxelMaterial.spec.ts. Expected: FAIL on the first test (string not found).

  • Step 3: Add the constant and the GLSL line

Emit the fraction with BEVEL_MAX_FRACTION.toFixed(3).

  • Step 4: Run it to verify it passes

Run: chemx test src/components/organisms/voxel/__tests__/voxelMaterial.spec.ts. Expected: PASS.

  • Step 5: Commit
git commit -m "feat(voxel): the bevel never takes more than a fifth of a small quad" -- src/components/organisms/voxel/voxelMaterial.ts src/components/organisms/voxel/__tests__/voxelMaterial.spec.ts

Task 4: Build-cost readout in the dev VoxelLab

Files:

  • Modify: src/app/Dev/VoxelLab.vue

Interfaces:

  • Consumes: measureHero, formatMeasure from voxel/measure.ts; BENCH_LOOKS from voxel/benchLooks.ts (Task 1).

  • Step 1: Add the readout under each hero of both benches

Build gearLooks from gearPairs in the script ({ ...DEFAULT_LOOK, leftHand: pair[0], rightHand: pair[1] }) and render each bench from an array of { look, label, cost } where cost = formatMeasure(measureHero(look)), computed once at setup. Under each hero a <span class="voxel-lab__cost"> shows cost; the gear bench keeps its existing item label above it. Style it like .voxel-lab__label at 11px.

  • Step 2: Check the page by hand

With the shared dev server up (curl -s localhost:8100 answers), open http://localhost:8100/#/dev/voxel-lab and ?set=gear in the built-in browser. Expected: a cost line under every hero, the default look near "46 boxes · 4xx quads".

  • Step 3: Commit
git commit -m "feat(dev): VoxelLab prints boxes, quads and build ms under each hero" -- src/app/Dev/VoxelLab.vue

Task 5: Gate and PR 1

Run once, after Tasks 1-4, in the PR 1 worktree: chemx typecheck and chemx lint. Expected: no errors (warnings tolerated). Push feat/voxel-mesher, open the PR (gh pr create, body ends with 🤖 Generated with [Claude Code](https://claude.com/claude-code)), then gh pr merge <n> --auto --squash. Read the Tests check; a red check is fixed in the worktree and pushed again.


Task 6: Head, hair and hats at half-unit (PR 2)

Files:

  • Modify: src/components/organisms/HeroModel/voxel/head.ts, voxel/hair.ts, voxel/hats.ts
  • Modify: src/components/organisms/HeroModel/__tests__/fixtures/voxelBaselines.ts (BENCH_SIGNATURES, every look's head row; HAT_BOUNDS only if a hat shrank and the old bounds are no longer the tightest, never widened)
  • Test: src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts

Interfaces:

  • Consumes: box, both, mirrorX, translate, shade, lighten, mix, PALETTE from voxel/shape.ts; HEAD from head.ts; HAT_COVERS_TOP from hair.ts.

  • Produces: the same exports with the same signatures; HEAD unchanged.

  • Step 1: Write the failing half-unit floor test

const obeysFloor = (b: Box): boolean => {
  const [a, mid] = [b.w, b.h, b.d].sort((p, q) => p - q);
  return mid >= 0.5 - 1e-6 && (a >= 0.5 - 1e-6 || a <= 0.25 + 1e-6);
};
describe("half-unit floor", () => {
  it("builds the head, every hair and every hat from half-unit boxes and quarter-unit paint", () => {
    const offenders: string[] = [];
    for (const face of FACE_STYLES) head("peach", face, "#222233").forEach((b, i) => { if (!obeysFloor(b)) offenders.push(`head/${face}#${i}`); });
    for (const style of HAIR_STYLES) for (const covered of [false, true]) hair(style, "#aa5500", { covered }).forEach((b, i) => { if (!obeysFloor(b)) offenders.push(`hair/${style}${covered ? "/covered" : ""}#${i}`); });
    for (const style of HAT_STYLES) hat(style, "#aa0000").forEach((b, i) => { if (!obeysFloor(b)) offenders.push(`hat/${style}#${i}`); });
    expect(offenders).toEqual([]);
  });
});
  • Step 2: Run it to verify it fails

Run: chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts -t "half-unit floor". Expected: FAIL listing today's thin boxes (the 0.4 ears, 0.3-tall hat and hair slabs, and others).

  • Step 3: Re-author head.ts

The head block becomes a cross of two boxes (x ±3.5 × z ±3.0 and x ±3.0 × z ±3.5, y 8..15) so the eight vertical edges are cut by half a unit; ears become 0.5 wide, starting at x 3.4 inside the block and reaching 3.9 (the bound), with a 0.5 lobe step, at most three boxes per side within y 10..12.5 and |z| <= 1; eyes, brows and mouths of all six face styles move onto half-unit steps with paint <= 0.25 deep. Keep the face on the +Z plane HEAD.z and nothing past z = 4.0. The perf spec's sanity assertion expect(m.boxes).toBe(46) on the default hero changes to the new box count in the same commit.

  • Step 4: Re-author hair.ts and hats.ts

Every slab and strip to 0.5 or paint; fringes and tufts step at 0.5; long and curly get a few 0.5 strands; each hat gets half-unit cut corners and one band or trim. Covered hair stays under y 14.5; hats keep their rim at y 14.5 and reach no higher or wider than HAT_BOUNDS.

  • Step 5: Re-pin the head signatures and run the spec

Run chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts; the pinned-geometry test prints the new head signatures in its failure. Replace only the head entries in BENCH_SIGNATURES. Run again, then chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts. Expected: everything PASS, including budgets, head rules, covered hair and hat bounds.

  • Step 6: Look at it

Open http://localhost:8100/#/dev/voxel-lab in the built-in browser and screenshot the hero bench next to a master screenshot (the dev server serves master; the worktree is judged through its own pnpm serve on another port, stopped afterwards, or by the owner on a phone). The silhouettes must read the same; the heads rounder.

  • Step 7: Commit
git commit -m "art(voxel): head, hair and hats at half-unit: cut corners, finer faces, tufts and trims" -- src/components/organisms/HeroModel/voxel/head.ts src/components/organisms/HeroModel/voxel/hair.ts src/components/organisms/HeroModel/voxel/hats.ts src/components/organisms/HeroModel/__tests__/fixtures/voxelBaselines.ts src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts

Task 7: Gear and held items at half-unit (PR 2, after Task 6)

Files:

  • Modify: src/components/organisms/HeroModel/voxel/gear.ts, voxel/held.ts
  • Modify: src/components/organisms/HeroModel/__tests__/fixtures/voxelBaselines.ts (BENCH_SIGNATURES rows handL, handR, armL, armR for the looks that hold items; HELD_BOUNDS never widened)

Interfaces:

  • Consumes: held(icon): Held | null, GEAR_BY_ICON, RAISE in held.ts; the gear builders in gear.ts (disc, grip helpers).

  • Produces: the same exports; RAISE values unchanged.

  • Step 1: Add trims at half-unit

Per item, inside its present bounds (HELD_BOUNDS): sword and axe guards, shield rim studs, bow grip wrap, book clasp, lantern frame, staff and sceptre caps, bottle cork, watch crown, mirror frame, boot straps, backpack buckle, wallet clasp. New boxes >= 0.5 on their two larger sides; paint <= 0.25.

  • Step 2: Re-pin the hand and arm signatures and run the specs

Run chemx test src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts; replace only the handL, handR, armL, armR entries that changed. Run again and chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts. Expected: PASS, including bounds and budgets.

  • Step 3: Look at it

http://localhost:8100/#/dev/voxel-lab?set=gear on the worktree's server: every item still reads as its icon, with a little more to it.

  • Step 4: Commit
git commit -m "art(voxel): held gear gets half-unit guards, straps, clasps and gems" -- src/components/organisms/HeroModel/voxel/gear.ts src/components/organisms/HeroModel/voxel/held.ts src/components/organisms/HeroModel/__tests__/fixtures/voxelBaselines.ts

Task 8: Gate, PR 2 and the owner's phone check

Run once, after Tasks 6-7: chemx typecheck, chemx lint. Push feat/voxel-half-unit, open the PR without auto-merge. Serve the worktree on a port of its own (pnpm serve --port 8101 there; stop it afterwards) and give the owner the LAN URL to /#/dev/voxel-lab for the phone check: silhouettes unchanged, heads rounder, the cost line's ms close to master's. On the owner's nod: gh pr merge <n> --auto --squash.

⚠️ **GitHub.com Fallback** ⚠️