From dfa3b77bc7cd74a689dcac727b89d3b42d61935f Mon Sep 17 00:00:00 2001 From: Brad Martin <1bradley.martin1@gmail.com> Date: Mon, 1 Jun 2026 09:53:46 -0600 Subject: [PATCH 1/3] feat(scaffold): extrude readout back-plate to a depth slab (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap PanelReadout's PlaneGeometry back-plate for a thin BoxGeometry extruded behind the front face by a new READOUT_PANEL_DEPTH token (8mm first-pass; bracket [4mm, 16mm] in the token doc). Subclasses keep their existing localZ contract — semantically now "front-face z" rather than "plane z" — so text-vs-panel ordering is unchanged. Why: smoke verdict on #252 / PR #269 was "moved in the right direction, but the panel still suffers from being 2D and yawing non-presence-like with the viewer." Per [[feedback_static_orientation_ for_embossed_glyph]], yaw-billboarding a depthless surface amplifies the flatness — without a depth cue the motion reads as a sliding decal, not a solid object turning. Adding real Z-depth means yaw now exposes the slab's side faces, restoring the "solid screen turning" read while preserving readability (front face still faces the user). One dial per round per [[feedback_binary_search_visual_constants]]; bezel / rim-glow / material upgrades from #270 stay deferred for later rounds if depth alone doesn't get there. --- src/exhibits/gradient-levels/SPEC.md | 4 ++- src/exhibits/quadrics/SPEC.md | 7 ++++-- src/exhibits/saddle-extrema/SPEC.md | 4 ++- src/exhibits/tangent-planes/SPEC.md | 4 ++- src/scaffold/ui/PanelReadout.ts | 36 +++++++++++++++++++-------- src/scaffold/ui/readoutTokens.ts | 13 ++++++++++ test/scaffold/ui/PanelReadout.test.ts | 19 ++++++++++---- 7 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/exhibits/gradient-levels/SPEC.md b/src/exhibits/gradient-levels/SPEC.md index 88b5a46..9a57941 100644 --- a/src/exhibits/gradient-levels/SPEC.md +++ b/src/exhibits/gradient-levels/SPEC.md @@ -494,7 +494,9 @@ ported onto the plinth. `GradientLevelsReadout` extends the shared `PanelReadout` base (`scaffold/ui/PanelReadout.ts`) which contributes the cluster-shared THREE.Group + boot-cloak + per-frame yaw `faceCamera` + dark -`MeshBasicMaterial` back-plate quad. +`MeshBasicMaterial` back-plate slab (front face flush with the original +PlaneGeometry position, extruded behind by `READOUT_PANEL_DEPTH` so +yaw-billboard motion reads as a solid screen turning — #270). Per parent plan #225 §3.5 v3 lock (option-c), the back-plate is a child of the readout's group, inheriting the yaw-billboard diff --git a/src/exhibits/quadrics/SPEC.md b/src/exhibits/quadrics/SPEC.md index 7c359bf..5abe659 100644 --- a/src/exhibits/quadrics/SPEC.md +++ b/src/exhibits/quadrics/SPEC.md @@ -574,8 +574,11 @@ enforces zero references in `.ts` files repo-wide. (`scaffold/ui/PanelReadout.ts`) which contributes (1) the THREE.Group construction + boot-cloak, (2) the per-frame yaw-only `faceCamera` billboard (formerly duplicated identical-by-copy across the four -cluster readouts), and (3) a dark `MeshBasicMaterial` back-plate quad -sized to the readout's worst-case content + padding. +cluster readouts), and (3) a dark `MeshBasicMaterial` back-plate slab +sized to the readout's worst-case content + padding (front face flush +with the original PlaneGeometry position, extruded behind by +`READOUT_PANEL_DEPTH` so yaw-billboard motion reads as a solid screen +turning, not a flat decal sliding — #270). Per parent plan #225 §3.5 v3 lock (option-c), the back-plate sits as a child of the readout's group, so it inherits the yaw-billboard diff --git a/src/exhibits/saddle-extrema/SPEC.md b/src/exhibits/saddle-extrema/SPEC.md index e7b61ea..6a1dbb2 100644 --- a/src/exhibits/saddle-extrema/SPEC.md +++ b/src/exhibits/saddle-extrema/SPEC.md @@ -833,7 +833,9 @@ ported onto the plinth. `SaddleExtremaReadout` extends the shared `PanelReadout` base (`scaffold/ui/PanelReadout.ts`) which contributes the cluster-shared THREE.Group + boot-cloak + per-frame yaw `faceCamera` + dark -`MeshBasicMaterial` back-plate quad. +`MeshBasicMaterial` back-plate slab (front face flush with the original +PlaneGeometry position, extruded behind by `READOUT_PANEL_DEPTH` so +yaw-billboard motion reads as a solid screen turning — #270). Per parent plan #225 §3.5 v3 lock (option-c), the back-plate is a child of the readout's group, inheriting the yaw-billboard diff --git a/src/exhibits/tangent-planes/SPEC.md b/src/exhibits/tangent-planes/SPEC.md index 0d95e94..81cc128 100644 --- a/src/exhibits/tangent-planes/SPEC.md +++ b/src/exhibits/tangent-planes/SPEC.md @@ -384,7 +384,9 @@ value. The pre-plinth mid-air `2.75` constant was deleted at PR2 `TangentPlaneReadout` extends the shared `PanelReadout` base (`scaffold/ui/PanelReadout.ts`) which contributes the cluster-shared THREE.Group + boot-cloak + per-frame yaw `faceCamera` + dark -`MeshBasicMaterial` back-plate quad. +`MeshBasicMaterial` back-plate slab (front face flush with the original +PlaneGeometry position, extruded behind by `READOUT_PANEL_DEPTH` so +yaw-billboard motion reads as a solid screen turning — #270). Per parent plan #225 §3.5 v3 lock (option-c), the back-plate is a child of the readout's group, inheriting the yaw-billboard diff --git a/src/scaffold/ui/PanelReadout.ts b/src/scaffold/ui/PanelReadout.ts index 2c61019..16ea840 100644 --- a/src/scaffold/ui/PanelReadout.ts +++ b/src/scaffold/ui/PanelReadout.ts @@ -13,11 +13,15 @@ // written every frame. Per parent-plan §3.5 v3 lock (option-c), // the back-plate inherits this rotation transitively as a child // of `group`, so panel + text yaw-billboard together. -// 3. Back-plate quad construction + dispose — a dark MeshBasicMaterial -// PlaneGeometry sized to the subclass-supplied worst-case text -// bounds + padding. Subclass calls createPanel(dims) once during -// its ctor after laying out text children; subclass's dispose() -// chains disposePanel() after disposing text children. +// 3. Back-plate slab construction + dispose — a dark MeshBasicMaterial +// BoxGeometry sized to the subclass-supplied worst-case text +// bounds + padding, with the front face flush at the subclass's +// requested z and the slab extruded behind it by +// READOUT_PANEL_DEPTH (#270 — gives the slab enough depth that +// yaw-billboard motion reads as a solid screen turning, not a +// flat decal sliding). Subclass calls createPanel(dims) once +// during its ctor after laying out text children; subclass's +// dispose() chains disposePanel() after disposing text children. // // What this base does NOT do: // - The text children themselves (subclass-specific layouts). @@ -29,7 +33,10 @@ // jitter. import * as THREE from 'three'; -import { READOUT_PANEL_COLOR_RGB } from './readoutTokens'; +import { + READOUT_PANEL_COLOR_RGB, + READOUT_PANEL_DEPTH, +} from './readoutTokens'; export interface PanelReadoutPanelDimensions { /** Half-width of the back-plate quad in group-local meters. */ @@ -39,7 +46,10 @@ export interface PanelReadoutPanelDimensions { /** Group-local (x, y) center of the back-plate. Defaults to (0, 0). * Used when text is offset from group origin. */ readonly center?: readonly [number, number]; - /** Recess in group-local +Z. Defaults to -0.001 m (text in front). */ + /** Front-face z (group-local +Z). The slab is extruded BEHIND this + * by READOUT_PANEL_DEPTH, so subclasses think in terms of "where + * does the screen surface sit" — the depth direction is internal. + * Defaults to -0.001 m (text in front of the screen surface). */ readonly localZ?: number; } @@ -50,7 +60,7 @@ export abstract class PanelReadout { readonly group: THREE.Group; private panel: THREE.Mesh< - THREE.PlaneGeometry, + THREE.BoxGeometry, THREE.MeshBasicMaterial > | null = null; private readonly camWorld = new THREE.Vector3(); @@ -72,9 +82,10 @@ export abstract class PanelReadout { if (this.panel !== null) { throw new Error('PanelReadout.createPanel: already created'); } - const geometry = new THREE.PlaneGeometry( + const geometry = new THREE.BoxGeometry( dims.halfWidth * 2, dims.halfHeight * 2, + READOUT_PANEL_DEPTH, ); const material = new THREE.MeshBasicMaterial({ color: new THREE.Color(...READOUT_PANEL_COLOR_RGB), @@ -86,7 +97,12 @@ export abstract class PanelReadout { // Defensive against three.js's render-order-vs-scene-graph subtlety. mesh.renderOrder = -1; const [cx, cy] = dims.center ?? [0, 0]; - mesh.position.set(cx, cy, dims.localZ ?? -0.001); + // dims.localZ is the SCREEN SURFACE z (front face). The slab's + // center sits half-a-depth behind, so the geometry extends BEHIND + // the screen surface — front-face position is unchanged from the + // old PlaneGeometry contract; text stays in front. + const frontZ = dims.localZ ?? -0.001; + mesh.position.set(cx, cy, frontZ - READOUT_PANEL_DEPTH / 2); this.group.add(mesh); this.panel = mesh; } diff --git a/src/scaffold/ui/readoutTokens.ts b/src/scaffold/ui/readoutTokens.ts index bdb9889..b4c597c 100644 --- a/src/scaffold/ui/readoutTokens.ts +++ b/src/scaffold/ui/readoutTokens.ts @@ -38,3 +38,16 @@ export const READOUT_SYNC_INTERVAL_MS = 33; // component. Immutable tuple per feedback_threejs_token_exports_ // immutable — produce a fresh THREE.Color in each consumer. export const READOUT_PANEL_COLOR_RGB = [0.08, 0.08, 0.1] as const; + +// Back-plate depth — Z-extrusion BEHIND the front face (away from the +// viewer, into the plinth) so the front face stays at the original +// plane position and text-vs-panel ordering is unchanged. Gives the +// back-plate enough physical presence that yaw-billboard motion reads +// as a solid screen turning, not a flat decal sliding (per #270 smoke +// verdict on #252 / PR #269: panel-as-flat-decal-that-tracks-you). +// First-pass at 8mm; binary-search bracket [4mm, 16mm]. Below ~4mm the +// depth cue is too subtle to register at typical viewer distance; +// above ~16mm the box can visibly poke through the plinth's working +// surface at extreme yaw angles. Tune one dial per round; smoke on +// Cloudflare PR preview. +export const READOUT_PANEL_DEPTH = 0.008; diff --git a/test/scaffold/ui/PanelReadout.test.ts b/test/scaffold/ui/PanelReadout.test.ts index c4e75ce..308cd28 100644 --- a/test/scaffold/ui/PanelReadout.test.ts +++ b/test/scaffold/ui/PanelReadout.test.ts @@ -29,6 +29,7 @@ import { import { READOUT_FONT_SIZE, READOUT_PANEL_COLOR_RGB, + READOUT_PANEL_DEPTH, } from '@/scaffold/ui/readoutTokens'; import { EquationReadout } from '@/exhibits/quadrics/EquationReadout'; import { TangentPlaneReadout } from '@/exhibits/tangent-planes/TangentPlaneReadout'; @@ -80,19 +81,26 @@ describe('PanelReadout', () => { const child = r.group.children[0]; expect(child).toBeInstanceOf(THREE.Mesh); const mesh = child as THREE.Mesh< - THREE.PlaneGeometry, + THREE.BoxGeometry, THREE.MeshBasicMaterial >; - expect(mesh.geometry).toBeInstanceOf(THREE.PlaneGeometry); + expect(mesh.geometry).toBeInstanceOf(THREE.BoxGeometry); expect(mesh.geometry.parameters.width).toBeCloseTo(0.2, 6); expect(mesh.geometry.parameters.height).toBeCloseTo(0.1, 6); + expect(mesh.geometry.parameters.depth).toBeCloseTo( + READOUT_PANEL_DEPTH, + 6, + ); expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial); const expectedColor = new THREE.Color(...READOUT_PANEL_COLOR_RGB); expect(mesh.material.color.equals(expectedColor)).toBe(true); expect(mesh.renderOrder).toBe(-1); expect(mesh.position.x).toBeCloseTo(0, 6); expect(mesh.position.y).toBeCloseTo(0, 6); - expect(mesh.position.z).toBeCloseTo(-0.001, 6); + // Box center sits half-a-depth behind the requested front-face z + // (default -0.001) so the screen surface stays where the old + // PlaneGeometry quad lived; the depth extends away from the viewer. + expect(mesh.position.z).toBeCloseTo(-0.001 - READOUT_PANEL_DEPTH / 2, 6); }); it('honors center and localZ overrides', () => { @@ -106,7 +114,8 @@ describe('PanelReadout', () => { const mesh = r.group.children[0] as THREE.Mesh; expect(mesh.position.x).toBeCloseTo(0.03, 6); expect(mesh.position.y).toBeCloseTo(-0.02, 6); - expect(mesh.position.z).toBeCloseTo(-0.005, 6); + // localZ is the front-face z; box center sits depth/2 behind it. + expect(mesh.position.z).toBeCloseTo(-0.005 - READOUT_PANEL_DEPTH / 2, 6); }); it('throws on a second call (single-shot guard)', () => { @@ -152,7 +161,7 @@ describe('PanelReadout', () => { const r = new TestReadout(); r.makePanel({ halfWidth: 0.1, halfHeight: 0.05 }); const mesh = r.group.children[0] as THREE.Mesh< - THREE.PlaneGeometry, + THREE.BoxGeometry, THREE.MeshBasicMaterial >; const geomSpy = vi.spyOn(mesh.geometry, 'dispose'); From 61698766c9eb21c44ce232fd33d2e3be5d3f52ab Mon Sep 17 00:00:00 2001 From: Brad Martin <1bradley.martin1@gmail.com> Date: Mon, 1 Jun 2026 09:58:05 -0600 Subject: [PATCH 2/3] =?UTF-8?q?feat(scaffold):=20bump=20readout=20slab=20d?= =?UTF-8?q?epth=208mm=20=E2=86=92=2012mm=20(#270=20round=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 smoke verdict on this PR: "getting there but the depth cue isn't strong enough yet." Binary-search step up within the documented bracket [4mm, 16mm]; 12mm is the upper-midpoint. Next-step rules captured in the token doc comment per the per-round bisection pattern. --- src/scaffold/ui/readoutTokens.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/scaffold/ui/readoutTokens.ts b/src/scaffold/ui/readoutTokens.ts index b4c597c..637f274 100644 --- a/src/scaffold/ui/readoutTokens.ts +++ b/src/scaffold/ui/readoutTokens.ts @@ -45,9 +45,10 @@ export const READOUT_PANEL_COLOR_RGB = [0.08, 0.08, 0.1] as const; // back-plate enough physical presence that yaw-billboard motion reads // as a solid screen turning, not a flat decal sliding (per #270 smoke // verdict on #252 / PR #269: panel-as-flat-decal-that-tracks-you). -// First-pass at 8mm; binary-search bracket [4mm, 16mm]. Below ~4mm the -// depth cue is too subtle to register at typical viewer distance; -// above ~16mm the box can visibly poke through the plinth's working -// surface at extreme yaw angles. Tune one dial per round; smoke on -// Cloudflare PR preview. -export const READOUT_PANEL_DEPTH = 0.008; +// Round 1 = 8mm; smoke verdict "getting there but not strong enough." +// Round 2 = 12mm (current). Bracket narrows to [8mm, 16mm]; if 12mm is +// still subtle, next try 14mm. If 12mm overshoots / slab visibly pokes +// through the plinth working surface at extreme yaw, back down to +// 10mm. Above ~16mm the slab is likely to clip through the plinth top +// regardless. Tune one dial per round; smoke on Cloudflare PR preview. +export const READOUT_PANEL_DEPTH = 0.012; From 6cd1ddbd6c08cbe82b3a60d93d6a05e3c09554f9 Mon Sep 17 00:00:00 2001 From: Brad Martin <1bradley.martin1@gmail.com> Date: Mon, 1 Jun 2026 10:01:35 -0600 Subject: [PATCH 3/3] =?UTF-8?q?feat(scaffold):=20bump=20readout=20slab=20d?= =?UTF-8?q?epth=2012mm=20=E2=86=92=2014mm=20(#270=20round=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 smoke verdict: "right track but still not strong enough yet." Step up within the narrowed bracket [12mm, 16mm]; 14mm is the next midpoint. Next-step rules updated in the token doc — 16mm is the hard ceiling (plinth-top clipping risk at extreme yaw), so if 14mm is still subtle that's the last step before re-evaluating whether depth alone is the right vocabulary or whether bezel / rim-glow from #270's candidate list needs to compose with it. --- src/scaffold/ui/readoutTokens.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/scaffold/ui/readoutTokens.ts b/src/scaffold/ui/readoutTokens.ts index 637f274..204bdcf 100644 --- a/src/scaffold/ui/readoutTokens.ts +++ b/src/scaffold/ui/readoutTokens.ts @@ -46,9 +46,10 @@ export const READOUT_PANEL_COLOR_RGB = [0.08, 0.08, 0.1] as const; // as a solid screen turning, not a flat decal sliding (per #270 smoke // verdict on #252 / PR #269: panel-as-flat-decal-that-tracks-you). // Round 1 = 8mm; smoke verdict "getting there but not strong enough." -// Round 2 = 12mm (current). Bracket narrows to [8mm, 16mm]; if 12mm is -// still subtle, next try 14mm. If 12mm overshoots / slab visibly pokes -// through the plinth working surface at extreme yaw, back down to -// 10mm. Above ~16mm the slab is likely to clip through the plinth top -// regardless. Tune one dial per round; smoke on Cloudflare PR preview. -export const READOUT_PANEL_DEPTH = 0.012; +// Round 2 = 12mm; smoke verdict "right track but still not strong +// enough." Round 3 = 14mm (current). Bracket narrows to [12mm, 16mm]; +// if 14mm still subtle, next try 16mm (with attention to plinth-top +// clipping at extreme yaw — that's the hard ceiling). If 14mm +// overshoots, back to 13mm. Tune one dial per round; smoke on +// Cloudflare PR preview. +export const READOUT_PANEL_DEPTH = 0.014;