Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/exhibits/gradient-levels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -650,6 +660,8 @@ const gradientLevelsExhibit: Exhibit = {
gradientArrow = undefined;
gradientLevelsReadout?.dispose();
gradientLevelsReadout = undefined;
gradientLevelsReadoutPost?.dispose();
gradientLevelsReadoutPost = undefined;
thetaLabel?.dispose();
thetaLabel = undefined;
phiLabel?.dispose();
Expand Down
25 changes: 25 additions & 0 deletions src/exhibits/quadrics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
},

Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/exhibits/saddle-extrema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions src/exhibits/tangent-planes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -661,6 +676,8 @@ const tangentPlanesExhibit: Exhibit = {
tangentPlane = undefined;
tangentPlaneReadout?.dispose();
tangentPlaneReadout = undefined;
tangentPlaneReadoutPost?.dispose();
tangentPlaneReadoutPost = undefined;
thetaLabel?.dispose();
thetaLabel = undefined;
phiLabel?.dispose();
Expand Down
89 changes: 89 additions & 0 deletions src/scaffold/ui/ReadoutPost.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
35 changes: 35 additions & 0 deletions src/scaffold/ui/readoutTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading