From 1f1ee32002e6b6debe3273be730051622a8b073a Mon Sep 17 00:00:00 2001 From: Robin Goodwin Date: Sat, 19 Sep 2026 11:38:03 -0700 Subject: [PATCH 1/6] Document GPU comparison mode --- docs/comparison-mode-spec.md | 339 +++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/comparison-mode-spec.md diff --git a/docs/comparison-mode-spec.md b/docs/comparison-mode-spec.md new file mode 100644 index 0000000..86b34ae --- /dev/null +++ b/docs/comparison-mode-spec.md @@ -0,0 +1,339 @@ +# GPU comparison mode + +Status: Draft for maintainer review +Issue: [#8 Interactive comparison mode](https://github.com/Yoosseph/pc-anatomy/issues/8) +Proposed owner: `@rheos` + +## Summary + +Add a comparison workbench for the three existing physical GPU models. A user +chooses two different cards, sees them at true relative scale in a split view, +orbits both with one camera, and drives both through the same disassembly +timeline. A comparison panel presents the cards' key published specifications. + +The first release is intentionally GPU-only. It establishes a reusable +comparison architecture without making every scale and component family part of +the initial change. + +## Why this belongs in PC Anatomy + +The project already has three complete graphics-card models drawn at the same +millimetre scale. They can currently be explored only one at a time. Comparison +mode makes the existing work more useful by exposing differences in physical +size, cooler construction, interfaces, power, memory, and architecture without +requiring the reader to remember one card while navigating another. + +## Goals + +- Compare any two different cards from `card`, `rx9070`, and `arcb580`. +- Preserve the cards' real relative size. A smaller card must look smaller. +- Keep camera orientation, zoom, and disassembly progress synchronized. +- Use one WebGL renderer and one canvas. +- Reuse the current model builders, catalogue data, lighting, motion language, + reduced-motion behaviour, and visual design. +- Remain usable on desktop, tablet, and phone. +- Keep ordinary exploration behaviour unchanged when comparison mode is off. + +## Non-goals for version 1 + +- Comparing arbitrary components or logical chip diagrams. +- Comparing more than two cards. +- Comparing a card with itself. +- Independent cameras or independent disassembly timelines. +- Diving from a compared card into its GPU architecture. +- Per-part hiding, isolation, airflow, or detailed part inspection inside the + comparison workbench. +- Adding new card models or new technical claims. +- URL persistence, sharing, ecommerce, pricing, or PC compatibility checks. +- Introducing React Three Fiber, imported models, or runtime asset downloads. + +## User experience + +### Entry and exit + +- Add a labelled `Compare GPUs` action to the application chrome. +- Entering comparison mode opens with RTX 5090 on the left and RX 9070 XT on + the right. +- A clear `Back to explorer` action restores the normal workbench. Leaving and + returning may reset comparison state in version 1. + +### Card selection + +- Each pane has a native or Base UI select control listing the three cards. +- The card selected in the opposite pane is disabled. +- A central `Swap sides` action exchanges the two selections without changing + camera position or explosion progress. +- Each pane is visibly labelled with product name and vendor/architecture. + +### 3D interaction + +- On screens at least 700 CSS pixels wide, the stage is divided into equal left + and right viewports with a visible divider. +- Dragging, zooming, or panning in either viewport changes one shared camera, so + both cards remain at the same angle and zoom. +- A single Auto button and slider drives both cards from assembled through + dissection to inventory. +- Camera fitting uses the larger of the two posed bounds. Both panes therefore + use the same camera distance and preserve relative physical scale. +- Hover labels identify pieces under the pointer within the correct pane. +- Reduced-motion users receive immediate slider changes and no automatic + interpolation, matching the existing explorer. + +### Specifications + +- A `Specs` action opens a two-column comparison sheet. +- Rows in version 1: architecture, GPU, memory, board power, interface, physical + dimensions, and exterior/reference design. +- Values come from the existing concept catalogue. Presentation aliases may + normalize labels such as `Graphics power` and `Total board power`, but the + values themselves must not be duplicated or rewritten. +- A differing value is emphasized visually without declaring a winner. +- Source and accuracy language remains available through the existing card + catalogue rather than being copied into comparison-only content. + +### Small screens + +- Below 700 CSS pixels, render one card at a time and provide an accessible + `Left` / `Right` segmented control. This is the version 1 mobile fallback. +- Camera orientation, zoom, and explosion progress remain shared when switching + cards. +- The specifications sheet becomes a bottom sheet and remains independently + scrollable. +- Do not create two WebGL contexts or render two full card models simultaneously + on a phone merely to preserve the desktop layout. + +## Rendering architecture + +### Decision + +Use one `WebGLRenderer`, one canvas, one `Scene`, and one shared +`PerspectiveCamera`/`OrbitControls` pair. + +Both card models are attached to the scene at the same origin. On wide screens, +the renderer uses `setViewport` and `setScissor` to draw the left and right +halves. Only the model belonging to the active pane is visible during that +pane's render pass. On small screens, only the selected pane is rendered. + +This design provides: + +- one WebGL context and one environment map; +- a naturally synchronized camera; +- strict separation between panes even at full inventory spread; +- a common camera distance, preserving true relative scale; +- pane-aware raycasting without duplicating the application renderer. + +Two independent canvases/renderers are explicitly rejected because they would +duplicate GPU contexts, environment setup, resize handling, animation loops, +and mobile fill-rate cost. + +### Shared model-stage logic + +The existing scene owns model posing, inventory placement, camera fitting, +animation collection, disposal, and pointer picking inside one large closure. +Comparison mode must not copy those algorithms into a second permanent +implementation. + +Extract the smallest stable, testable primitives needed by both renderers, for +example: + +- explosion/inventory pose calculation; +- model bounds at a given explosion value; +- animation collection and batch refresh; +- model resource disposal; +- pointer-to-pane normalized coordinates. + +The ordinary `createViewer` public interface should remain unchanged unless a +small additive change is required. The comparison renderer should have its own +focused interface rather than extending `ExplorerState` with nullable fields +that ordinary exploration does not use. + +### Proposed state + +```ts +export type GpuComparisonLevel = 'card' | 'rx9070' | 'arcb580'; + +export type ComparisonSide = 'left' | 'right'; + +export type ComparisonState = { + left: GpuComparisonLevel; + right: GpuComparisonLevel; + activeSide: ComparisonSide; + explode: number; + playing: boolean; + specsOpen: boolean; +}; +``` + +State transitions such as selection, swapping, and mobile-side switching should +be pure functions where practical and covered by unit tests. Invalid identical +card pairs must be rejected or automatically resolved at the state boundary, +not patched in the UI after render. + +### Pane-aware picking + +- Determine the pane from the pointer's CSS-pixel X coordinate. +- Convert the pointer into normalized device coordinates relative to that + pane, not the full canvas. +- Raycast only against that pane's model root. +- Keep the existing mouse/touch aim tolerances and transparent-surface policy. +- Clear hover state when a selector changes, the layout switches between split + and mobile modes, or the pointer crosses the divider. + +### Resource lifetime + +- Build each selected card once and rebuild only the side whose selection + changes. +- Dispose a replaced model's geometries, materials, and generated textures. +- Dispose the second model and comparison-only controls when leaving comparison + mode. +- Do not dispose shared environment resources until the comparison renderer is + destroyed. + +## Data mapping + +Add a small comparison projection that maps a card level to its root concept +and the existing catalogue values used by the specs sheet. It may normalize +field names but should reference existing values. + +The projection should fail tests if: + +- a configured card level or root concept is missing; +- either selected level is not a physical GPU root; +- a required comparison row resolves to an empty value; +- a fourth GPU is later added without an explicit decision about comparison + support. + +## Accessibility + +- `Compare GPUs`, `Back to explorer`, `Swap sides`, selectors, pane switcher, + `Specs`, Auto, and the slider must all be keyboard reachable. +- Each viewport has an accessible name containing the selected card name. +- The visual divider is ignored by assistive technology. +- Selector changes and swaps announce the resulting pair through a polite live + region. +- The shared slider's accessible value describes both cards. +- Respect `prefers-reduced-motion` exactly as the ordinary explorer does. +- Focus returns to `Compare GPUs` when the comparison workbench closes. + +## Performance requirements + +- One renderer, one canvas, one animation loop, and one WebGL context. +- Retain the current pixel-ratio caps: 2 on desktop and 1.5 on compact/coarse + devices. +- Render two passes only in split mode. Render one pass in mobile switch mode. +- Preserve demand-driven rendering: do not run continuously when the camera, + models, and authored animations are settled. +- Record comparison draw calls, triangles, current pair, and explosion amount + on the host dataset for manual QA, following the existing viewer diagnostics. +- No new runtime network requests or asset files. + +## Proposed file changes + +Names may change during implementation, but responsibilities should remain +separated. + +- `app/page.tsx`: lift application mode and switch between explorer and + comparison workbenches. +- `app/explorer-workbench.tsx`: optional extraction of the current explorer + composition so hooks are not called conditionally. +- `app/comparison-workbench.tsx`: comparison composition and responsive mode. +- `app/comparison-controls.tsx`: card selectors, labels, swap, specs, and exit. +- `app/comparison-viewer.tsx`: React bridge for the comparison engine. +- `app/comparison-specs.tsx`: two-column specification sheet. +- `app/workbench.css`: split stage, divider, selector chrome, sheet, and mobile + switch layout. +- `lib/comparison-state.ts`: typed GPU list, pure transitions, and catalogue + projection. +- `lib/comparison-scene.ts`: one-renderer scissored comparison engine. +- `lib/model-stage.ts`: only the shared scene/model primitives that can be + extracted without weakening the existing viewer. +- `lib/scene.ts`: consume extracted primitives while preserving behaviour. +- `tests/comparison.test.ts`: state, projection, pane math, and bounds tests. +- `ARCHITECTURE.md`: document comparison mode, renderer ownership, and new + module responsibilities. + +## Acceptance criteria + +1. A user can enter and exit GPU comparison mode without reloading. +2. The initial pair is RTX 5090 versus RX 9070 XT. +3. Any two different existing GPUs can be selected. +4. Selecting the same card on both sides is impossible. +5. Both cards respond to one shared camera and one shared disassembly value. +6. Physical size differences remain visually truthful at every disassembly + position. +7. Wide screens show two isolated panes rendered by one canvas and renderer. +8. Screens below 700 CSS pixels show one switchable pane and retain state when + switching sides. +9. The specs sheet shows all required rows using existing catalogue values. +10. Hover picking resolves against the correct pane. +11. Reduced-motion behaviour, context-loss handling, and cleanup work in both + modes. +12. Normal exploration, diving, selection, airflow, hiding, and disassembly are + unchanged outside comparison mode. +13. `npm run check`, `npm run lint`, `npm test`, and `npm run build` pass. +14. `ARCHITECTURE.md` describes the final implementation and any settled + decisions that differ from this draft. + +## Test plan + +### Automated + +- Comparison state initializes with a valid distinct pair. +- Selecting, swapping, and mobile-side switching preserve unrelated state. +- All three pair combinations resolve complete comparison rows. +- Card dimensions remain sourced from the existing catalogue. +- Pane coordinate conversion maps left/right edges and divider boundaries + correctly. +- Common camera-fit bounds contain both posed models at 0, 50, and 100 percent. +- Existing model and GPU tests continue to pass after shared logic extraction. +- Type checking, lint, full tests, and production build pass in CI. + +### Manual + +- Desktop Chrome, Firefox, Safari, and Edge at common laptop dimensions. +- Tablet portrait and landscape. +- Phone portrait and short landscape, including coarse-pointer behaviour. +- Mouse orbit, wheel zoom, right-drag pan, touch rotate, and pinch zoom. +- Divider-boundary hovering and selection changes while a card is exploded. +- Repeatedly enter/exit comparison mode and change both cards while monitoring + console errors and WebGL resource behaviour. +- Reduced motion and forced WebGL context loss. +- Compare dataset draw calls and triangle counts across all three pairs. + +## Delivery sequence + +1. Confirm this scope and UX with the maintainer. +2. Add pure comparison state/data projection and tests. +3. Extract the minimum shared model-stage primitives with all existing tests + green. +4. Build the scissored comparison renderer and shared controls. +5. Add specification sheet and responsive/mobile behaviour. +6. Run cross-browser and performance QA, then update architecture docs. +7. Capture a short comparison demo for the pull request. + +## Risks and mitigations + +- **Regression in the existing viewer.** Keep extraction small, retain the + current `createViewer` API, and run existing tests after each extraction. +- **False visual size comparison.** Use one camera projection and a common fit + distance, never independently auto-fit each pane. +- **Full-inventory clipping.** Compute common bounds from both posed models at + the current explosion value. +- **Pointer errors around the divider.** Keep pane coordinate conversion pure + and unit tested. +- **Mobile GPU load.** Render only the active side below the breakpoint. +- **Resource leaks when changing cards.** Centralize model disposal and include + repeated selector changes in manual QA. +- **Scope expansion into arbitrary comparison.** Keep the public type limited + to the three physical GPU root levels in version 1. + +## Maintainer decisions requested + +1. Confirm GPU-only comparison for version 1. +2. Confirm one shared camera and shared disassembly timeline. +3. Confirm the mobile switcher instead of simultaneously rendering two panes. +4. Confirm that part inspection and dives remain in the normal explorer for + version 1. +5. Confirm placement of the `Compare GPUs` entry action in the application + chrome. + From 1a506b2f0524b3cca0278d36e7939897c519ad85 Mon Sep 17 00:00:00 2001 From: Robin Goodwin Date: Sat, 19 Sep 2026 12:07:06 -0700 Subject: [PATCH 2/6] Add interactive GPU comparison mode --- ARCHITECTURE.md | 56 ++- app/comparison-specs.tsx | 84 +++++ app/comparison-viewer.tsx | 108 ++++++ app/comparison-workbench.tsx | 209 ++++++++++++ app/disassembly.tsx | 16 +- app/page.tsx | 17 + app/topbar.tsx | 19 +- app/workbench.css | 635 +++++++++++++++++++++++++++++++++++ lib/comparison-scene.ts | 518 ++++++++++++++++++++++++++++ lib/comparison-state.ts | 218 ++++++++++++ lib/model-stage.ts | 156 +++++++++ lib/scene.ts | 79 ++--- tests/comparison.test.ts | 163 +++++++++ 13 files changed, 2208 insertions(+), 70 deletions(-) create mode 100644 app/comparison-specs.tsx create mode 100644 app/comparison-viewer.tsx create mode 100644 app/comparison-workbench.tsx create mode 100644 lib/comparison-scene.ts create mode 100644 lib/comparison-state.ts create mode 100644 lib/model-stage.ts create mode 100644 tests/comparison.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 85ad5c5..4a94db7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,8 +67,9 @@ 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/scene.ts ordinary explorer renderer, lights, camera and dive ramp +lib/model-stage.ts shared posing, inventory, bounds and model disposal +lib/comparison-*.ts GPU comparison state/data and the scissored renderer │ app/ React: what the viewer is currently looking at ``` @@ -162,16 +163,20 @@ 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/scene.ts` | Ordinary explorer renderer, lights, orbit, visibility, camera fitting and the dive ramp. Returns `{ update, dispose }`. | +| `lib/model-stage.ts` | Reusable model-stage primitives shared by both renderers: piece destinations and posture, inventory preparation, posed/common bounds, and resource disposal. | +| `lib/comparison-state.ts` | The three supported GPU roots, pure comparison transitions, catalogue-backed spec projection, validation, and pane coordinate math. | +| `lib/comparison-scene.ts` | One-renderer comparison engine. Owns the shared camera and controls, 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/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` | 57 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 +261,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 GPU 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 +297,35 @@ 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. +## GPU comparison mode + +Comparison is a separate workbench rather than nullable fields added to +`ExplorerState`. `ComparisonState` admits only `card`, `rx9070`, and `arcb580`, +and pure transitions reject a duplicate left/right pair at the state boundary. +Its catalogue validator also fails if the set of physical GPU roots changes, +so a future fourth card requires an explicit support decision. + +The comparison engine owns one `WebGLRenderer`, canvas, scene, camera and +`OrbitControls` pair. Both card 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, so the cards keep their millimetre-derived relative +scale. Inventory packing retains each card's physical sizes instead of +normalizing 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 renderer resources on exit. + +The specification sheet does not store product values. It projects the seven +rows from the existing root concepts, normalizes the two existing power labels, +and reads the RTX interface from its existing PCIe edge-connector 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 +390,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 +416,10 @@ 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 transitions, + complete catalogue-backed spec rows for all three cards, 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..199cd49 --- /dev/null +++ b/app/comparison-specs.tsx @@ -0,0 +1,84 @@ +'use client'; +import { X } from 'lucide-react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from '@/components/ui/sheet'; +import { + comparisonCard, + type GpuComparisonLevel, +} from '@/lib/comparison-state'; + +type Props = { + left: GpuComparisonLevel; + right: GpuComparisonLevel; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export default function ComparisonSpecs({ + left, + right, + open, + onOpenChange, +}: Props) { + const leftCard = comparisonCard(left), + rightCard = comparisonCard(right); + return ( + + +
+
+ Specification comparison + + Published catalogue values, shown without ranking either card. + +
+ +
+ + + + + + + + + + {leftCard.specs.map((leftSpec, index) => { + const rightSpec = rightCard.specs[index], + different = leftSpec.value !== rightSpec.value; + return ( + + + + + + ); + })} + +
Specification{leftCard.shortName}{rightCard.shortName}
{leftSpec.label}{leftSpec.value}{rightSpec.value}
+

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

