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..204bdcf 100644 --- a/src/scaffold/ui/readoutTokens.ts +++ b/src/scaffold/ui/readoutTokens.ts @@ -38,3 +38,18 @@ 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). +// Round 1 = 8mm; smoke verdict "getting there but not strong enough." +// 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; 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');