diff --git a/src/exhibits/gradient-levels/index.ts b/src/exhibits/gradient-levels/index.ts index 6d38573..8144a4d 100644 --- a/src/exhibits/gradient-levels/index.ts +++ b/src/exhibits/gradient-levels/index.ts @@ -55,6 +55,11 @@ import { } from '@/scaffold/staging/Plinth'; import { createGradientArrow, type GradientArrowHandles } from './GradientArrow'; import { GradientLevelsReadout } from './GradientLevelsReadout'; +import { + createReadoutPost, + type ReadoutPostHandles, +} from '@/scaffold/ui/ReadoutPost'; +import { READOUT_POST_LENGTH } from '@/scaffold/ui/readoutTokens'; import { BOUND, fJsRaw, gradJs } from './surfaceModel'; // Gradient + level-surfaces scene (#162 epic). Third member of the @@ -286,6 +291,7 @@ let phiSlider: Slider | undefined; let indicator: THREE.Mesh | undefined; let gradientArrow: GradientArrowHandles | undefined; let gradientLevelsReadout: GradientLevelsReadout | undefined; +let gradientLevelsReadoutPost: ReadoutPostHandles | undefined; let thetaLabel: Label | undefined; let phiLabel: Label | undefined; let kLabel: Label | undefined; @@ -450,6 +456,9 @@ const gradientLevelsExhibit: Exhibit = { magnitudeColor: YELLOW, }); + // Post-mount stem (#286). See quadrics for the architecture note. + gradientLevelsReadoutPost = createReadoutPost(); + // Per-slider labels (#170). Right-anchored so worst-case secondary // text ("−2.00" at k_min, "−0.80π" at φ extremes) stays clear of // the slider thumb at any value. Slot positions applied via the @@ -495,7 +504,8 @@ const gradientLevelsExhibit: Exhibit = { { id: 'label-theta', target: thetaLabel.group, localXYZ: [SLIDER_LABEL_X_OFFSET, PLINTH_THETA_Y, 0.001] }, { id: 'label-phi', target: phiLabel.group, localXYZ: [SLIDER_LABEL_X_OFFSET, PLINTH_PHI_Y, 0.001] }, { id: 'label-k', target: kLabel.group, localXYZ: [SLIDER_LABEL_X_OFFSET, PLINTH_K_Y, 0.001] }, - { id: 'readout', target: gradientLevelsReadout.group, localXYZ: [0, PLINTH_READOUT_Y, 0] }, + { id: 'readout', target: gradientLevelsReadout.group, localXYZ: [0, PLINTH_READOUT_Y, READOUT_POST_LENGTH] }, + { id: 'readout-post', target: gradientLevelsReadoutPost.group, localXYZ: [0, PLINTH_READOUT_Y, 0], orientation: 'surface' }, { id: 'world-axes', target: worldAxes.group, @@ -650,6 +660,8 @@ const gradientLevelsExhibit: Exhibit = { gradientArrow = undefined; gradientLevelsReadout?.dispose(); gradientLevelsReadout = undefined; + gradientLevelsReadoutPost?.dispose(); + gradientLevelsReadoutPost = undefined; thetaLabel?.dispose(); thetaLabel = undefined; phiLabel?.dispose(); diff --git a/src/exhibits/quadrics/index.ts b/src/exhibits/quadrics/index.ts index e70963e..dc27e30 100644 --- a/src/exhibits/quadrics/index.ts +++ b/src/exhibits/quadrics/index.ts @@ -14,6 +14,11 @@ import { classify, getPlanePose } from './classify'; import { createDoublePlane, type DoublePlaneHandles } from './DoublePlane'; import { EquationReadout } from './EquationReadout'; import { Label } from '@/scaffold/ui/Label'; +import { + createReadoutPost, + type ReadoutPostHandles, +} from '@/scaffold/ui/ReadoutPost'; +import { READOUT_POST_LENGTH } from '@/scaffold/ui/readoutTokens'; import { Preset, type LinearPresetValues, type PresetValues } from '@/scaffold/ui/Preset'; import { PresetTween } from '@/scaffold/anim/PresetTween'; import { createImplicitSurface } from '@/scaffold/render/ImplicitSurface'; @@ -878,6 +883,7 @@ let presetsExpanded = false; let pointers: readonly Pointer[] = []; let rackLabel: Label | undefined; let equationReadout: EquationReadout | undefined; +let equationReadoutPost: ReadoutPostHandles | undefined; let rendererInfoProbe: RendererInfoProbe | undefined; let worldAxes: WorldAxes | undefined; let camera: THREE.Camera | undefined; @@ -1244,6 +1250,14 @@ const quadricsExhibit: Exhibit = { coefficientColors: EQUATION_COEFFICIENT_COLORS, }); + // Post-mount stem (#286). Sits in its own 'surface'-oriented + // plinth slot at the same XY as the readout, anchored statically + // to the working surface; the readout slot below is lifted by + // READOUT_POST_LENGTH so the readout group origin sits at the + // post tip. Panel face still yaws to camera via the unchanged + // PanelReadout.faceCamera. + equationReadoutPost = createReadoutPost(); + // Math-frame axis indicator (#43). Uses `orientation: 'world'` // so the indicator stays math-frame-aligned regardless of the // plinth's working-surface tilt — the X/Y/Z arrows have to read @@ -1332,7 +1346,16 @@ const quadricsExhibit: Exhibit = { slots.push({ id: 'equation-readout', target: equationReadout.group, + localXYZ: [0, PLINTH_EQUATION_READOUT_Y, READOUT_POST_LENGTH], + }); + // Explicit `orientation: 'surface'` (#286 v3 second-Sonnet #1) — + // load-bearing for the post (no faceCamera overwrite); writing it + // out makes the intent visible at the slot site. + slots.push({ + id: 'equation-readout-post', + target: equationReadoutPost.group, localXYZ: [0, PLINTH_EQUATION_READOUT_Y, 0], + orientation: 'surface', }); slots.push({ id: 'rack-label', @@ -1384,6 +1407,7 @@ const quadricsExhibit: Exhibit = { if (canonicalFormsHeading) ownedDisposables.push(canonicalFormsHeading); if (rackLabel) ownedDisposables.push(rackLabel); if (equationReadout) ownedDisposables.push(equationReadout); + if (equationReadoutPost) ownedDisposables.push(equationReadoutPost); if (worldAxes) ownedDisposables.push(worldAxes); }, @@ -1658,6 +1682,7 @@ const quadricsExhibit: Exhibit = { pointers = []; rackLabel = undefined; equationReadout = undefined; + equationReadoutPost = undefined; // RendererInfoProbe holds a renderer reference for read-only stats — // no GPU resources to dispose. Reset to undefined so re-mount's // `if (isFpsOverlayEnabled())` conditional allocates fresh. diff --git a/src/exhibits/saddle-extrema/index.ts b/src/exhibits/saddle-extrema/index.ts index ce3b6f8..3d2a014 100644 --- a/src/exhibits/saddle-extrema/index.ts +++ b/src/exhibits/saddle-extrema/index.ts @@ -58,6 +58,11 @@ import { } from './GraphSurface'; import { DEFAULT_PRESET_INDEX, PRESETS } from './presets'; import { SaddleExtremaReadout } from './SaddleExtremaReadout'; +import { + createReadoutPost, + type ReadoutPostHandles, +} from '@/scaffold/ui/ReadoutPost'; +import { READOUT_POST_LENGTH } from '@/scaffold/ui/readoutTokens'; import { buildAxisSnapPoints } from './snap-points'; import { createTaylorOverlay, @@ -238,6 +243,7 @@ let stageLighting: StageLightingHandles | undefined; let plinth: PlinthHandles | undefined; let activePresetIndex = DEFAULT_PRESET_INDEX; let saddleExtremaReadout: SaddleExtremaReadout | undefined; +let saddleExtremaReadoutPost: ReadoutPostHandles | undefined; let camera: THREE.Camera | undefined; let pointers: readonly Pointer[] = []; @@ -438,6 +444,9 @@ const saddleExtremaExhibit: Exhibit = { accentColor: YELLOW, }); + // Post-mount stem (#286). See quadrics for the architecture note. + saddleExtremaReadoutPost = createReadoutPost(); + // Preset row (#178) — five archetypes left → right, mirroring the // PRESETS array order. The starter (saddle, DEFAULT_PRESET_INDEX) // is marked sticky-active so the user reads which archetype is @@ -477,7 +486,13 @@ const saddleExtremaExhibit: Exhibit = { slots.push({ id: 'readout', target: saddleExtremaReadout.group, + localXYZ: [0, PLINTH_READOUT_Y, READOUT_POST_LENGTH], + }); + slots.push({ + id: 'readout-post', + target: saddleExtremaReadoutPost.group, localXYZ: [0, PLINTH_READOUT_Y, 0], + orientation: 'surface', }); slots.push({ id: 'world-axes', @@ -593,6 +608,8 @@ const saddleExtremaExhibit: Exhibit = { presetButtons = []; saddleExtremaReadout?.dispose(); saddleExtremaReadout = undefined; + saddleExtremaReadoutPost?.dispose(); + saddleExtremaReadoutPost = undefined; if (indicator) { indicator.geometry.dispose(); (indicator.material as THREE.Material).dispose(); diff --git a/src/exhibits/tangent-planes/index.ts b/src/exhibits/tangent-planes/index.ts index 3f30657..2b9cf63 100644 --- a/src/exhibits/tangent-planes/index.ts +++ b/src/exhibits/tangent-planes/index.ts @@ -57,6 +57,11 @@ import { } from '@/scaffold/staging/Plinth'; import { createTangentPlane, type TangentPlaneHandles } from './TangentPlane'; import { TangentPlaneReadout } from './TangentPlaneReadout'; +import { + createReadoutPost, + type ReadoutPostHandles, +} from '@/scaffold/ui/ReadoutPost'; +import { READOUT_POST_LENGTH } from '@/scaffold/ui/readoutTokens'; // Tangent-planes scene (#147). First sub-issue of the #121 epic — sets up // the v0.6 scene's surface + θ/φ point selection so #148 (tangent-plane @@ -263,6 +268,7 @@ let phiSlider: Slider | undefined; let indicator: THREE.Mesh | undefined; let tangentPlane: TangentPlaneHandles | undefined; let tangentPlaneReadout: TangentPlaneReadout | undefined; +let tangentPlaneReadoutPost: ReadoutPostHandles | undefined; let thetaLabel: Label | undefined; let phiLabel: Label | undefined; let worldAxes: WorldAxes | undefined; @@ -492,6 +498,9 @@ const tangentPlanesExhibit: Exhibit = { axisColors: [VERMILLION, BLUISH_GREEN, SKY_BLUE], }); + // Post-mount stem (#286). See quadrics for the architecture note. + tangentPlaneReadoutPost = createReadoutPost(); + // Math-frame axis indicator. orientation: 'world' so the X/Y/Z // arrows read in the math frame, not the tabletop frame — // WorldAxes' faceCamera rotates only its child letter-Text nodes, @@ -528,7 +537,13 @@ const tangentPlanesExhibit: Exhibit = { { id: 'readout', target: tangentPlaneReadout.group, + localXYZ: [0, PLINTH_READOUT_Y, READOUT_POST_LENGTH], + }, + { + id: 'readout-post', + target: tangentPlaneReadoutPost.group, localXYZ: [0, PLINTH_READOUT_Y, 0], + orientation: 'surface', }, { id: 'world-axes', @@ -661,6 +676,8 @@ const tangentPlanesExhibit: Exhibit = { tangentPlane = undefined; tangentPlaneReadout?.dispose(); tangentPlaneReadout = undefined; + tangentPlaneReadoutPost?.dispose(); + tangentPlaneReadoutPost = undefined; thetaLabel?.dispose(); thetaLabel = undefined; phiLabel?.dispose(); diff --git a/src/scaffold/ui/ReadoutPost.ts b/src/scaffold/ui/ReadoutPost.ts new file mode 100644 index 0000000..2e8a80c --- /dev/null +++ b/src/scaffold/ui/ReadoutPost.ts @@ -0,0 +1,89 @@ +// ReadoutPost — thin cylindrical stem mounting a readout panel to the +// plinth working surface (#286 / two-slot architecture). Each cluster +// scene registers two plinth slots per readout: +// +// 1. A `'surface'`-oriented post slot at slot-Z = 0. The post sits +// flat on the working surface and extends along surface-normal +// (= slot-local +Z) for READOUT_POST_LENGTH. No faceCamera; the +// slot's 'surface' orientation is load-bearing — it stays +// statically tilted with the desk. +// 2. The existing readout slot at slot-Z = READOUT_POST_LENGTH — +// `computePlinthSlotTransform` lifts the readout group origin +// off the working surface by that distance along surface-normal, +// placing it at the post tip. The readout's `faceCamera` +// continues to overwrite group rotation with world-Y yaw every +// frame — unchanged from today. +// +// The post is purely visual signaling that the readout is mounted on +// the plinth, NOT a kinematic element. Panel yaws independently around +// the lifted slot point; post stays static. The panel's offset from +// the slot point (8 mm in group-local -Z) means the panel center +// orbits the post tip on an 8 mm circle in world XZ as yaw varies — +// visually invisible at panel scale (half-widths 0.20–0.38 m). +// +// Why not in scaffold/staging — the post is semantically a readout- +// mounting element, composing with the readout's group rather than the +// stage furniture. Same module home as PanelReadout; no cross-layer +// imports needed (color + dimension tokens all in readoutTokens.ts). +// +// Lifecycle (mirrors StageFloor / StageRailing / ContrastPit): +// exhibit-owned. Scene mount allocates via `createReadoutPost()`, +// passes the handles into the plinth slot manifest. Scene unmount +// disposes via `ownedDisposables`. Plinth removal in +// shell.ts:471–472 takes the group with it. + +import * as THREE from 'three'; +import { + READOUT_POST_COLOR_RGB, + READOUT_POST_LENGTH, + READOUT_POST_RADIUS, +} from './readoutTokens'; + +export interface ReadoutPostHandles { + /** Add to a plinth slot at the readout's XY with slot-Z = 0 and + * `orientation: 'surface'`. The mesh sits flat on the working + * surface and extends along surface-normal for + * READOUT_POST_LENGTH. */ + readonly group: THREE.Group; + /** Idempotent. Disposes the cylinder geometry + material. */ + dispose(): void; +} + +export function createReadoutPost(): ReadoutPostHandles { + const group = new THREE.Group(); + group.name = 'readout-post'; + + // CylinderGeometry is Y-aligned by default (height axis = +Y). The + // post sits in a 'surface'-oriented slot where slot-local +Z = + // surface normal. Rotate the mesh by π/2 about local +X so the + // cylinder's height axis maps from local +Y to local +Z. Then + // translate by +READOUT_POST_LENGTH/2 along local +Z so the mesh + // extends from local (0, 0, 0) (surface plane) to local + // (0, 0, READOUT_POST_LENGTH) (post tip). + const geometry = new THREE.CylinderGeometry( + READOUT_POST_RADIUS, + READOUT_POST_RADIUS, + READOUT_POST_LENGTH, + 12, + 1, + ); + const material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(...READOUT_POST_COLOR_RGB), + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = 'readout-post-cylinder'; + mesh.rotation.x = Math.PI / 2; + mesh.position.z = READOUT_POST_LENGTH / 2; + group.add(mesh); + + let disposed = false; + return { + group, + dispose(): void { + if (disposed) return; + disposed = true; + geometry.dispose(); + material.dispose(); + }, + }; +} diff --git a/src/scaffold/ui/readoutTokens.ts b/src/scaffold/ui/readoutTokens.ts index 204bdcf..af20074 100644 --- a/src/scaffold/ui/readoutTokens.ts +++ b/src/scaffold/ui/readoutTokens.ts @@ -53,3 +53,38 @@ export const READOUT_PANEL_COLOR_RGB = [0.08, 0.08, 0.1] as const; // overshoots, back to 13mm. Tune one dial per round; smoke on // Cloudflare PR preview. export const READOUT_PANEL_DEPTH = 0.014; + +// Post length: distance from the plinth working surface to the panel +// mount point, along the surface normal (= slot-local +Z). Lifts the +// readout off the desk so the post is a deliberate mounting element +// rather than a vestigial pin. #286 / two-slot architecture: the +// post sits in its own 'surface'-oriented plinth slot at slot-Z = 0; +// the readout slot's localXYZ[2] = READOUT_POST_LENGTH lifts the +// readout group origin to the post-tip position. +// +// First-pass: 0.12 m. Bracket [0.08, 0.18]; binary-search per +// feedback_binary_search_visual_constants if smoke reports the +// readout reads as "too floating" (→ shorter) or "stage-furniture +// rather than display" (→ longer). One dial per round. +export const READOUT_POST_LENGTH = 0.12; + +// Post radius: half-thickness of the cylindrical stem. 5 mm reads as +// "thin metal stem" without disappearing at typical viewing distance. +// First-pass smoke-tunable; bracket [0.003, 0.008]. One dial per round. +export const READOUT_POST_RADIUS = 0.005; + +// Post color: matches PLINTH_BASE_COLOR_RGB at first-pass so the post +// reads as extruded from the plinth body. Held as a SEPARATE token +// (not imported from staging/Plinth.ts) so post-color tuning doesn't +// bleed into the plinth body — if smoke flags the post as too +// prominent / invisible / disconnected, this token is the tuning +// seam. Bracket each component [0, 0.5]. Material is +// MeshStandardMaterial with defaults (roughness=1, metalness=0) +// matching the plinth body's constructor at Plinth.ts:319 exactly. +// Immutable tuple per feedback_threejs_token_exports_immutable — +// produce a fresh THREE.Color in each consumer. +export const READOUT_POST_COLOR_RGB = [ + 0x40 / 255, + 0x38 / 255, + 0x44 / 255, +] as const; diff --git a/test/scaffold/ui/ReadoutPost.test.ts b/test/scaffold/ui/ReadoutPost.test.ts new file mode 100644 index 0000000..4817589 --- /dev/null +++ b/test/scaffold/ui/ReadoutPost.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as THREE from 'three'; +import { createReadoutPost } from '../../../src/scaffold/ui/ReadoutPost.ts'; +import { + READOUT_POST_LENGTH, + READOUT_POST_RADIUS, +} from '../../../src/scaffold/ui/readoutTokens.ts'; +import { + createPlinth, + PLINTH_TILT_DEFAULT, +} from '../../../src/scaffold/staging/Plinth.ts'; + +describe('createReadoutPost (#286 / two-slot post-mount primitive)', () => { + it('returns a group with one cylinder mesh', () => { + const post = createReadoutPost(); + expect(post.group).toBeInstanceOf(THREE.Group); + expect(post.group.children).toHaveLength(1); + const child = post.group.children[0]; + expect(child).toBeInstanceOf(THREE.Mesh); + const mesh = child as THREE.Mesh; + expect(mesh.geometry).toBeInstanceOf(THREE.CylinderGeometry); + }); + + it('cylinder geometry has the expected radii + height matching tokens', () => { + const post = createReadoutPost(); + const mesh = post.group.children[0] as THREE.Mesh; + const params = mesh.geometry.parameters; + expect(params.radiusTop).toBeCloseTo(READOUT_POST_RADIUS, 6); + expect(params.radiusBottom).toBeCloseTo(READOUT_POST_RADIUS, 6); + expect(params.height).toBeCloseTo(READOUT_POST_LENGTH, 6); + }); + + it("mesh rotation aligns cylinder height axis with local +Z (rotation.x = π/2)", () => { + const post = createReadoutPost(); + const mesh = post.group.children[0] as THREE.Mesh; + expect(mesh.rotation.x).toBeCloseTo(Math.PI / 2, 6); + expect(mesh.rotation.y).toBeCloseTo(0, 6); + expect(mesh.rotation.z).toBeCloseTo(0, 6); + }); + + it('mesh position centers cylinder from local (0,0,0) to (0,0,READOUT_POST_LENGTH)', () => { + const post = createReadoutPost(); + const mesh = post.group.children[0] as THREE.Mesh; + expect(mesh.position.x).toBeCloseTo(0, 6); + expect(mesh.position.y).toBeCloseTo(0, 6); + expect(mesh.position.z).toBeCloseTo(READOUT_POST_LENGTH / 2, 6); + }); + + it('dispose() is idempotent and releases GPU resources exactly once', () => { + const post = createReadoutPost(); + const mesh = post.group.children[0] as THREE.Mesh; + const geometry = mesh.geometry; + const material = mesh.material as THREE.Material; + const geometryDispose = vi.spyOn(geometry, 'dispose'); + const materialDispose = vi.spyOn(material, 'dispose'); + + post.dispose(); + post.dispose(); // second call — no throw, no double-dispose + + expect(geometryDispose).toHaveBeenCalledTimes(1); + expect(materialDispose).toHaveBeenCalledTimes(1); + }); + + it("wiring: placed in a 'surface'-oriented plinth slot, post.group.quaternion equals R_x(-PLINTH_TILT_DEFAULT)", () => { + // Second-Sonnet #2: make the "trivially correct by construction" + // claim falsifiable here, rather than only via the existing + // Plinth.test.ts 'surface'-orientation tests. + const post = createReadoutPost(); + createPlinth({ + anchorWorldXYZ: [0, 0, 0], + slots: [ + { + id: 'p', + target: post.group, + localXYZ: [0, 0.5, 0], + orientation: 'surface', + }, + ], + }); + const expected = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), + -PLINTH_TILT_DEFAULT, + ); + // Quaternion.equals uses exact equality; compare component-wise + // with tolerance for floating-point noise from the slot transform + // composition. + expect(post.group.quaternion.x).toBeCloseTo(expected.x, 6); + expect(post.group.quaternion.y).toBeCloseTo(expected.y, 6); + expect(post.group.quaternion.z).toBeCloseTo(expected.z, 6); + expect(post.group.quaternion.w).toBeCloseTo(expected.w, 6); + }); +});