+
+
+ ); +} diff --git a/app/comparison-viewer.tsx b/app/comparison-viewer.tsx new file mode 100644 index 0000000..2a47e52 --- /dev/null +++ b/app/comparison-viewer.tsx @@ -0,0 +1,108 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import { comparisonCard, type ComparisonState } from '@/lib/comparison-state'; +import type { createComparisonViewer } from '@/lib/comparison-scene'; + +type Props = { + state: ComparisonState; + 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 = comparisonCard(state.left), + right = comparisonCard(state.right); + + useEffect(() => { + latest.current = { state, onCount }; + engine.current?.update(state); + }, [state, onCount]); + + useEffect(() => { + 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; + engine.current?.dispose(); + engine.current = null; + }; + }, []); + + return ( + <> +
+
+
+
+ {!ready && !error && ( + + Loading both graphics cards… + + )} + {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..9f3f00d --- /dev/null +++ b/app/comparison-workbench.tsx @@ -0,0 +1,209 @@ +'use client'; +import { useEffect, useMemo, useState } from 'react'; +import { ArrowLeftRight, BarChart3, ChevronLeft, Cpu } from 'lucide-react'; +import { + comparisonCard, + gpuComparisonLevels, + initialComparisonState, + selectComparisonGpu, + setComparisonActiveSide, + setComparisonExplode, + setComparisonPlaying, + setComparisonSpecsOpen, + swapComparisonSides, + type ComparisonSide, + type GpuComparisonLevel, +} from '@/lib/comparison-state'; +import ComparisonViewer from './comparison-viewer'; +import ComparisonSpecs from './comparison-specs'; +import Disassembly from './disassembly'; + +type Props = { onExit: () => void }; + +export default function ComparisonWorkbench({ onExit }: Props) { + const [state, setState] = useState(initialComparisonState), + [count, setCount] = useState(null), + [viewerRevision, setViewerRevision] = useState(0); + const cards = useMemo( + () => + Object.fromEntries( + gpuComparisonLevels.map((level) => [level, comparisonCard(level)]), + ), + [], + ) as Record>; + + useEffect(() => { + if (!state.playing) return; + let frame = 0, + last = performance.now(); + const step = (now: number) => { + const delta = Math.min(0.1, (now - last) / 1000); + last = now; + setState((current) => { + const explode = Math.min(100, current.explode + delta * 11); + return { + ...current, + explode, + playing: explode < 100, + }; + }); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [state.playing]); + + const choose = (side: ComparisonSide, level: GpuComparisonLevel) => + setState((current) => selectComparisonGpu(current, side, level)); + + const toggleAuto = () => { + if (state.playing) { + setState((current) => setComparisonPlaying(current, false)); + return; + } + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + setState((current) => setComparisonExplode(current, 100)); + return; + } + setState((current) => + setComparisonPlaying( + current.explode >= 99 ? { ...current, explode: 0 } : current, + true, + ), + ); + }; + + const reset = () => { + setState((current) => ({ ...current, explode: 0, playing: false })); + setViewerRevision((revision) => revision + 1); + }; + + const selector = (side: ComparisonSide) => { + const level = state[side], + other = state[side === 'left' ? 'right' : 'left'], + card = cards[level]; + return ( + + ); + }; + + return ( +
+
+
+ +
+ GPU comparison + PC Anatomy +
+
+
+ + +
+
+ +
+ {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 ( -
+
svg { + width: 34px; + height: 34px; + padding: 7px; + color: #152226; + background: var(--accent); + border-radius: 6px; +} +.comparison-brand strong, +.comparison-brand span { + display: block; +} +.comparison-brand strong { + color: #f2f4f5; + font-size: 16px; + letter-spacing: 0.15px; +} +.comparison-brand span { + margin-top: 3px; + color: #888d94; + font: 9px monospace; + letter-spacing: 1.2px; + text-transform: uppercase; +} +.comparison-top-actions { + display: flex; + align-items: center; + gap: 9px; +} +.comparison-specs-button, +.comparison-exit { + gap: 7px; + min-height: 36px; + padding: 0 12px; + border-radius: 5px; + font-size: 12px; +} +.comparison-specs-button { + color: #d4f0f4; + background: #1c3035; + border: 1px solid #497b86; +} +.comparison-exit { + color: #c3c7cc; + border: 1px solid #45484d; +} +.comparison-specs-button:hover, +.comparison-exit:hover { + color: #fff; + background: #292c30; +} +.comparison-setup { + position: absolute; + inset: 76px 0 auto; + z-index: 10; + height: 94px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: stretch; + background: #17191b; + border-bottom: 1px solid #34373b; +} +.comparison-selector { + position: relative; + min-width: 0; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + grid-template-rows: auto auto; + align-content: center; + column-gap: 10px; + padding: 15px clamp(18px, 4vw, 56px); +} +.comparison-selector > span { + grid-row: 1 / 3; + align-self: center; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + color: #0f2226; + background: var(--accent); + font: 700 11px monospace; +} +.comparison-selector select { + width: 100%; + min-width: 0; + height: 34px; + padding: 0 30px 0 10px; + color: #eef0f1; + background: #24272a; + border: 1px solid #4b4f54; + border-radius: 4px; + font: + 600 13px 'Segoe UI', + Arial, + sans-serif; + color-scheme: dark; +} +.comparison-selector select:hover { + border-color: #73787f; +} +.comparison-selector select:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 3px; +} +.comparison-selector small { + margin-top: 6px; + overflow: hidden; + color: #8f949b; + font: 9px monospace; + letter-spacing: 0.75px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.comparison-swap { + align-self: center; + flex-direction: column; + gap: 4px; + width: 72px; + height: 58px; + color: #bfc4ca; + border: 1px solid #474b50; + border-radius: 6px; + background: #121416; + font-size: 9px; +} +.comparison-swap svg { + color: var(--accent-bright); +} +.comparison-swap:hover { + background: #282b2f; + border-color: #666b72; +} +.comparison-viewport { + position: absolute; + inset: 170px 0 112px; + touch-action: none; +} +.comparison-viewport canvas { + display: block; + width: 100%; + height: 100%; + outline: none; +} +.comparison-pane-a11y { + position: absolute; + inset-block: 0; + width: 50%; + pointer-events: none; +} +.comparison-pane-a11y.left { + left: 0; +} +.comparison-pane-a11y.right { + right: 0; +} +.comparison-divider { + position: absolute; + z-index: 4; + top: 170px; + bottom: 112px; + left: 50%; + width: 1px; + background: linear-gradient(#4c5158, #30343a 78%, #4c5158); + pointer-events: none; +} +.comparison-divider::before { + content: ''; + position: absolute; + top: 22px; + left: -2px; + width: 5px; + height: 34px; + border-radius: 3px; + background: #6e747d; +} +.comparison-pane-label { + position: absolute; + z-index: 5; + top: 190px; + display: flex; + align-items: center; + gap: 9px; + padding: 7px 10px; + color: #cbd0d5; + background: #111315d9; + border: 1px solid #3c4146; + border-radius: 4px; + pointer-events: none; +} +.comparison-pane-label.left { + left: 22px; +} +.comparison-pane-label.right { + left: calc(50% + 22px); +} +.comparison-pane-label span { + color: var(--accent-bright); + font: 700 10px monospace; +} +.comparison-pane-label strong { + font-size: 11px; + font-weight: 600; +} +.comparison-mobile-switch { + display: none; +} +.comparison-count { + position: absolute; + z-index: 5; + right: 20px; + bottom: 132px; + padding: 7px 9px; + color: #858b92; + background: #111315d9; + border-radius: 4px; + font: 9px monospace; + pointer-events: none; +} +.comparison-count strong { + color: #d9dde0; + font-size: 11px; +} +.comparison-workbench .disassembly { + left: 0; +} +.comparison-loading, +.comparison-error { + position: absolute; + z-index: 6; + top: 48%; + left: 50%; + max-width: 390px; + transform: translate(-50%, -50%); + color: #c9cdd1; + text-align: center; + font-size: 14px; +} +.comparison-error { + padding: 20px; + background: #1c1d1f; + border: 1px solid #7e6650; +} +.comparison-error button { + display: block; + margin: 14px auto 0; + padding: 8px 12px; + color: var(--accent-bright); + border: 1px solid #568995; + border-radius: 4px; +} +.comparison-hover { + border-left-width: 1px; +} +.comparison-specs[data-slot='sheet-content'] { + position: fixed; + inset: calc(76px + var(--safe-t)) 0 calc(112px + var(--safe-b)) auto; + z-index: 30; + width: min(620px, 48vw); + max-width: none; + height: auto; + padding: 26px 26px 30px; + gap: 0; + overflow-y: auto; + color: #e5e7e9; + background: #17191b; + border: 0; + border-left: 1px solid #4b4e53; + border-radius: 0; + scrollbar-color: #5b6066 transparent; + scrollbar-width: thin; +} +.comparison-specs-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 24px; +} +.comparison-specs-heading [data-slot='sheet-title'] { + color: #fff; + font-size: 25px; + font-weight: 450; + letter-spacing: -0.5px; +} +.comparison-specs-heading [data-slot='sheet-description'] { + margin-top: 7px; + color: #a7abb1; + line-height: 1.45; +} +.comparison-specs-heading button { + padding: 6px; + color: #b5b9be; +} +.comparison-specs-grid { + width: 100%; + min-width: 0; + border-collapse: collapse; + table-layout: fixed; + border-top: 1px solid #4b4e53; +} +.spec-row { + border-bottom: 1px solid #34373b; +} +.spec-row > th, +.spec-row > td { + padding: 13px 7px; + text-align: left; + vertical-align: top; +} +.spec-row > th:first-child { + width: 28%; + color: #969ba2; + font-size: 11px; + font-weight: 400; +} +.spec-row > td { + color: #d8dbde; + font-size: 12px; + font-weight: 500; + line-height: 1.45; +} +.spec-row[data-different] > td { + color: #d9f0f3; +} +.spec-head { + background: #1d2023; +} +.spec-head > th, +.spec-head > th:first-child { + padding-block: 10px; + color: #afb4ba; + font: 9px monospace; + letter-spacing: 0.7px; + text-transform: uppercase; +} +.comparison-specs-note { + max-width: 62ch; + margin: 22px 0 0; + color: #8e939a; + font-size: 11px; + line-height: 1.55; +} + +@media (max-width: 1100px) { + .compare-button > span { + display: none; + } + .compare-button { + width: 36px; + padding: 0; + } +} + +@media (max-width: 699px) { + .comparison-topbar { + height: 64px; + padding: 0 14px; + } + .comparison-brand { + gap: 8px; + } + .comparison-brand > svg { + width: 31px; + height: 31px; + } + .comparison-brand strong { + font-size: 14px; + } + .comparison-brand span { + display: none; + } + .comparison-top-actions { + gap: 5px; + } + .comparison-specs-button, + .comparison-exit { + min-height: 34px; + padding: 0 8px; + } + .comparison-exit { + width: 34px; + font-size: 0; + } + .comparison-setup { + inset-block-start: 64px; + height: 98px; + } + .comparison-selector { + grid-template-columns: 22px minmax(0, 1fr); + column-gap: 6px; + padding: 11px 8px; + } + .comparison-selector > span { + width: 21px; + height: 21px; + font-size: 9px; + } + .comparison-selector select { + height: 32px; + padding-inline: 7px 20px; + font-size: 11px; + } + .comparison-selector small { + font-size: 9px; + letter-spacing: 0.35px; + } + .comparison-swap { + width: 38px; + height: 52px; + } + .comparison-swap span { + display: none; + } + .comparison-mobile-switch { + position: absolute; + z-index: 9; + top: 162px; + left: 50%; + display: flex; + width: min(280px, calc(100vw - 36px)); + height: 38px; + padding: 3px; + transform: translateX(-50%); + background: #17191b; + border: 1px solid #45494e; + border-radius: 6px; + } + .comparison-mobile-switch button { + flex: 1; + border-radius: 3px; + color: #8f949b; + font: 10px monospace; + } + .comparison-mobile-switch button[aria-pressed='true'] { + color: #edf7f8; + background: #3a4b4f; + } + .comparison-viewport { + inset: 200px 0 142px; + } + .comparison-pane-a11y { + inset: 0; + width: 100%; + } + .comparison-pane-a11y.left, + .comparison-pane-a11y.right { + left: 0; + right: 0; + } + .comparison-viewport[data-active-side='left'] .comparison-pane-a11y.right, + .comparison-viewport[data-active-side='right'] .comparison-pane-a11y.left { + display: none; + } + .comparison-divider { + display: none; + } + .comparison-pane-label { + top: 214px; + } + .comparison-pane-label.left, + .comparison-pane-label.right { + left: 14px; + } + .comparison-pane-label.right { + display: none; + } + .comparison-workbench[data-active-side='right'] .comparison-pane-label.left { + display: none; + } + .comparison-workbench[data-active-side='right'] .comparison-pane-label.right { + display: flex; + } + .comparison-count { + right: 12px; + bottom: 154px; + } + .comparison-specs[data-slot='sheet-content'] { + inset: auto 0 0; + width: 100%; + max-width: none; + height: min(70dvh, 620px); + padding: 20px 18px calc(26px + var(--safe-b)); + border-left: 0; + border-top: 1px solid #5b6066; + border-radius: 10px 10px 0 0; + } + .comparison-specs-heading { + margin-bottom: 18px; + } + .comparison-specs-heading [data-slot='sheet-title'] { + font-size: 21px; + } + .spec-row > th, + .spec-row > td { + padding: 11px 5px; + } + .spec-row > td { + font-size: 11px; + } + .spec-row > th:first-child { + font-size: 10px; + } +} + +@media (max-height: 500px) and (max-width: 900px) { + .comparison-topbar { + height: 52px; + } + .comparison-brand > svg, + .comparison-brand span, + .comparison-specs-button span { + display: none; + } + .comparison-specs-button { + width: 34px; + padding: 0; + font-size: 0; + } + .comparison-setup { + inset-block-start: 52px; + height: 72px; + } + .comparison-selector { + padding-block: 6px; + } + .comparison-selector small { + display: none; + } + .comparison-swap { + height: 42px; + } + .comparison-mobile-switch { + top: 124px; + height: 34px; + } + .comparison-viewport { + inset: 158px 0 88px; + } + .comparison-pane-label { + top: 166px; + } + .comparison-count { + bottom: 96px; + } + .comparison-workbench .disassembly { + height: 88px; + padding: 10px 16px; + grid-template-columns: auto minmax(130px, 1fr) auto; + gap: 12px; + } + .comparison-workbench .disassembly-intro { + grid-column: 1; + grid-row: 1; + } + .comparison-workbench .disassembly-label, + .comparison-workbench .phase-labels, + .comparison-workbench .ruler { + display: none; + } + .comparison-workbench .timeline { + grid-column: 2; + grid-row: 1; + } + .comparison-workbench .disassembly-readout { + grid-column: 3; + grid-row: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .comparison-workbench *, + .comparison-workbench *::before, + .comparison-workbench *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/lib/comparison-scene.ts b/lib/comparison-scene.ts new file mode 100644 index 0000000..ad8eef5 --- /dev/null +++ b/lib/comparison-scene.ts @@ -0,0 +1,518 @@ +import * as T from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; +import { + comparisonPanePoint, + type ComparisonSide, + type ComparisonState, + type GpuComparisonLevel, +} from './comparison-state.ts'; +import { byId } from './manifest.ts'; +import { + commonModelBounds, + disposeModelResources, + laidOutAmount, + pieceDestination, + posedModelBounds, + prepareModelInventory, + type BuiltModel, +} from './model-stage.ts'; +import { buildModel, refreshBatches } from './models.ts'; +import { resolvePickNear } from './picking.ts'; + +const BREAKPOINT = 700, + MOUSE_AIM = 8; + +export type ComparisonSceneCallbacks = { + hover: (name: string | null, x: number, y: number) => void; + stats: (visible: number) => void; + error: (message: string) => void; +}; + +type PaneModel = { + level: GpuComparisonLevel; + model: BuiltModel; + layoutSignature: string; +}; + +export function createComparisonViewer( + host: HTMLDivElement, + initial: ComparisonState, + callbacks: ComparisonSceneCallbacks, +) { + const scene = new T.Scene(); + const compact = matchMedia('(pointer: coarse)').matches || innerWidth < 900, + maxPixelRatio = compact ? 1.5 : 2, + shadowSize = compact ? 1024 : 2048; + const renderer = new T.WebGLRenderer({ + antialias: true, + alpha: true, + powerPreference: 'high-performance', + }); + renderer.setPixelRatio(Math.min(devicePixelRatio, maxPixelRatio)); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = T.PCFShadowMap; + renderer.setClearColor(0, 0); + renderer.outputColorSpace = T.SRGBColorSpace; + renderer.toneMapping = T.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.02; + renderer.autoClear = false; + renderer.info.autoReset = false; + renderer.domElement.tabIndex = -1; + host.appendChild(renderer.domElement); + + const pmrem = new T.PMREMGenerator(renderer), + room = new RoomEnvironment(), + environment = pmrem.fromScene(room, 0.04); + scene.environment = environment.texture; + scene.environmentIntensity = 1.05; + room.dispose(); + pmrem.dispose(); + + scene.add(new T.HemisphereLight(0xccd9e2, 0x23211f, 0.3)); + const key = new T.DirectionalLight(0xfff2e2, 2.2); + key.position.set(-5, 7.5, 9); + key.castShadow = true; + key.shadow.mapSize.set(shadowSize, shadowSize); + Object.assign(key.shadow.camera, { + left: -19, + right: 19, + top: 19, + bottom: -19, + near: 0.1, + far: 60, + }); + key.shadow.bias = -0.0002; + key.shadow.normalBias = compact ? 0.03 : 0.017; + scene.add(key); + const rim = new T.DirectionalLight(0xb6d0e0, 1.6); + rim.position.set(-6, 3.5, -5); + scene.add(rim); + const kick = new T.DirectionalLight(0xa2bdcf, 0.55); + kick.position.set(7, -4, 8); + scene.add(kick); + + const camera = new T.PerspectiveCamera(32, 1, 0.1, 200); + camera.position.set(9, 11, 14); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.09; + controls.minDistance = 1; + controls.maxDistance = 100; + controls.enablePan = true; + controls.mouseButtons = { + LEFT: T.MOUSE.ROTATE, + MIDDLE: T.MOUSE.DOLLY, + RIGHT: T.MOUSE.PAN, + }; + controls.touches = { ONE: T.TOUCH.ROTATE, TWO: T.TOUCH.DOLLY_PAN }; + + const makePane = (level: GpuComparisonLevel): PaneModel => { + const model = buildModel(level); + model.root.traverse((object) => { + if (object.userData.contextFrame) object.visible = false; + }); + scene.add(model.root); + return { level, model, layoutSignature: '' }; + }; + let state = initial, + panes: Record = { + left: makePane(initial.left), + right: makePane(initial.right), + }, + amount = initial.explode / 100, + split = host.clientWidth >= BREAKPOINT, + frameId = 0, + settle = 80, + autoCamera = true, + disposed = false, + pointerPosition: { clientX: number; clientY: number } | null = null, + activePointer: number | null = null, + dragStart: [number, number] = [0, 0], + gestureMoved = false; + + const targetPosition = new T.Vector3(), + targetLook = new T.Vector3(), + size = new T.Vector3(), + position = new T.Vector3(), + inventoryTarget = new T.Vector3(), + matrix = new T.Matrix4(), + scale = new T.Vector3(), + quaternion = new T.Quaternion(), + leftBounds = new T.Box3(), + rightBounds = new T.Box3(), + commonBounds = new T.Box3(), + raycaster = new T.Raycaster(), + pointer = new T.Vector2(); + const activeTouches = new Set(); + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + let animated: T.Object3D[] = []; + + function collectAnimations() { + animated = []; + for (const { model } of Object.values(panes)) + model.root.traverse((object) => { + if (object.userData.spinRate || object.userData.seekAmplitude) { + object.userData.restRotationY ??= object.rotation.y; + animated.push(object); + } + }); + } + collectAnimations(); + + function paneAspect() { + const width = split ? host.clientWidth / 2 : host.clientWidth; + return width / Math.max(1, host.clientHeight); + } + + function refreshPane(pane: PaneModel) { + const visible = pane.model.pieces.filter( + (piece) => piece.reveal === 0 || state.explode / 100 > piece.reveal, + ); + pane.model.pieces.forEach((piece) => { + piece.visible = visible.includes(piece); + }); + const signature = + pane.level + + '|' + + paneAspect().toFixed(3) + + '|' + + visible.map((piece) => piece.key).join(','); + if (signature !== pane.layoutSignature) { + pane.layoutSignature = signature; + prepareModelInventory(pane.model, pane.level, paneAspect(), visible); + } + } + + function refresh() { + refreshPane(panes.left); + refreshPane(panes.right); + const visible = panes.left.model.pieces.filter( + (piece) => piece.visible, + ).length; + const visibleRight = panes.right.model.pieces.filter( + (piece) => piece.visible, + ).length; + callbacks.stats(visible + visibleRight); + host.dataset.pair = `${state.left}:${state.right}`; + host.dataset.visible = String(visible + visibleRight); + settle = 90; + } + + function posePane(pane: PaneModel) { + const batches = new Set(); + for (const piece of pane.model.pieces) { + const destination = pieceDestination( + piece, + pane.level, + amount, + position, + inventoryTarget, + ); + const reveal = piece.reveal + ? T.MathUtils.smoothstep(amount, piece.reveal, piece.reveal + 0.07) + : 1; + const pieceScale = piece.visible ? destination.scale * reveal : 0; + piece.object.position.copy(destination.position); + piece.object.scale.setScalar(pieceScale); + if (piece.lie && piece.restQuat) + piece.object.quaternion + .copy(piece.restQuat) + .slerp(piece.lie, laidOutAmount(amount)); + if (piece.batch) { + matrix.compose( + piece.object.position, + quaternion.identity(), + scale.setScalar(pieceScale), + ); + piece.batch.setMatrixAt(piece.index!, matrix); + batches.add(piece.batch); + } else piece.object.visible = piece.visible; + } + refreshBatches(batches); + pane.model.root.updateMatrixWorld(true); + } + + function fit() { + posedModelBounds(panes.left.model, panes.left.level, amount, leftBounds); + posedModelBounds(panes.right.model, panes.right.level, amount, rightBounds); + commonModelBounds(leftBounds, rightBounds, commonBounds); + commonBounds.getCenter(targetLook); + commonBounds.getSize(size); + const direction = new T.Vector3( + 0.7, + T.MathUtils.lerp( + 1, + 0.44, + Math.min(1, size.y / Math.max(size.x, size.z, 0.001)), + ), + 1.2, + ).normalize(); + const radius = Math.max(size.length() / 2, 0.35); + const vertical = T.MathUtils.degToRad(camera.fov); + const horizontal = 2 * Math.atan(Math.tan(vertical / 2) * camera.aspect); + const distance = + (radius / Math.sin(Math.min(vertical, horizontal) / 2)) * + (camera.aspect < 1 ? 1.08 : 1); + targetPosition + .copy(targetLook) + .addScaledVector(direction, Math.max(6, distance)); + } + + function renderPane(side: ComparisonSide, x: number, width: number) { + const height = host.clientHeight; + panes.left.model.root.visible = side === 'left'; + panes.right.model.root.visible = side === 'right'; + camera.aspect = width / Math.max(1, height); + camera.updateProjectionMatrix(); + renderer.setViewport(x, 0, width, height); + renderer.setScissor(x, 0, width, height); + renderer.clear(true, true, true); + renderer.render(scene, camera); + } + + let lastTime = performance.now(); + function render() { + if (disposed) return; + const now = performance.now(), + dt = Math.min(1, (now - lastTime) / 1000); + lastTime = now; + amount = reducedMotion + ? state.explode / 100 + : T.MathUtils.damp(amount, state.explode / 100, 10, dt); + let changed = Math.abs(amount - state.explode / 100) > 0.0001; + key.shadow.intensity = 1 - T.MathUtils.smoothstep(amount, 0.62, 0.9); + renderer.shadowMap.autoUpdate = key.shadow.intensity > 0.002; + posePane(panes.left); + posePane(panes.right); + + if (!reducedMotion && animated.length) { + const seconds = now / 1000; + for (const object of animated) { + const rest = object.userData.restRotationY as number; + object.rotation.y = object.userData.spinRate + ? rest + seconds * (object.userData.spinRate as number) + : rest + + Math.sin(seconds * 1.35) * + (object.userData.seekAmplitude as number); + } + changed = true; + } + + camera.aspect = paneAspect(); + camera.updateProjectionMatrix(); + if (autoCamera) { + fit(); + const blend = reducedMotion ? 1 : 1 - Math.exp(-9 * dt); + camera.position.lerp(targetPosition, blend); + controls.target.lerp(targetLook, blend); + changed ||= camera.position.distanceToSquared(targetPosition) > 0.00001; + } + controls.update(); + + renderer.info.reset(); + renderer.setScissorTest(true); + if (split) { + const width = host.clientWidth / 2; + renderPane('left', 0, width); + renderPane('right', width, width); + } else renderPane(state.activeSide, 0, host.clientWidth); + panes.left.model.root.visible = true; + panes.right.model.root.visible = true; + host.dataset.drawCalls = String(renderer.info.render.calls); + host.dataset.triangles = String(renderer.info.render.triangles); + host.dataset.explode = String(Math.round(amount * 100)); + host.dataset.mode = split ? 'split' : state.activeSide; + + if (pointerPosition && activePointer === null && changed) + updateHover(pointerPosition); + if (changed || settle-- > 0) frameId = requestAnimationFrame(render); + else frameId = 0; + } + + function wake() { + settle = 35; + if (!frameId) frameId = requestAnimationFrame(render); + } + + controls.addEventListener('start', () => { + autoCamera = false; + wake(); + }); + controls.addEventListener('change', wake); + + function resize() { + const width = host.clientWidth, + height = host.clientHeight; + if (!width || !height) return; + const wasSplit = split; + split = width >= BREAKPOINT; + renderer.setSize(width, height); + camera.aspect = paneAspect(); + camera.updateProjectionMatrix(); + panes.left.layoutSignature = ''; + panes.right.layoutSignature = ''; + if (wasSplit !== split) { + pointerPosition = null; + callbacks.hover(null, 0, 0); + } + refresh(); + autoCamera = true; + wake(); + } + const observer = new ResizeObserver(resize); + observer.observe(host); + + function hit(event: { clientX: number; clientY: number }, radius = 0) { + const rect = renderer.domElement.getBoundingClientRect(); + const origin = comparisonPanePoint( + event.clientX, + event.clientY, + rect, + split, + state.activeSide, + ); + const cast = (dx: number, dy: number) => { + const point = comparisonPanePoint( + event.clientX + dx, + event.clientY + dy, + rect, + split, + state.activeSide, + ); + if (point.side !== origin.side) return []; + pointer.set(point.x, point.y); + camera.aspect = paneAspect(); + camera.updateProjectionMatrix(); + raycaster.setFromCamera(pointer, camera); + return raycaster.intersectObject(panes[origin.side].model.root, true); + }; + return resolvePickNear(cast, radius); + } + + function updateHover(event: { clientX: number; clientY: number }) { + const piece = hit(event, MOUSE_AIM); + renderer.domElement.style.cursor = piece ? 'help' : 'grab'; + callbacks.hover( + piece ? byId[piece.concept].shortName : null, + event.clientX, + event.clientY, + ); + } + + function down(event: PointerEvent) { + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (event.pointerType === 'touch') activeTouches.add(event.pointerId); + if (activeTouches.size > 1) { + gestureMoved = true; + return; + } + dragStart = [event.clientX, event.clientY]; + gestureMoved = false; + activePointer = event.pointerId; + callbacks.hover(null, 0, 0); + } + + function up(event: PointerEvent) { + activeTouches.delete(event.pointerId); + if (event.pointerId !== activePointer) return; + activePointer = null; + if ( + !gestureMoved && + activeTouches.size === 0 && + Math.hypot(event.clientX - dragStart[0], event.clientY - dragStart[1]) <= + 5 + ) + updateHover(event); + } + + function move(event: PointerEvent) { + if ( + activePointer === event.pointerId && + Math.hypot(event.clientX - dragStart[0], event.clientY - dragStart[1]) > 5 + ) + gestureMoved = true; + pointerPosition = { clientX: event.clientX, clientY: event.clientY }; + if (event.buttons || event.pointerType === 'touch') return; + updateHover(event); + wake(); + } + + function leave() { + pointerPosition = null; + activePointer = null; + activeTouches.clear(); + callbacks.hover(null, 0, 0); + wake(); + } + + function lost(event: Event) { + event.preventDefault(); + callbacks.error( + 'The 3D context was interrupted. Reload the viewer to continue.', + ); + } + + const canvas = renderer.domElement; + canvas.addEventListener('pointerdown', down); + canvas.addEventListener('pointerup', up); + canvas.addEventListener('pointermove', move); + canvas.addEventListener('pointerleave', leave); + canvas.addEventListener('pointercancel', leave); + canvas.addEventListener('webglcontextlost', lost); + refresh(); + resize(); + + function replacePane(side: ComparisonSide, level: GpuComparisonLevel) { + const previous = panes[side]; + scene.remove(previous.model.root); + disposeModelResources(previous.model); + panes[side] = makePane(level); + } + + return { + update(next: ComparisonState) { + callbacks.hover(null, 0, 0); + pointerPosition = null; + const swapped = next.left === state.right && next.right === state.left; + if (swapped) panes = { left: panes.right, right: panes.left }; + else { + if (next.left !== state.left) replacePane('left', next.left); + if (next.right !== state.right) replacePane('right', next.right); + } + const cameraChange = + next.explode !== state.explode || + next.left !== state.left || + next.right !== state.right; + state = next; + if (swapped || next.left !== panes.left.level) + panes.left.level = next.left; + if (swapped || next.right !== panes.right.level) + panes.right.level = next.right; + collectAnimations(); + refresh(); + autoCamera = cameraChange || autoCamera; + wake(); + }, + dispose() { + disposed = true; + cancelAnimationFrame(frameId); + observer.disconnect(); + controls.dispose(); + canvas.removeEventListener('pointerdown', down); + canvas.removeEventListener('pointerup', up); + canvas.removeEventListener('pointermove', move); + canvas.removeEventListener('pointerleave', leave); + canvas.removeEventListener('pointercancel', leave); + canvas.removeEventListener('webglcontextlost', lost); + for (const pane of Object.values(panes)) { + scene.remove(pane.model.root); + disposeModelResources(pane.model); + } + environment.dispose(); + key.shadow.map?.dispose(); + renderer.dispose(); + canvas.remove(); + }, + }; +} diff --git a/lib/comparison-state.ts b/lib/comparison-state.ts new file mode 100644 index 0000000..c9a098b --- /dev/null +++ b/lib/comparison-state.ts @@ -0,0 +1,218 @@ +import { byId, manifest } from './manifest.ts'; +import { levels, type LevelId } from './levels.ts'; + +export const gpuComparisonLevels = ['card', 'rx9070', 'arcb580'] as const; +export type GpuComparisonLevel = (typeof gpuComparisonLevels)[number]; +export type ComparisonSide = 'left' | 'right'; + +export type ComparisonState = { + left: GpuComparisonLevel; + right: GpuComparisonLevel; + activeSide: ComparisonSide; + explode: number; + playing: boolean; + specsOpen: boolean; +}; + +export const initialComparisonState: ComparisonState = { + left: 'card', + right: 'rx9070', + activeSide: 'left', + explode: 0, + playing: false, + specsOpen: false, +}; + +export function selectComparisonGpu( + state: ComparisonState, + side: ComparisonSide, + level: GpuComparisonLevel, +): ComparisonState { + const other = side === 'left' ? state.right : state.left; + if (level === other) return state; + return { ...state, [side]: level }; +} + +export function swapComparisonSides(state: ComparisonState): ComparisonState { + return { ...state, left: state.right, right: state.left }; +} + +export function setComparisonActiveSide( + state: ComparisonState, + activeSide: ComparisonSide, +): ComparisonState { + return { ...state, activeSide }; +} + +export function setComparisonExplode( + state: ComparisonState, + explode: number, +): ComparisonState { + return { + ...state, + explode: Math.min(100, Math.max(0, explode)), + playing: false, + }; +} + +export function setComparisonPlaying( + state: ComparisonState, + playing: boolean, +): ComparisonState { + return { ...state, playing }; +} + +export function setComparisonSpecsOpen( + state: ComparisonState, + specsOpen: boolean, +): ComparisonState { + return { ...state, specsOpen }; +} + +export type ComparisonSpecId = + | 'architecture' + | 'gpu' + | 'memory' + | 'boardPower' + | 'interface' + | 'dimensions' + | 'exterior'; + +export type ComparisonSpec = { + id: ComparisonSpecId; + label: string; + value: string; +}; + +export type ComparisonCard = { + level: GpuComparisonLevel; + name: string; + shortName: string; + note: string; + specs: ComparisonSpec[]; +}; + +const rows: readonly { + id: ComparisonSpecId; + label: string; + keys: readonly string[]; +}[] = [ + { id: 'architecture', label: 'Architecture', keys: ['Architecture'] }, + { id: 'gpu', label: 'GPU', keys: ['GPU'] }, + { id: 'memory', label: 'Memory', keys: ['Memory'] }, + { + id: 'boardPower', + label: 'Board power', + keys: ['Total board power', 'Graphics power'], + }, + { id: 'interface', label: 'Interface', keys: ['Interface'] }, + { id: 'dimensions', label: 'Dimensions', keys: ['Dimensions'] }, + { + id: 'exterior', + label: 'Exterior / reference design', + keys: ['Exterior reference'], + }, +]; + +function specificationValue( + level: GpuComparisonLevel, + row: ComparisonSpecId, + keys: readonly string[], +) { + const root = byId[levels[level].concept]; + for (const key of keys) { + const rootValue = root.specifications[key]; + if (rootValue) return rootValue; + const childValue = manifest.find( + (concept) => + concept.level === level && + concept.specifications[key] && + (row !== 'interface' || + (concept.category === 'Board' && /PCIe/i.test(concept.name))), + )?.specifications[key]; + if (childValue) return childValue; + } + return null; +} + +export function comparisonCard(level: GpuComparisonLevel): ComparisonCard { + const definition = levels[level]; + const root = byId[definition.concept]; + if ( + definition.kind !== 'physical' || + definition.branchLabel !== 'GPU' || + !root || + root.open !== level + ) + throw new Error(`Comparison level "${level}" is not a physical GPU root`); + + const specs = rows.map(({ id, label, keys }) => { + const value = + specificationValue(level, id, keys) ?? + (id === 'exterior' ? root.name : null); + if (!value) + throw new Error( + `Comparison level "${level}" has no value for "${label}"`, + ); + return { id, label, value }; + }); + + return { + level, + name: root.name, + shortName: root.shortName, + note: definition.submenuNote ?? '', + specs, + }; +} + +export function validateComparisonCatalogue() { + const physicalGpuRoots = (Object.keys(levels) as LevelId[]).filter( + (level) => + levels[level].kind === 'physical' && + levels[level].branchLabel === 'GPU' && + byId[levels[level].concept]?.open === level, + ); + if ( + physicalGpuRoots.length !== gpuComparisonLevels.length || + physicalGpuRoots.some( + (level) => !gpuComparisonLevels.includes(level as GpuComparisonLevel), + ) + ) + throw new Error( + 'The physical GPU catalogue changed; comparison support needs an explicit update', + ); + gpuComparisonLevels.forEach(comparisonCard); +} + +export type PanePoint = { + side: ComparisonSide; + x: number; + y: number; +}; + +/** Convert a CSS-pixel pointer into normalized coordinates for its pane. */ +export function comparisonPanePoint( + clientX: number, + clientY: number, + rect: { left: number; top: number; width: number; height: number }, + split: boolean, + activeSide: ComparisonSide, +): PanePoint { + const localX = clientX - rect.left; + const localY = clientY - rect.top; + const side = split + ? localX < rect.width / 2 + ? 'left' + : 'right' + : activeSide; + const paneWidth = split ? rect.width / 2 : rect.width; + const paneX = split && side === 'right' ? localX - paneWidth : localX; + return { + side, + x: (paneX / paneWidth) * 2 - 1, + y: -(localY / rect.height) * 2 + 1, + }; +} + +validateComparisonCatalogue(); diff --git a/lib/model-stage.ts b/lib/model-stage.ts new file mode 100644 index 0000000..9928e1c --- /dev/null +++ b/lib/model-stage.ts @@ -0,0 +1,156 @@ +import * as T from 'three'; +import { byId } from './manifest.ts'; +import { isPhysical, levels, type LevelId } from './levels.ts'; +import { inventoryLayout, smoothstep, spatialInventory } from './layout.ts'; +import type { Piece } from './models.ts'; + +export type BuiltModel = { root: T.Group; pieces: Piece[] }; + +export const laidOutAmount = (value: number) => smoothstep(0.7, 1, value); + +export function piecePosture( + piece: Piece, + grid: number, + extent: T.Vector3, + centre: T.Vector3, +) { + if (!piece.lie || !piece.lieExtent || !piece.lieCenter) + return { extent: piece.extent, centre: piece.center }; + return { + extent: extent.copy(piece.extent).lerp(piece.lieExtent, grid), + centre: centre.copy(piece.center).lerp(piece.lieCenter, grid), + }; +} + +export function pieceDestination( + piece: Piece, + level: LevelId, + value: number, + position: T.Vector3, + inventoryTarget: T.Vector3, +) { + const stage = + byId[piece.concept].category === 'Cooling' + ? smoothstep(0, 0.48, value) + : smoothstep(0.22, 0.68, value); + position + .copy(piece.base) + .addScaledVector( + piece.delta, + (isPhysical(level) ? stage : smoothstep(0, 0.75, value)) * + (levels[level].spread ?? 1), + ); + const grid = laidOutAmount(value); + inventoryTarget + .copy(piece.inventory) + .addScaledVector( + piece.lieCenter ?? piece.center, + -(piece.inventoryScale ?? 1), + ); + position.lerp(inventoryTarget, grid); + return { + position, + scale: T.MathUtils.lerp(1, piece.inventoryScale ?? 1, grid), + }; +} + +/** Pack the visible model pieces once for the current pane aspect. */ +export function prepareModelInventory( + model: BuiltModel, + level: LevelId, + aspect: number, + pieces = model.pieces.filter((piece) => piece.visible), +) { + const positions = inventoryLayout(pieces.length, Math.max(0.6, aspect)); + const hardware = isPhysical(level) + ? spatialInventory( + pieces.map((piece) => ({ + extent: (piece.lieExtent ?? piece.extent).toArray() as [ + number, + number, + number, + ], + size: piece.size, + })), + aspect, + ) + : null; + pieces.forEach((piece, index) => { + piece.inventory.set(...(hardware?.[index].position ?? positions[index])); + piece.inventoryScale = hardware?.[index].scale ?? 1.12 / piece.size; + }); +} + +/** Bounds for the model at one shared disassembly position. */ +export function posedModelBounds( + model: BuiltModel, + level: LevelId, + value: number, + target = new T.Box3(), +) { + const position = new T.Vector3(), + inventory = new T.Vector3(), + extent = new T.Vector3(), + centre = new T.Vector3(), + point = new T.Vector3(), + half = new T.Vector3(), + grid = laidOutAmount(value); + target.makeEmpty(); + for (const piece of model.pieces.filter((candidate) => candidate.visible)) { + const destination = pieceDestination( + piece, + level, + value, + position, + inventory, + ); + const posture = piecePosture(piece, grid, extent, centre); + half.copy(posture.extent).multiplyScalar(destination.scale * 0.52); + target.expandByPoint( + point + .copy(destination.position) + .addScaledVector(posture.centre, destination.scale) + .add(half), + ); + target.expandByPoint( + point + .copy(destination.position) + .addScaledVector(posture.centre, destination.scale) + .sub(half), + ); + } + if (target.isEmpty()) + target.set(new T.Vector3(-4, -1, -2), new T.Vector3(4, 1, 2)); + return target; +} + +export function commonModelBounds( + left: T.Box3, + right: T.Box3, + target = new T.Box3(), +) { + return target.copy(left).union(right); +} + +export function disposeModelResources(model: BuiltModel) { + const geometries = new Set(), + materials = new Set(); + model.root.traverse((object) => { + if (!(object instanceof T.Mesh)) return; + geometries.add(object.geometry); + (Array.isArray(object.material) + ? object.material + : [object.material] + ).forEach((material) => materials.add(material)); + }); + geometries.forEach((geometry) => geometry.dispose()); + materials.forEach((material) => { + if ( + material instanceof T.MeshBasicMaterial || + material instanceof T.MeshStandardMaterial + ) + for (const value of Object.values(material)) + if (value instanceof T.Texture) value.dispose(); + material.dispose(); + }); +} diff --git a/lib/scene.ts b/lib/scene.ts index 9b8c7e4..60dff88 100644 --- a/lib/scene.ts +++ b/lib/scene.ts @@ -6,8 +6,14 @@ import { resolvePickNear } from './picking.ts'; import { airflowShown, airflowStrength } from './airflow.ts'; import { byId, openLevel } from './manifest'; import type { ExplorerState, Selection } from './explorer-state.ts'; -import { isPhysical, levels } from './levels.ts'; +import { isPhysical } from './levels.ts'; import { inventoryLayout, smoothstep, spatialInventory } from './layout'; +import { + disposeModelResources, + laidOutAmount, + pieceDestination, + piecePosture, +} from './model-stage'; /** * How far off a part you may point and still mean it, in CSS pixels. * @@ -186,26 +192,7 @@ export function createViewer( ); } function disposeModel() { - const gs = new Set(), - ms = new Set(); - model.root.traverse((o) => { - if (o instanceof T.Mesh) { - gs.add(o.geometry); - (Array.isArray(o.material) ? o.material : [o.material]).forEach((m) => - ms.add(m), - ); - } - }); - gs.forEach((g) => g.dispose()); - ms.forEach((m) => { - if ( - m instanceof T.MeshBasicMaterial || - m instanceof T.MeshStandardMaterial - ) - for (const value of Object.values(m)) - if (value instanceof T.Texture) value.dispose(); - m.dispose(); - }); + disposeModelResources(model); scene.remove(model.root); } /** Which set of pieces the shelves were last packed for. */ @@ -284,38 +271,16 @@ export function createViewer( * at all between 68% and 80% and then moving every part at once over the * last fifth, which is what read as a pause followed by a lurch. */ - const laidOut = (value: number) => smoothstep(0.7, 1, value); - /** A piece's extent and box centre, part way through turning face up. */ - function posture(p: Piece, grid: number) { - if (!p.lie || !p.lieExtent || !p.lieCenter) - return { extent: p.extent, centre: p.center }; - return { - extent: turning.extent.copy(p.extent).lerp(p.lieExtent, grid), - centre: turning.centre.copy(p.center).lerp(p.lieCenter, grid), - }; - } - function destination(p: Piece, value: number) { - const stage = - byId[p.concept].category === 'Cooling' - ? smoothstep(0, 0.48, value) - : smoothstep(0.22, 0.68, value); - layoutPosition - .copy(p.base) - .addScaledVector( - p.delta, - (isPhysical(state.level) ? stage : smoothstep(0, 0.75, value)) * - (levels[state.level].spread ?? 1), - ); - const grid = laidOut(value); - inventoryTarget - .copy(p.inventory) - .addScaledVector(p.lieCenter ?? p.center, -(p.inventoryScale ?? 1)); - layoutPosition.lerp(inventoryTarget, grid); - return { - position: layoutPosition, - scale: T.MathUtils.lerp(1, p.inventoryScale ?? 1, grid), - }; - } + const destination = (piece: Piece, value: number) => + pieceDestination( + piece, + state.level, + value, + layoutPosition, + inventoryTarget, + ); + const posture = (piece: Piece, grid: number) => + piecePosture(piece, grid, turning.extent, turning.centre); function fit() { bounds.makeEmpty(); let focus = state.focusRevision > 0 && !!state.selection; @@ -326,7 +291,7 @@ export function createViewer( focus = false; candidates.push(...model.pieces.filter((p) => p.visible)); } - const grid = laidOut(state.explode / 100); + const grid = laidOutAmount(state.explode / 100); for (const p of candidates) { const dst = destination(p, state.explode / 100); const { extent, centre } = posture(p, grid); @@ -394,7 +359,7 @@ export function createViewer( // each blade turns, which made the hover outline breathe in and out even // though the fan itself never changed size. const s = p.object.scale.x; - const { extent, centre } = posture(p, laidOut(amount)); + const { extent, centre } = posture(p, laidOutAmount(amount)); target.setFromCenterAndSize( boxCenter.copy(p.object.position).addScaledVector(centre, s), boxSize.copy(extent).multiplyScalar(s).addScalar(0.018), @@ -489,7 +454,9 @@ export function createViewer( p.object.position.copy(dst.position); p.object.scale.setScalar(s); if (p.lie && p.restQuat) - p.object.quaternion.copy(p.restQuat).slerp(p.lie, laidOut(amount)); + p.object.quaternion + .copy(p.restQuat) + .slerp(p.lie, laidOutAmount(amount)); if (p.batch) { matrix.compose(p.object.position, quat, scale.setScalar(s)); p.batch.setMatrixAt(p.index!, matrix); diff --git a/tests/comparison.test.ts b/tests/comparison.test.ts new file mode 100644 index 0000000..93e5705 --- /dev/null +++ b/tests/comparison.test.ts @@ -0,0 +1,163 @@ +import { after, test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as T from 'three'; +import { + comparisonCard, + comparisonPanePoint, + gpuComparisonLevels, + initialComparisonState, + selectComparisonGpu, + setComparisonActiveSide, + setComparisonExplode, + swapComparisonSides, + validateComparisonCatalogue, +} from '../lib/comparison-state.ts'; +import { buildModel } from '../lib/models.ts'; +import { + commonModelBounds, + disposeModelResources, + posedModelBounds, + prepareModelInventory, +} from '../lib/model-stage.ts'; + +const previousDocument = globalThis.document; +globalThis.document = { + createElement: () => ({ + width: 0, + height: 0, + getContext: () => + new Proxy( + {}, + { + get: (_, key) => + key === 'createImageData' + ? (width: number, height: number) => ({ + data: new Uint8ClampedArray(width * height * 4), + }) + : () => {}, + }, + ), + }), +} as unknown as Document; +const comparisonModels = Object.fromEntries( + gpuComparisonLevels.map((level) => [level, buildModel(level)]), +) as Record< + (typeof gpuComparisonLevels)[number], + ReturnType +>; +after(() => { + Object.values(comparisonModels).forEach(disposeModelResources); + globalThis.document = previousDocument; +}); + +await test('comparison state starts with a distinct valid pair', () => { + assert.equal(initialComparisonState.left, 'card'); + assert.equal(initialComparisonState.right, 'rx9070'); + assert.notEqual(initialComparisonState.left, initialComparisonState.right); + validateComparisonCatalogue(); +}); + +await test('comparison transitions preserve unrelated state', () => { + const open = { ...initialComparisonState, specsOpen: true, explode: 42 }; + const selected = selectComparisonGpu(open, 'right', 'arcb580'); + assert.deepEqual(selected, { ...open, right: 'arcb580' }); + assert.equal(selectComparisonGpu(selected, 'left', 'arcb580'), selected); + assert.deepEqual(swapComparisonSides(selected), { + ...selected, + left: 'arcb580', + right: 'card', + }); + assert.deepEqual(setComparisonActiveSide(open, 'right'), { + ...open, + activeSide: 'right', + }); + assert.deepEqual(setComparisonExplode({ ...open, playing: true }, 140), { + ...open, + explode: 100, + playing: false, + }); +}); + +await test('all three cards resolve the complete catalogue-backed spec sheet', () => { + for (const level of gpuComparisonLevels) { + const card = comparisonCard(level); + assert.equal(card.level, level); + assert.equal(card.specs.length, 7); + assert.ok(card.specs.every((row) => row.value.trim().length > 0)); + } + assert.equal( + comparisonCard('card').specs.find((row) => row.id === 'boardPower')?.value, + '575 W', + ); + assert.equal( + comparisonCard('card').specs.find((row) => row.id === 'interface')?.value, + 'PCI Express 5.0', + ); + assert.equal( + comparisonCard('arcb580').specs.find((row) => row.id === 'exterior')?.value, + 'Intel Arc B580 Limited Edition', + ); +}); + +await test('pane coordinates map split and mobile viewports independently', () => { + const rect = { left: 100, top: 50, width: 1000, height: 500 }; + assert.deepEqual(comparisonPanePoint(100, 50, rect, true, 'left'), { + side: 'left', + x: -1, + y: 1, + }); + assert.deepEqual(comparisonPanePoint(600, 300, rect, true, 'left'), { + side: 'right', + x: -1, + y: 0, + }); + assert.deepEqual(comparisonPanePoint(1100, 550, rect, true, 'left'), { + side: 'right', + x: 1, + y: -1, + }); + assert.deepEqual(comparisonPanePoint(600, 300, rect, false, 'right'), { + side: 'right', + x: 0, + y: 0, + }); +}); + +await test('common camera bounds contain both posed cards throughout disassembly', () => { + for (const amount of [0, 0.5, 1]) { + const boxes = gpuComparisonLevels.map((level) => { + const model = comparisonModels[level]; + model.pieces.forEach((piece) => { + piece.visible = piece.reveal === 0 || amount > piece.reveal; + }); + prepareModelInventory( + model, + level, + 1, + model.pieces.filter((piece) => piece.visible), + ); + return posedModelBounds(model, level, amount); + }); + for (let left = 0; left < boxes.length; left++) + for (let right = left + 1; right < boxes.length; right++) { + const common = commonModelBounds(boxes[left], boxes[right]); + for (const box of [boxes[left], boxes[right]]) { + assert.ok( + common.containsPoint(box.min), + `minimum escaped at ${amount}`, + ); + assert.ok( + common.containsPoint(box.max), + `maximum escaped at ${amount}`, + ); + } + } + } + const rtx = posedModelBounds(comparisonModels.card, 'card', 0).getSize( + new T.Vector3(), + ); + const arc = posedModelBounds(comparisonModels.arcb580, 'arcb580', 0).getSize( + new T.Vector3(), + ); + assert.ok(rtx.x > arc.x, 'the RTX 5090 must remain longer than the Arc B580'); +}); From 349320d43a6bca44d22969fbe40778d77bbf0b34 Mon Sep 17 00:00:00 2001 From: Robin Goodwin Date: Sat, 19 Sep 2026 13:46:48 -0700 Subject: [PATCH 3/6] Release viewer resources on mode changes --- app/comparison-viewer.tsx | 7 ++++--- app/viewer.tsx | 7 ++++--- lib/comparison-scene.ts | 1 + lib/scene.ts | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/comparison-viewer.tsx b/app/comparison-viewer.tsx index 2a47e52..2db6e53 100644 --- a/app/comparison-viewer.tsx +++ b/app/comparison-viewer.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { comparisonCard, type ComparisonState } from '@/lib/comparison-state'; import type { createComparisonViewer } from '@/lib/comparison-scene'; @@ -27,7 +27,7 @@ export default function ComparisonViewer({ state, onCount }: Props) { engine.current?.update(state); }, [state, onCount]); - useEffect(() => { + useLayoutEffect(() => { let stopped = false; void import('@/lib/comparison-scene') .then(({ createComparisonViewer }) => { @@ -57,8 +57,9 @@ export default function ComparisonViewer({ state, onCount }: Props) { }); return () => { stopped = true; - engine.current?.dispose(); + const current = engine.current; engine.current = null; + current?.dispose(); }; }, []); diff --git a/app/viewer.tsx b/app/viewer.tsx index 10e9440..31a139e 100644 --- a/app/viewer.tsx +++ b/app/viewer.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { ExplorerState, Selection } from '@/lib/explorer-state'; import type { createViewer } from '@/lib/scene'; type Props = { @@ -25,7 +25,7 @@ export default function Viewer({ state, onSelect, onCount, onDived }: Props) { latest.current = { state, onSelect, onCount, onDived }; engine.current?.update(state); }, [state, onSelect, onCount, onDived]); - useEffect(() => { + useLayoutEffect(() => { let stopped = false; void import('@/lib/scene') .then(({ createViewer }) => { @@ -52,8 +52,9 @@ export default function Viewer({ state, onSelect, onCount, onDived }: Props) { }); return () => { stopped = true; - engine.current?.dispose(); + const current = engine.current; engine.current = null; + current?.dispose(); }; }, []); return ( diff --git a/lib/comparison-scene.ts b/lib/comparison-scene.ts index ad8eef5..38c36fc 100644 --- a/lib/comparison-scene.ts +++ b/lib/comparison-scene.ts @@ -512,6 +512,7 @@ export function createComparisonViewer( environment.dispose(); key.shadow.map?.dispose(); renderer.dispose(); + renderer.forceContextLoss(); canvas.remove(); }, }; diff --git a/lib/scene.ts b/lib/scene.ts index 60dff88..0dc9d6c 100644 --- a/lib/scene.ts +++ b/lib/scene.ts @@ -728,6 +728,7 @@ export function createViewer( env.dispose(); key.shadow.map?.dispose(); renderer.dispose(); + renderer.forceContextLoss(); canvas.remove(); }, }; From a9d66b69bfdf05f936521377ffccdb90047541cc Mon Sep 17 00:00:00 2001 From: Robin Goodwin Date: Sat, 19 Sep 2026 19:41:33 -0700 Subject: [PATCH 4/6] refactor: generalize component comparison --- ARCHITECTURE.md | 78 +++--- app/comparison-specs.tsx | 30 ++- app/comparison-viewer.tsx | 15 +- app/comparison-workbench.tsx | 195 ++++++++------ app/page.tsx | 2 +- app/scale-nav.tsx | 11 + app/topbar.tsx | 19 +- app/use-disassembly-playback.ts | 62 +++++ app/use-explorer.ts | 74 ++---- app/workbench.css | 122 ++++++--- docs/comparison-mode-spec.md | 455 +++++++++++--------------------- lib/comparison-scene.ts | 201 ++++---------- lib/comparison-state.ts | 318 +++++++++++++++------- lib/model-stage.ts | 46 ++++ lib/scene.ts | 220 +++------------ lib/stage-runtime.ts | 147 +++++++++++ tests/comparison.test.ts | 128 +++++---- 17 files changed, 1105 insertions(+), 1018 deletions(-) create mode 100644 app/use-disassembly-playback.ts create mode 100644 lib/stage-runtime.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a94db7..7507d71 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,9 +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 ordinary explorer renderer, lights, camera and dive ramp +lib/stage-runtime.ts shared WebGL, lighting, camera and controls lib/model-stage.ts shared posing, inventory, bounds and model disposal -lib/comparison-*.ts GPU comparison state/data and the scissored renderer +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 ``` @@ -163,15 +164,17 @@ 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` | Ordinary explorer renderer, lights, orbit, visibility, camera fitting and the dive ramp. Returns `{ update, dispose }`. | -| `lib/model-stage.ts` | Reusable model-stage primitives shared by both renderers: piece destinations and posture, inventory preparation, posed/common bounds, and resource disposal. | -| `lib/comparison-state.ts` | The three supported GPU roots, pure comparison transitions, catalogue-backed spec projection, validation, and pane coordinate math. | -| `lib/comparison-scene.ts` | One-renderer comparison engine. Owns the shared camera and controls, renders isolated scissored panes on wide screens, and one active pane below 700 CSS pixels. | +| `lib/stage-runtime.ts` | Shared Three.js stage foundation: renderer, environment, lighting, camera, controls, animation ticking, camera fitting, 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: piece destinations and posture, posed transforms, inventory preparation, posed/common bounds, and resource disposal. | +| `lib/comparison-state.ts` | Typed GPU, PSU, and CPU group registry, 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` | 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. | @@ -261,7 +264,7 @@ 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 app is in explorer or GPU comparison mode, whether the search palette and +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 @@ -297,34 +300,40 @@ 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. -## GPU comparison mode +## Same-category comparison mode Comparison is a separate workbench rather than nullable fields added to -`ExplorerState`. `ComparisonState` admits only `card`, `rx9070`, and `arcb580`, -and pure transitions reject a duplicate left/right pair at the state boundary. -Its catalogue validator also fails if the set of physical GPU roots changes, -so a future fourth card requires an explicit support decision. - -The comparison engine owns one `WebGLRenderer`, canvas, scene, camera and -`OrbitControls` pair. Both card 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. +`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, so the cards keep their millimetre-derived relative -scale. Inventory packing retains each card's physical sizes instead of -normalizing 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 renderer resources on exit. - -The specification sheet does not store product values. It projects the seven -rows from the existing root concepts, normalizes the two existing power labels, -and reads the RTX interface from its existing PCIe edge-connector concept. A -root product name is the exterior/reference value only when that catalogue root -does not carry an `Exterior reference` field. +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 @@ -416,10 +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 transitions, - complete catalogue-backed spec rows for all three cards, pane-local pointer - coordinates at desktop and mobile widths, and common bounds that contain both - posed models at assembled, half-disassembled, and inventory positions. +- `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 index 199cd49..8ee38fb 100644 --- a/app/comparison-specs.tsx +++ b/app/comparison-specs.tsx @@ -7,25 +7,30 @@ import { SheetTitle, } from '@/components/ui/sheet'; import { - comparisonCard, - type GpuComparisonLevel, + comparisonGroups, + comparisonItem, + type ComparisonGroupId, + type ComparisonLevel, } from '@/lib/comparison-state'; type Props = { - left: GpuComparisonLevel; - right: GpuComparisonLevel; + group: ComparisonGroupId; + left: ComparisonLevel; + right: ComparisonLevel; open: boolean; onOpenChange: (open: boolean) => void; }; export default function ComparisonSpecs({ + group, left, right, open, onOpenChange, }: Props) { - const leftCard = comparisonCard(left), - rightCard = comparisonCard(right); + const leftItem = comparisonItem(group, left), + rightItem = comparisonItem(group, right), + definition = comparisonGroups[group]; return ( Specification comparison - Published catalogue values, shown without ranking either card. + Published {definition.shortLabel} catalogue values, shown without + ranking either model.
- {selector('right')} +
+
+ Component category + {comparisonGroupIds.map((groupId) => ( + + ))} +
+
+ {selector('left')} + + {selector('right')} +
@@ -163,17 +181,21 @@ export default function ComparisonWorkbench({ onExit }: Props) {