diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 85ad5c5..cc57c74 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,8 +67,10 @@ lib/concepts/*.ts the written catalogue, composed by lib/manifest.ts lib/models.ts one geometry builder per scale, handed `ModelTools`. │ Builders attach geometry to concept ids via add() │ -lib/scene.ts renderer, lights, camera, picking, the explode - interpolation and the dive ramp +lib/stage-runtime.ts shared WebGL, lighting, camera and controls +lib/model-stage.ts shared posing, inventory, bounds and model disposal +lib/scene.ts ordinary explorer visibility, picking and dive ramp +lib/comparison-*.ts same-category comparison state/data and renderer │ app/ React: what the viewer is currently looking at ``` @@ -162,16 +164,22 @@ subsystems stop touching. | `lib/materials.ts` | The ten surface finishes and `temperedGlass`. | | `lib/surfaces.ts`, `pcb.ts`, `silicon-texture.ts` | Generated material maps. No downloaded textures. | | `lib/board-details.ts` | Representative support circuitry and mechanical subassemblies. | -| `lib/scene.ts` | Renderer, lights, orbit, visibility, camera fitting, the dive ramp, disposal. Returns `{ update, dispose }`. | +| `lib/stage-runtime.ts` | Shared Three.js stage foundation: renderer, environment, lighting, camera, controls, demand-driven frame scheduling, animation ticking, camera fitting, context-loss reporting, and base disposal. | +| `lib/scene.ts` | Ordinary explorer visibility, picking, camera targets, and dive ramp composed over the shared stage runtime. Returns `{ update, dispose }`. | +| `lib/model-stage.ts` | Reusable model-stage primitives shared by both renderers: scratch-backed piece posing and bounds, inventory preparation, common bounds, and resource disposal. | +| `lib/comparison-state.ts` | Category-specific comparison schemas, metadata-driven model discovery, pure comparison transitions, catalogue-backed spec projection, validation, and pane coordinate math. | +| `lib/comparison-scene.ts` | One-renderer comparison engine composed over the shared stage runtime. It renders isolated scissored panes on wide screens and one active pane below 700 CSS pixels. | | `lib/picking.ts` | `resolvePick` / `resolvePickNear` — the see-through picking policy. Deliberately outside the scene so tests can import it. | | `lib/layout.ts` | `inventoryLayout`, `hardwareInventory`, `spatialInventory`, `smoothstep`, `Vec3`. | -| `app/page.tsx` | Composition only. Which panels exist and in what order. | +| `app/page.tsx` | Chooses the explorer or comparison workbench and otherwise keeps application-level composition state. | +| `app/comparison-workbench.tsx`, `comparison-viewer.tsx`, `comparison-specs.tsx` | Comparison composition and controls, the React-to-Three bridge, and the responsive specification sheet. | | `app/use-explorer.ts` | All explorer state and every named move: navigate, dive, arrive, choose, isolate, hide, and the rest. | +| `app/use-disassembly-playback.ts` | Shared request-animation-frame playback for explorer and comparison disassembly controls. | | `app/topbar.tsx`, `scale-nav.tsx`, `systems-list.tsx`, `stage-heading.tsx`, `stage-tools.tsx`, `disassembly.tsx`, `search-dialog.tsx`, `detail-panel.tsx`, `about-dialog.tsx` | One panel each. | | `app/viewer.tsx` | The React ↔ three.js bridge: lazy scene load, hover label, error state. | | `app/links.ts` | Destinations used by more than one panel. | | `app/globals.css`, `app/workbench.css` | The visual direction, desktop through phone. | -| `tests/*.test.ts` | 52 tests: catalogue integrity, layout, picking, geometry presence, airflow. | +| `tests/*.test.ts` | 58 tests: catalogue integrity, layout, picking, geometry presence, airflow, and comparison state/data/pane/bounds behaviour. | | `scripts/generate-icons.mjs` | Rasterises `public/favicon.svg` into PNG and ICO variants. Uses Playwright and Edge. | | `scripts/generate-reference-index.mjs` | Regenerates `docs/component-references.md` from the catalogue. Run it after changing citations. | @@ -256,8 +264,8 @@ zero, heaped at the origin. `app/page.tsx` is composition and nothing else: it renders the panels in order and wires them to `useExplorer`. It holds only the state no panel owns — whether -the search palette and about dialog are open, and the part count the viewer -reports. +the app is in explorer or comparison mode, whether the search palette and +about dialog are open, and the part count the viewer reports. `app/use-explorer.ts` owns everything else about the current view and exposes it as named moves rather than state patches. A panel calls `navigate`, `dive`, @@ -292,6 +300,41 @@ Three interaction decisions are settled, and re-opening them has been tried: it off again. The switch is absent where there are no fans, and airflow follows the Cooling category — hide the fans and the arrows go with them. +## Same-category comparison mode + +Comparison is a separate workbench rather than nullable fields added to +`ExplorerState`. A typed group registry defines the supported roots, labels, +root concept ids and catalogue-backed specification rows. The current groups +are the three GPUs, two PSUs, and two processors; motherboard comparison stays +unavailable until a second motherboard model exists. Pure transitions reject +duplicate pairs and cross-group selections at the state boundary. The +catalogue validator fails when a configured root, root concept, or required +specification is missing. + +The comparison engine and ordinary explorer are separate scene coordinators, +but both compose the same `stage-runtime.ts` and `model-stage.ts` primitives. +The runtime owns one `WebGLRenderer`, canvas, scene, camera and `OrbitControls` +pair. Both selected roots sit at the same origin. At 700 CSS pixels and wider, +equal scissored render passes alternately reveal the left and right root; below +that breakpoint only the selected A/B root is rendered. Raycasting first +converts the pointer into coordinates local to its pane and then tests only +that pane's root. + +Both models use the same disassembly amount and the same camera. Each model's +posed bounds are computed through `lib/model-stage.ts`, unioned, and fitted once +for the shared camera. Physical models therefore retain their builder-derived +relative scale, while logical processor diagrams share one consistent diagram +scale and switch to a top-down inventory view. Inventory packing does not +normalize either side. The comparison renderer builds each selected model once, +disposes only a replaced side, swaps existing roots without rebuilding, and +disposes both sides plus the shared runtime resources on exit. + +The specification sheet does not store product values. Each group's row +definitions project from the existing catalogue, with small lookup adapters +only where equivalent source fields use different labels or live on a child +concept. A root product name is the exterior/reference value only when that +catalogue root does not carry an `Exterior reference` field. + ## Adding a component A component joins the written catalogue to selectable geometry through its id. @@ -356,12 +399,12 @@ the model has. 229 concepts are physical, 98 are logical diagrams. ## Tests -52 tests, all through Node's built-in runner. +57 tests, all through Node's built-in runner. `.github/workflows/ci.yml` runs `npm ci`, type checking, linting, tests, and a production build on every push and pull request with Node.js 22. -- `tests/manifest.test.ts` (15) — unique ids, reciprocal parents, no cycles, +- `tests/manifest.test.ts` (16) — unique ids, reciprocal parents, no cycles, source resolution, SKU counts against full-chip capacity, search behaviour, reachability of every scale, the three-stage dissection language, inventory packing without overlap at several aspect ratios, machine depth. @@ -382,6 +425,11 @@ production build on every push and pull request with Node.js 22. and over by a tenth, that the chevrons march and stop on command, that no stream strays far from the hardware it describes, and that the tower's air enters at the front and leaves at the back. +- `tests/comparison.test.ts` (5) — valid distinct state and group transitions, + complete catalogue-backed spec rows for every configured group, pane-local + pointer coordinates at desktop and mobile widths, and common bounds that + contain both posed models at assembled, half-disassembled, and inventory + positions. The suite builds every scale, so a geometry regression usually surfaces as a failing assertion rather than a silent visual change. diff --git a/app/comparison-specs.tsx b/app/comparison-specs.tsx new file mode 100644 index 0000000..8ee38fb --- /dev/null +++ b/app/comparison-specs.tsx @@ -0,0 +1,90 @@ +'use client'; +import { X } from 'lucide-react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from '@/components/ui/sheet'; +import { + comparisonGroups, + comparisonItem, + type ComparisonGroupId, + type ComparisonLevel, +} from '@/lib/comparison-state'; + +type Props = { + group: ComparisonGroupId; + left: ComparisonLevel; + right: ComparisonLevel; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export default function ComparisonSpecs({ + group, + left, + right, + open, + onOpenChange, +}: Props) { + const leftItem = comparisonItem(group, left), + rightItem = comparisonItem(group, right), + definition = comparisonGroups[group]; + return ( + + +
+
+ Specification comparison + + Published {definition.shortLabel} catalogue values, shown without + ranking either model. + +
+ +
+ + + + + + + + + + {leftItem.specs.map((leftSpec, index) => { + const rightSpec = rightItem.specs[index], + different = leftSpec.value !== rightSpec.value; + return ( + + + + + + ); + })} + +
Specification{leftItem.shortName}{rightItem.shortName}
{leftSpec.label}{leftSpec.value}{rightSpec.value}
+

+ Sources and modeling accuracy remain available with each model in the + explorer. +

+
+
+ ); +} diff --git a/app/comparison-viewer.tsx b/app/comparison-viewer.tsx new file mode 100644 index 0000000..d7d4595 --- /dev/null +++ b/app/comparison-viewer.tsx @@ -0,0 +1,110 @@ +'use client'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + comparisonItem, + type ComparisonViewState, +} from '@/lib/comparison-state'; +import type { createComparisonViewer } from '@/lib/comparison-scene'; + +type Props = { + state: ComparisonViewState; + onCount: (count: number) => void; +}; + +export default function ComparisonViewer({ state, onCount }: Props) { + const host = useRef(null), + engine = useRef | null>(null), + latest = useRef({ state, onCount }); + const [error, setError] = useState(''), + [hover, setHover] = useState<{ + name: string; + x: number; + y: number; + } | null>(null), + [ready, setReady] = useState(false); + const left = comparisonItem(state.group, state.left), + right = comparisonItem(state.group, state.right); + + useEffect(() => { + latest.current = { state, onCount }; + engine.current?.update(state); + }, [state, onCount]); + + useLayoutEffect(() => { + let stopped = false; + void import('@/lib/comparison-scene') + .then(({ createComparisonViewer }) => { + if (stopped || !host.current) return; + try { + engine.current = createComparisonViewer( + host.current, + latest.current.state, + { + stats: (count) => latest.current.onCount(count), + hover: (name, x, y) => setHover(name ? { name, x, y } : null), + error: setError, + }, + ); + setReady(true); + } catch { + setError( + 'WebGL could not start. Enable graphics acceleration in your browser, then reload the viewer.', + ); + } + }) + .catch(() => { + if (!stopped) + setError( + 'The comparison viewer could not load. Reload to try again.', + ); + }); + return () => { + stopped = true; + const current = engine.current; + engine.current = null; + current?.dispose(); + }; + }, []); + + return ( + <> +
+
+
+
+ {!ready && !error && ( + Loading both models… + )} + {error && ( +
+ {error} + +
+ )} + {hover && ( +
+ {hover.name} + Compared part +
+ )} + + ); +} diff --git a/app/comparison-workbench.tsx b/app/comparison-workbench.tsx new file mode 100644 index 0000000..ade8c0f --- /dev/null +++ b/app/comparison-workbench.tsx @@ -0,0 +1,230 @@ +'use client'; +import { useCallback, useMemo, useState } from 'react'; +import { ArrowLeftRight, BarChart3, ChevronLeft, Cpu } from 'lucide-react'; +import { + comparisonGroupIds, + comparisonGroups, + comparisonItem, + comparisonLevels, + initialComparisonState, + selectComparisonGroup, + selectComparisonItem, + setComparisonActiveSide, + setComparisonExplode, + setComparisonSpecsOpen, + swapComparisonSides, + type ComparisonGroupId, + type ComparisonItem, + type ComparisonLevel, + type ComparisonSide, + type ComparisonViewState, +} from '@/lib/comparison-state'; +import { isPhysical } from '@/lib/levels'; +import ComparisonViewer from './comparison-viewer'; +import ComparisonSpecs from './comparison-specs'; +import Disassembly from './disassembly'; +import { useDisassemblyPlayback } from './use-disassembly-playback'; + +type Props = { onExit: () => void }; + +export default function ComparisonWorkbench({ onExit }: Props) { + const [state, setState] = useState(initialComparisonState); + const [count, setCount] = useState(null); + const [viewerRevision, setViewerRevision] = useState(0); + const group = comparisonGroups[state.group]; + const items = useMemo( + () => + Object.fromEntries( + comparisonLevels(state.group).map((level) => [ + level, + comparisonItem(state.group, level), + ]), + ) as Partial>, + [state.group], + ); + const updateExplode = useCallback( + (explode: number) => + setState((current) => setComparisonExplode(current, explode)), + [], + ); + const { + playing, + setExplode, + stop: stopDisassembly, + toggleAuto, + } = useDisassemblyPlayback(state.explode, updateExplode); + const viewerState = useMemo( + () => ({ + group: state.group, + left: state.left, + right: state.right, + activeSide: state.activeSide, + explode: state.explode, + }), + [state.activeSide, state.explode, state.group, state.left, state.right], + ); + + const choose = (side: ComparisonSide, level: ComparisonLevel) => + setState((current) => selectComparisonItem(current, side, level)); + + const chooseGroup = (nextGroup: ComparisonGroupId) => { + stopDisassembly(); + setState((current) => selectComparisonGroup(current, nextGroup)); + }; + + const reset = () => { + stopDisassembly(); + setState((current) => ({ ...current, explode: 0 })); + setViewerRevision((revision) => revision + 1); + }; + + const selector = (side: ComparisonSide) => { + const level = state[side]; + const other = state[side === 'left' ? 'right' : 'left']; + const item = items[level]!; + return ( + + ); + }; + + const leftItem = items[state.left]!; + const rightItem = items[state.right]!; + const logical = !isPhysical(state.left); + + return ( +
+
+
+ +
+ Component comparison + {group.label} +
+
+
+ + +
+
+ +
+
+ Component category + {comparisonGroupIds.map((groupId) => ( + + ))} +
+
+ {selector('left')} + + {selector('right')} +
+
+ +
+ Visible comparison side + {(['left', 'right'] as const).map((side) => ( + + ))} +
+ + + +
+ ); +} diff --git a/app/disassembly.tsx b/app/disassembly.tsx index a8f8957..8db7935 100644 --- a/app/disassembly.tsx +++ b/app/disassembly.tsx @@ -11,6 +11,10 @@ type Props = { onToggleAuto: () => void; onSetExplode: (value: number) => void; onReset: () => void; + sectionLabel?: string; + title?: string; + sliderLabel?: string; + valueText?: string; }; export default function Disassembly({ @@ -21,11 +25,15 @@ export default function Disassembly({ onToggleAuto, onSetExplode, onReset, + sectionLabel = 'Explosion control', + title, + sliderLabel = 'Disassemble the specimen', + valueText, }: Props) { const phases = levels[level].phases, explodePercent = Math.round(explode); return ( -
+
+