Skip to content
Merged
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
66 changes: 57 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions app/comparison-specs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sheet open={open} modal={false} onOpenChange={onOpenChange}>
<SheetContent
className="comparison-specs"
side="right"
showCloseButton={false}
initialFocus={false}
>
<div className="comparison-specs-heading">
<div>
<SheetTitle>Specification comparison</SheetTitle>
<SheetDescription>
Published {definition.shortLabel} catalogue values, shown without
ranking either model.
</SheetDescription>
</div>
<button
aria-label="Close specification comparison"
onClick={() => onOpenChange(false)}
>
<X size={18} />
</button>
</div>
<table className="comparison-specs-grid">
<thead>
<tr className="spec-row spec-head">
<th scope="col">Specification</th>
<th scope="col">{leftItem.shortName}</th>
<th scope="col">{rightItem.shortName}</th>
</tr>
</thead>
<tbody>
{leftItem.specs.map((leftSpec, index) => {
const rightSpec = rightItem.specs[index],
different = leftSpec.value !== rightSpec.value;
return (
<tr
className="spec-row"
data-different={different || undefined}
key={leftSpec.id}
>
<th scope="row">{leftSpec.label}</th>
<td>{leftSpec.value}</td>
<td>{rightSpec.value}</td>
</tr>
);
})}
</tbody>
</table>
<p className="comparison-specs-note">
Sources and modeling accuracy remain available with each model in the
explorer.
</p>
</SheetContent>
</Sheet>
);
}
110 changes: 110 additions & 0 deletions app/comparison-viewer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null),
engine = useRef<ReturnType<typeof createComparisonViewer> | 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 (
<>
<div
ref={host}
className="comparison-viewport"
data-active-side={state.activeSide}
aria-label={`Synchronized 3D comparison of ${left.shortName} and ${right.shortName}`}
>
<section
className="comparison-pane-a11y left"
aria-label={`Left viewport: ${left.name}`}
/>
<section
className="comparison-pane-a11y right"
aria-label={`Right viewport: ${right.name}`}
/>
</div>
{!ready && !error && (
<output className="comparison-loading">Loading both models…</output>
)}
{error && (
<div className="comparison-error" role="alert">
{error}
<button onClick={() => location.reload()}>Reload viewer</button>
</div>
)}
{hover && (
<div
className="hover-label comparison-hover"
role="tooltip"
style={{
left: Math.max(8, Math.min(hover.x + 16, window.innerWidth - 220)),
top: Math.max(10, Math.min(hover.y - 48, window.innerHeight - 70)),
}}
>
{hover.name}
<span>Compared part</span>
</div>
)}
</>
);
}
Loading
Loading