- {logical ? 'Expand' : 'Disassemble'}
+ {title ?? (logical ? 'Expand' : 'Disassemble')}
DRAG OR PLAY
@@ -67,8 +75,8 @@ export default function Disassembly({
onValueChange={(value) =>
onSetExplode(Array.isArray(value) ? value[0] : value)
}
- aria-label="Disassemble the specimen"
- aria-valuetext={`${explodePercent} percent`}
+ aria-label={sliderLabel}
+ aria-valuetext={valueText ?? `${explodePercent} percent`}
/>
{Array.from({ length: 41 }, (_, i) => (
diff --git a/app/page.tsx b/app/page.tsx
index 21d0b4b..b638da3 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -14,6 +14,7 @@ import Disassembly from './disassembly';
import SearchDialog from './search-dialog';
import DetailPanel from './detail-panel';
import AboutDialog from './about-dialog';
+import ComparisonWorkbench from './comparison-workbench';
export default function Home() {
const explorer = useExplorer();
@@ -22,6 +23,7 @@ export default function Home() {
const [search, setSearch] = useState(false),
[query, setQuery] = useState(''),
[about, setAbout] = useState(false),
+ [mode, setMode] = useState<'explorer' | 'comparison'>('explorer'),
// `null` until the viewer has reported for the first time. Zero means the
// viewer is running and nothing is switched on, which is a different thing
// to say to the reader, and saying the wrong one was what put "No
@@ -67,6 +69,20 @@ export default function Home() {
return () => window.removeEventListener('keydown', key);
}, [about, choose, navigate, search, state.level]);
+ if (mode === 'comparison')
+ return (
+ {
+ setMode('explorer');
+ requestAnimationFrame(() =>
+ document
+ .querySelector('.compare-button')
+ ?.focus(),
+ );
+ }}
+ />
+ );
+
return (
setMode('comparison')}
onClose={() => explorer.setLayers(false)}
/>
void;
onOpenSubmenu: (submenu: LevelId | 'none' | null) => void;
onNavigate: (level: LevelId) => void;
+ onCompare: () => void;
onClose: () => void;
};
@@ -72,6 +74,7 @@ export default function ScaleNav({
onOpenMenu,
onOpenSubmenu,
onNavigate,
+ onCompare,
onClose,
}: Props) {
return (
@@ -86,6 +89,14 @@ export default function ScaleNav({
+
+
+
+ Compare components
+ GPUs, power supplies, and processors
+
+
+
void,
+) {
+ const [playing, setPlaying] = useState(false);
+ const explodeRef = useRef(explode);
+ const changeRef = useRef(onExplodeChange);
+ useEffect(() => {
+ explodeRef.current = explode;
+ }, [explode]);
+ useEffect(() => {
+ changeRef.current = onExplodeChange;
+ }, [onExplodeChange]);
+
+ const stop = useCallback(() => setPlaying(false), []);
+ const setExplode = useCallback((value: number) => {
+ setPlaying(false);
+ const next = Math.min(100, Math.max(0, value));
+ explodeRef.current = next;
+ changeRef.current(next);
+ }, []);
+
+ const toggleAuto = useCallback(() => {
+ if (playing) {
+ setPlaying(false);
+ return;
+ }
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ explodeRef.current = 100;
+ changeRef.current(100);
+ return;
+ }
+ if (explodeRef.current >= 99) {
+ explodeRef.current = 0;
+ changeRef.current(0);
+ }
+ setPlaying(true);
+ }, [playing]);
+
+ useEffect(() => {
+ if (!playing) return;
+ let frame = 0;
+ let last = performance.now();
+ const step = (now: number) => {
+ const delta = Math.min(0.1, (now - last) / 1000);
+ last = now;
+ const next = Math.min(100, explodeRef.current + delta * 11);
+ explodeRef.current = next;
+ changeRef.current(next);
+ if (next >= 100) setPlaying(false);
+ else frame = requestAnimationFrame(step);
+ };
+ frame = requestAnimationFrame(step);
+ return () => cancelAnimationFrame(frame);
+ }, [playing]);
+
+ return { playing, setExplode, stop, toggleAuto };
+}
diff --git a/app/use-explorer.ts b/app/use-explorer.ts
index 756a414..42265b4 100644
--- a/app/use-explorer.ts
+++ b/app/use-explorer.ts
@@ -1,5 +1,5 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useState } from 'react';
import { byId, categories, openLevel, type Category } from '@/lib/manifest';
import { airflowShown } from '@/lib/airflow';
import {
@@ -16,6 +16,7 @@ import {
submenuRoot,
type LevelId,
} from '@/lib/levels';
+import { useDisassemblyPlayback } from './use-disassembly-playback';
/** Graphics cards whose chip diagrams open directly, without a dive. */
const gpuRoots = new Set(['card', 'rx9070', 'arcb580']);
@@ -29,7 +30,6 @@ const gpuRoots = new Set(['card', 'rx9070', 'arcb580']);
*/
export function useExplorer() {
const [state, setState] = useState(initialState);
- const [playing, setPlaying] = useState(false);
const [layers, setLayers] = useState(false);
const [hiddenMenuOpen, setHiddenMenuOpen] = useState(false);
// The subsystem whose menu is open. Follows wherever you are unless you
@@ -48,10 +48,21 @@ export function useExplorer() {
const selected =
state.selection && !state.diveInto ? byId[state.selection.concept] : null;
const logical = !isPhysical(state.level);
+ const updateExplode = useCallback(
+ (explode: number) =>
+ setState((current) => ({ ...current, explode, focusRevision: 0 })),
+ [],
+ );
+ const {
+ playing,
+ setExplode,
+ stop: stopDisassembly,
+ toggleAuto,
+ } = useDisassemblyPlayback(state.explode, updateExplode);
const navigate = useCallback(
(level: LevelId, selection: Selection | null = null) => {
- setPlaying(false);
+ stopDisassembly();
setHiddenMenuOpen(false);
setOpenMenu(menuRoot(level));
setOpenSubmenu(null);
@@ -70,11 +81,11 @@ export function useExplorer() {
}));
setLayers(false);
},
- [],
+ [stopDisassembly],
);
const reset = useCallback(() => {
- setPlaying(false);
+ stopDisassembly();
setHiddenMenuOpen(false);
setOpenMenu(null);
setOpenSubmenu(null);
@@ -83,7 +94,7 @@ export function useExplorer() {
cameraRevision: s.cameraRevision + 1,
}));
setLayers(false);
- }, []);
+ }, [stopDisassembly]);
// Physical disassembly is one continuous move owned by the scene: the stage clears
// around the part you clicked while the camera closes in on it, and when the
@@ -104,7 +115,7 @@ export function useExplorer() {
navigate(target, { concept: levels[target].concept });
return true;
}
- setPlaying(false);
+ stopDisassembly();
setLayers(false);
setState((s) =>
s.diveInto
@@ -121,7 +132,7 @@ export function useExplorer() {
);
return true;
},
- [navigate],
+ [navigate, stopDisassembly],
);
const arrive = useCallback(() => {
@@ -172,53 +183,6 @@ export function useExplorer() {
setLayers(false);
}, []);
- // Runs the disassembly slowly enough to follow, and hands control straight
- // back the moment the viewer touches the slider or changes scale.
- useEffect(() => {
- if (!playing) return;
- let frame = 0,
- last = performance.now();
- const step = (now: number) => {
- // Clamp the step so one stalled frame (a slow machine, a background
- // tab, a heavy rebuild) cannot teleport the disassembly to the end.
- // The run then takes a little longer on slow hardware but stays watchable.
- const delta = Math.min(0.1, (now - last) / 1000);
- last = now;
- setState((s) => {
- const next = Math.min(100, s.explode + delta * 11);
- if (next >= 100) setPlaying(false);
- return { ...s, explode: next, focusRevision: 0 };
- });
- frame = requestAnimationFrame(step);
- };
- frame = requestAnimationFrame(step);
- return () => cancelAnimationFrame(frame);
- }, [playing]);
-
- const setExplode = useCallback((value: number) => {
- setPlaying(false);
- setState((s) => ({ ...s, explode: value, focusRevision: 0 }));
- }, []);
-
- const toggleAuto = () => {
- if (playing) {
- setPlaying(false);
- return;
- }
- // Someone who has asked for less motion gets the end state, not a
- // nine-second animation they did not want.
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
- setState((s) => ({ ...s, explode: 100, focusRevision: 0 }));
- return;
- }
- setState((s) => ({
- ...s,
- explode: s.explode >= 99 ? 0 : s.explode,
- focusRevision: 0,
- }));
- setPlaying(true);
- };
-
const toggleCategory = (category: Category) =>
setState((s) => ({
...s,
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/app/workbench.css b/app/workbench.css
index f32d574..7ad53b8 100644
--- a/app/workbench.css
+++ b/app/workbench.css
@@ -2557,3 +2557,700 @@ a {
min-width: 42px;
}
}
+
+/* Component comparison workbench ------------------------------------------ */
+.compare-button,
+.comparison-specs-button,
+.comparison-exit,
+.comparison-swap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.compare-button {
+ width: 100%;
+ min-height: 58px;
+ justify-content: flex-start;
+ gap: 11px;
+ margin: 0 0 14px;
+ padding: 9px 10px;
+ color: #d8edf0;
+ background: #1b3339;
+ border: 1px solid #4d7f89;
+ border-radius: 6px;
+ text-align: left;
+}
+.compare-button:hover {
+ background: #203c43;
+ border-color: #68a7b4;
+}
+.compare-button > svg:first-child {
+ color: var(--accent-bright);
+}
+.compare-button > svg:last-child {
+ margin-left: auto;
+ color: #7faab2;
+}
+.compare-button > span {
+ min-width: 0;
+}
+.compare-button strong,
+.compare-button small {
+ display: block;
+}
+.compare-button strong {
+ font-size: 12px;
+ font-weight: 600;
+}
+.compare-button small {
+ margin-top: 4px;
+ overflow: hidden;
+ color: #91aeb4;
+ font-size: 9px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.comparison-workbench {
+ background: radial-gradient(
+ ellipse at 50% 47%,
+ #292b2f 0%,
+ #151719 48%,
+ #090a0b 88%
+ );
+}
+.comparison-topbar {
+ position: absolute;
+ inset: 0 0 auto;
+ z-index: 15;
+ height: 76px;
+ padding: 0 28px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ background: #101112;
+ border-bottom: 1px solid #2c2e31;
+ -webkit-user-select: none;
+ user-select: none;
+}
+.comparison-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.comparison-brand > 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: 132px;
+ background: #17191b;
+ border-bottom: 1px solid #34373b;
+}
+.comparison-groups {
+ height: 38px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ border-bottom: 1px solid #303338;
+}
+.comparison-groups button {
+ min-height: 28px;
+ padding: 0 13px;
+ color: #8e939a;
+ border-radius: 4px;
+ font-size: 10px;
+}
+.comparison-groups button:hover {
+ color: #d9dde0;
+ background: #25282b;
+}
+.comparison-groups button[aria-pressed='true'] {
+ color: #eef8f9;
+ background: #304247;
+}
+.comparison-pair {
+ height: 94px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ align-items: stretch;
+}
+.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: 208px 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: 208px;
+ 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: 228px;
+ 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: 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: 136px;
+ }
+ .comparison-groups {
+ height: 38px;
+ gap: 2px;
+ }
+ .comparison-groups button {
+ padding-inline: 9px;
+ font-size: 9px;
+ }
+ .comparison-pair {
+ 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: 200px;
+ 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: 238px 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: 252px;
+ }
+ .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: 108px;
+ }
+ .comparison-groups {
+ height: 36px;
+ }
+ .comparison-pair {
+ height: 72px;
+ }
+ .comparison-selector {
+ padding-block: 6px;
+ }
+ .comparison-selector small {
+ display: none;
+ }
+ .comparison-swap {
+ height: 42px;
+ }
+ .comparison-mobile-switch {
+ top: 160px;
+ height: 34px;
+ }
+ .comparison-viewport {
+ inset: 194px 0 88px;
+ }
+ .comparison-pane-label {
+ top: 202px;
+ }
+ .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/docs/comparison-mode-spec.md b/docs/comparison-mode-spec.md
new file mode 100644
index 0000000..3fc7f11
--- /dev/null
+++ b/docs/comparison-mode-spec.md
@@ -0,0 +1,189 @@
+# Same-category comparison mode
+
+Status: Implemented in pull request #17; awaiting maintainer review
+
+Issue: [#8 Interactive comparison mode](https://github.com/Yoosseph/pc-anatomy/issues/8)
+
+Owner: `@rheos`
+
+## Summary
+
+Add one reusable comparison workbench for existing models that are meaningfully
+comparable within the same category. A reader selects a category, chooses two
+different models, sees them in synchronized panes, drives both through one
+disassembly timeline, and opens a category-specific specification table.
+
+Version 1 supports the catalogue pairs the maintainer identified:
+
+- Graphics cards: RTX 5090, RX 9070 XT, and Arc B580.
+- Power supplies: TUF Gaming 850W Gold and 750W Bronze.
+- Processors: Ryzen 9 9950X and Core Ultra 9 285K.
+
+The motherboard category has only one model and therefore cannot form a pair.
+Cross-category comparison remains a separate product decision.
+
+## Goals
+
+- Compare any two different configured models within one supported category.
+- Use one workbench and renderer for every category, not category-specific
+ copies.
+- Preserve true relative scale where the source models share physical units.
+- Preserve the deliberately shared diagram scale of the two processor models.
+- Keep camera orientation, zoom, and disassembly progress synchronized.
+- Reuse current model builders, catalogue data, disassembly controls, stage
+ runtime, motion, picking, reduced-motion behavior, and visual design.
+- Keep ordinary exploration unchanged when comparison mode is off.
+
+## Non-goals
+
+- Comparing models from different categories.
+- Comparing categories whose existing builders use unrelated presentation
+ scales, such as a case fan against a liquid cooler.
+- Comparing more than two models or a model with itself.
+- Independent cameras or disassembly timelines.
+- Diving, hiding, isolation, airflow, or part inspection in comparison mode.
+- Adding models, technical claims, pricing, or compatibility checking.
+- Introducing React Three Fiber, imported models, or runtime asset downloads.
+
+## User experience
+
+### Entry and exit
+
+- A labelled `Compare components` action sits with `Explore by scale`, where
+ readers choose models. Its supporting copy names the supported categories.
+- Entering opens Graphics cards with RTX 5090 on the left and RX 9070 XT on the
+ right.
+- `Back to explorer` restores normal exploration and focus returns to the entry
+ action.
+
+### Category and model selection
+
+- A three-option category control switches among Graphics cards, Power
+ supplies, and Processors.
+- Switching categories selects that category's first valid pair, resets the
+ shared disassembly value, and closes the specification sheet.
+- Each pane has a native select listing only models in the active category.
+- The model selected in the opposite pane is disabled.
+- `Swap sides` exchanges the two models without rebuilding them or changing
+ camera position.
+
+### 3D interaction
+
+- At 700 CSS pixels and wider, two isolated scissored panes share one camera
+ and one `OrbitControls` pair.
+- Below 700 pixels, one A/B pane is rendered at a time while camera and
+ disassembly state are retained.
+- A single Auto button and slider drives both models.
+- Camera fitting uses the union of both posed bounds, so neither pane is
+ normalized independently.
+- Logical processor diagrams move to a top view as they reach inventory,
+ matching normal exploration.
+- Hover labels resolve only against the model in the pointer's pane.
+
+### Specifications
+
+Values are projected from existing catalogue concepts; comparison state does
+not duplicate product facts.
+
+- GPU: architecture, GPU, memory, board power, interface, dimensions, exterior.
+- PSU: format, output, efficiency, cabling, dimensions.
+- CPU: cores/threads, clocks, cache, process, socket, power.
+
+Differing values are emphasized without ranking either model. Sources and
+accuracy language remain available in the explorer.
+
+## Architecture
+
+### Category registry
+
+Comparable model roots declare a `comparisonGroup` in `lib/levels.ts`.
+`lib/comparison-state.ts` discovers eligible levels and their root catalogue
+concepts from that canonical data; it owns only the group labels and semantic
+specification rows that cannot be inferred safely. State transitions validate
+category membership and prevent identical pairs. Adding a model to an existing
+group requires no comparison-view change. Adding a future category requires a
+schema and tests, not a new workbench.
+
+### Shared stage runtime
+
+`lib/stage-runtime.ts` owns renderer, environment, lighting, camera,
+`OrbitControls`, demand-driven frame scheduling, animated-object helpers,
+camera fitting, context-loss reporting, and base disposal. Both the ordinary
+explorer and comparison renderer compose this runtime.
+
+`lib/model-stage.ts` owns scratch-backed model posing, inventory placement,
+posed bounds, and model-resource disposal. Both renderers call these
+primitives. React uses one `useDisassemblyPlayback` hook for automatic 0–100
+playback in both modes.
+
+### Comparison renderer
+
+The comparison engine owns one `WebGLRenderer`, one canvas, one scene, and one
+camera/control pair. Both roots sit at the same origin. Wide screens alternate
+root visibility during left and right scissored render passes; compact screens
+render only the active root.
+
+Models are built through the existing `buildModel(level)` registry. A changed
+side is disposed and rebuilt; swapping reuses both existing roots. Stage and
+model resources are released synchronously when comparison mode exits.
+
+### Scale policy
+
+- The three graphics cards use the same millimetre conversion in
+ `card-kit.ts`.
+- Both power supplies use the same builder and millimetre conversion.
+- Both processor floorplans are explicitly authored at the same diagram scale.
+
+These guarantees make within-group visual comparisons meaningful. They do not
+extend to arbitrary cross-category pairs because many other builders are
+independently scaled to fill the ordinary explorer stage.
+
+## Accessibility
+
+- Entry, exit, categories, selectors, swap, A/B switch, specs, Auto, slider,
+ phase stops, and reset are keyboard reachable.
+- Each viewport's accessible name includes its selected model.
+- Pair changes are announced through a polite live region.
+- The shared slider describes both models and the active category.
+- Reduced-motion users jump to the requested state without auto-animation.
+- Focus returns to `Compare components` on exit.
+
+## Performance and resource requirements
+
+- One renderer, canvas, scene, animation loop, and WebGL context.
+- Pixel-ratio caps remain 2 on desktop and 1.5 on compact/coarse devices.
+- Two render passes only in split mode; one pass in compact mode.
+- Demand-driven rendering stops after camera and authored animations settle.
+- Dataset diagnostics record group, pair, mode, draw calls, triangles, visible
+ parts, and disassembly amount.
+- Replaced models and all stage resources are disposed without retained viewer
+ trees or active WebGL contexts.
+
+## Acceptance criteria
+
+1. Comparison opens and closes without reloading.
+2. GPU, PSU, and CPU groups each present every configured valid pair.
+3. Models cannot be selected outside the active group or duplicated across
+ both sides.
+4. Category changes reset to a valid distinct pair.
+5. Both panes share camera and disassembly state.
+6. Physical or deliberately shared diagram scale remains truthful.
+7. Desktop uses isolated scissored panes in one canvas.
+8. Compact layouts use an accessible A/B switch and retain state.
+9. Category-specific specs resolve entirely from the catalogue.
+10. Pane-local hover picking, touch controls, reduced motion, context loss, and
+ cleanup work in both modes.
+11. Ordinary exploration behavior remains unchanged.
+12. Type checking, linting, tests, and production build pass.
+
+## Verification plan
+
+Automated coverage validates group membership, distinct-pair transitions,
+catalogue-backed rows for every configured model, pane coordinate conversion,
+and common bounds for every group at assembled, dissection, and inventory
+positions.
+
+Manual QA covers desktop split view, compact A/B switching, category changes,
+model selectors, swap, shared disassembly, category-specific specs, divider
+picking, touch controls, reduced motion, repeated model replacement, and
+repeated entry/exit resource cleanup.
diff --git a/lib/comparison-scene.ts b/lib/comparison-scene.ts
new file mode 100644
index 0000000..97a7b5c
--- /dev/null
+++ b/lib/comparison-scene.ts
@@ -0,0 +1,404 @@
+import * as T from 'three';
+import {
+ comparisonPanePoint,
+ type ComparisonLevel,
+ type ComparisonSide,
+ type ComparisonViewState,
+} from './comparison-state.ts';
+import { byId } from './manifest.ts';
+import { isPhysical } from './levels.ts';
+import {
+ applyPiecePose,
+ commonModelBounds,
+ disposeModelResources,
+ posedModelBounds,
+ prepareModelInventory,
+ type BuiltModel,
+ ModelStageScratch,
+} from './model-stage.ts';
+import { buildModel, refreshBatches, type Piece } from './models.ts';
+import { resolvePickNear } from './picking.ts';
+import {
+ animateObjects,
+ collectAnimatedObjects,
+ configureStageLighting,
+ createStageLoop,
+ createStageRuntime,
+ defaultCameraDirection,
+ fitCameraToBounds,
+} from './stage-runtime.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: ComparisonLevel;
+ model: BuiltModel;
+ layoutSignature: string;
+};
+
+export function createComparisonViewer(
+ host: HTMLDivElement,
+ initial: ComparisonViewState,
+ callbacks: ComparisonSceneCallbacks,
+) {
+ const runtime = createStageRuntime(host, callbacks.error);
+ const { scene, renderer, key, camera, controls } = runtime;
+ renderer.autoClear = false;
+ renderer.info.autoReset = false;
+
+ const makePane = (level: ComparisonLevel): PaneModel => {
+ const model = buildModel(level);
+ model.root.traverse((object) => {
+ if (object.userData.airflowTick) 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,
+ autoCamera = true,
+ pointerPosition: { clientX: number; clientY: number } | null = null,
+ activePointer: number | null = null,
+ dragStart: [number, number] = [0, 0],
+ gestureMoved = false;
+ configureStageLighting(runtime, initial.left);
+
+ const targetPosition = new T.Vector3(),
+ targetLook = new T.Vector3(),
+ size = new T.Vector3(),
+ leftBounds = new T.Box3(),
+ rightBounds = new T.Box3(),
+ commonBounds = new T.Box3(),
+ cameraDirection = new T.Vector3(),
+ raycaster = new T.Raycaster(),
+ pointer = new T.Vector2();
+ const activeTouches = new Set(),
+ batches = new Set(),
+ stageScratch = new ModelStageScratch();
+ let animated: T.Object3D[] = [];
+
+ function collectAnimations() {
+ animated = collectAnimatedObjects(
+ panes.left.model.root,
+ panes.right.model.root,
+ );
+ }
+ collectAnimations();
+
+ function paneAspect() {
+ const width = split ? host.clientWidth / 2 : host.clientWidth;
+ return width / Math.max(1, host.clientHeight);
+ }
+
+ function refreshPane(pane: PaneModel) {
+ const visible: Piece[] = [];
+ for (const piece of pane.model.pieces) {
+ piece.visible = piece.reveal === 0 || state.explode / 100 > piece.reveal;
+ if (piece.visible) visible.push(piece);
+ }
+ const aspect = paneAspect();
+ const signature =
+ pane.level +
+ '|' +
+ aspect.toFixed(3) +
+ '|' +
+ visible.map((piece) => piece.key).join(',');
+ if (signature !== pane.layoutSignature) {
+ pane.layoutSignature = signature;
+ prepareModelInventory(pane.model, pane.level, aspect, visible);
+ }
+ return visible.length;
+ }
+
+ function refresh() {
+ const visible = refreshPane(panes.left) + refreshPane(panes.right);
+ callbacks.stats(visible);
+ host.dataset.pair = `${state.left}:${state.right}`;
+ host.dataset.group = state.group;
+ host.dataset.visible = String(visible);
+ loop.wake(90);
+ }
+
+ function posePane(pane: PaneModel) {
+ batches.clear();
+ for (const piece of pane.model.pieces) {
+ applyPiecePose(piece, pane.level, amount, stageScratch);
+ if (piece.batch) batches.add(piece.batch);
+ }
+ refreshBatches(batches);
+ pane.model.root.updateMatrixWorld(true);
+ }
+
+ function fit() {
+ posedModelBounds(
+ panes.left.model,
+ panes.left.level,
+ amount,
+ leftBounds,
+ stageScratch,
+ );
+ posedModelBounds(
+ panes.right.model,
+ panes.right.level,
+ amount,
+ rightBounds,
+ stageScratch,
+ );
+ commonModelBounds(leftBounds, rightBounds, commonBounds);
+ commonBounds.getSize(size);
+ const direction =
+ !isPhysical(state.left) && amount > 0.85
+ ? cameraDirection.set(0, 1, 0.001)
+ : defaultCameraDirection(size, cameraDirection);
+ fitCameraToBounds(
+ commonBounds,
+ camera,
+ targetPosition,
+ targetLook,
+ direction,
+ size,
+ camera.aspect < 1 ? 1.08 : 1,
+ );
+ }
+
+ 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);
+ }
+
+ function render(seconds: number, dt: number, reducedMotion: boolean) {
+ 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 = isPhysical(state.left)
+ ? 1 - T.MathUtils.smoothstep(amount, 0.62, 0.9)
+ : 0;
+ renderer.shadowMap.autoUpdate = key.shadow.intensity > 0.002;
+ posePane(panes.left);
+ posePane(panes.right);
+
+ if (!reducedMotion && animated.length) {
+ animateObjects(animated, seconds);
+ 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);
+ return changed;
+ }
+ const loop = createStageLoop(
+ controls,
+ () => {
+ autoCamera = false;
+ },
+ render,
+ );
+ const wake = loop.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();
+ }
+
+ const canvas = renderer.domElement;
+ canvas.addEventListener('pointerdown', down);
+ canvas.addEventListener('pointerup', up);
+ canvas.addEventListener('pointermove', move);
+ canvas.addEventListener('pointerleave', leave);
+ canvas.addEventListener('pointercancel', leave);
+ refresh();
+ resize();
+
+ function replacePane(side: ComparisonSide, level: ComparisonLevel) {
+ const previous = panes[side];
+ scene.remove(previous.model.root);
+ disposeModelResources(previous.model);
+ panes[side] = makePane(level);
+ }
+
+ return {
+ update(next: ComparisonViewState) {
+ 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;
+ configureStageLighting(runtime, next.left);
+ collectAnimations();
+ refresh();
+ autoCamera = cameraChange || autoCamera;
+ wake();
+ },
+ dispose() {
+ loop.dispose();
+ observer.disconnect();
+ canvas.removeEventListener('pointerdown', down);
+ canvas.removeEventListener('pointerup', up);
+ canvas.removeEventListener('pointermove', move);
+ canvas.removeEventListener('pointerleave', leave);
+ canvas.removeEventListener('pointercancel', leave);
+ for (const pane of Object.values(panes)) {
+ scene.remove(pane.model.root);
+ disposeModelResources(pane.model);
+ }
+ runtime.dispose();
+ },
+ };
+}
diff --git a/lib/comparison-state.ts b/lib/comparison-state.ts
new file mode 100644
index 0000000..11bc428
--- /dev/null
+++ b/lib/comparison-state.ts
@@ -0,0 +1,325 @@
+import { byId, manifest } from './manifest.ts';
+import { levelIds, levels, type LevelId } from './levels.ts';
+import type { Category } from './concept.ts';
+
+export type ComparisonLevel = LevelId;
+export type ComparisonSide = 'left' | 'right';
+
+export type ComparisonSpecId =
+ | 'architecture'
+ | 'gpu'
+ | 'memory'
+ | 'boardPower'
+ | 'interface'
+ | 'dimensions'
+ | 'exterior'
+ | 'format'
+ | 'output'
+ | 'efficiency'
+ | 'cabling'
+ | 'cores'
+ | 'clocks'
+ | 'cache'
+ | 'process'
+ | 'socket'
+ | 'power';
+
+type ComparisonRowDefinition = {
+ id: ComparisonSpecId;
+ label: string;
+ keys: readonly string[];
+ fallback?: 'name';
+ category?: Category;
+};
+
+export type ComparisonGroupDefinition = {
+ label: string;
+ shortLabel: string;
+ itemLabel: string;
+ rows: readonly ComparisonRowDefinition[];
+};
+
+export const comparisonGroups = {
+ gpu: {
+ label: 'Graphics cards',
+ shortLabel: 'GPU',
+ itemLabel: 'graphics card',
+ rows: [
+ { 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'],
+ category: 'Board',
+ },
+ { id: 'dimensions', label: 'Dimensions', keys: ['Dimensions'] },
+ {
+ id: 'exterior',
+ label: 'Exterior / reference design',
+ keys: ['Exterior reference'],
+ fallback: 'name',
+ },
+ ],
+ },
+ psu: {
+ label: 'Power supplies',
+ shortLabel: 'PSU',
+ itemLabel: 'power supply',
+ rows: [
+ { id: 'format', label: 'Format', keys: ['Format'] },
+ { id: 'output', label: 'Output', keys: ['Output'] },
+ { id: 'efficiency', label: 'Efficiency', keys: ['Efficiency'] },
+ { id: 'cabling', label: 'Cabling', keys: ['Cabling'] },
+ { id: 'dimensions', label: 'Dimensions', keys: ['Dimensions'] },
+ ],
+ },
+ cpu: {
+ label: 'Processors',
+ shortLabel: 'CPU',
+ itemLabel: 'processor',
+ rows: [
+ { id: 'cores', label: 'Cores / threads', keys: ['Cores'] },
+ { id: 'clocks', label: 'Clocks', keys: ['Clocks'] },
+ { id: 'cache', label: 'Cache', keys: ['Cache'] },
+ { id: 'process', label: 'Process', keys: ['Process'] },
+ { id: 'socket', label: 'Socket', keys: ['Socket'] },
+ {
+ id: 'power',
+ label: 'Power',
+ keys: ['Power', 'Default TDP'],
+ },
+ ],
+ },
+} as const satisfies Record;
+
+export type ComparisonGroupId = keyof typeof comparisonGroups;
+export const comparisonGroupIds = Object.keys(
+ comparisonGroups,
+) as ComparisonGroupId[];
+
+export type ComparisonState = {
+ group: ComparisonGroupId;
+ left: ComparisonLevel;
+ right: ComparisonLevel;
+ activeSide: ComparisonSide;
+ explode: number;
+ specsOpen: boolean;
+};
+
+export type ComparisonViewState = Omit;
+
+export function comparisonLevels(group: ComparisonGroupId) {
+ return levelIds.filter((level) => levels[level].comparisonGroup === group);
+}
+
+function defaultComparisonPair(
+ group: ComparisonGroupId,
+): readonly [ComparisonLevel, ComparisonLevel] {
+ const [left, right] = comparisonLevels(group);
+ if (!left || !right)
+ throw new Error(`Comparison group "${group}" needs at least two models`);
+ return [left, right];
+}
+
+const [initialLeft, initialRight] = defaultComparisonPair('gpu');
+
+export const initialComparisonState: ComparisonState = {
+ group: 'gpu',
+ left: initialLeft,
+ right: initialRight,
+ activeSide: 'left',
+ explode: 0,
+ specsOpen: false,
+};
+
+export function selectComparisonGroup(
+ state: ComparisonState,
+ group: ComparisonGroupId,
+): ComparisonState {
+ if (group === state.group) return state;
+ const [left, right] = defaultComparisonPair(group);
+ return {
+ ...state,
+ group,
+ left,
+ right,
+ activeSide: 'left',
+ explode: 0,
+ specsOpen: false,
+ };
+}
+
+export function selectComparisonItem(
+ state: ComparisonState,
+ side: ComparisonSide,
+ level: ComparisonLevel,
+): ComparisonState {
+ const other = side === 'left' ? state.right : state.left;
+ if (level === other || levels[level].comparisonGroup !== state.group)
+ 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)) };
+}
+
+export function setComparisonSpecsOpen(
+ state: ComparisonState,
+ specsOpen: boolean,
+): ComparisonState {
+ return { ...state, specsOpen };
+}
+
+export type ComparisonSpec = {
+ id: ComparisonSpecId;
+ label: string;
+ value: string;
+};
+
+export type ComparisonItem = {
+ level: ComparisonLevel;
+ name: string;
+ shortName: string;
+ note: string;
+ specs: ComparisonSpec[];
+};
+
+function specificationValue(
+ level: ComparisonLevel,
+ conceptId: string,
+ keys: readonly string[],
+ category?: Category,
+) {
+ const root = byId[conceptId];
+ for (const key of keys) {
+ const rootValue = root.specifications[key];
+ if (rootValue) return rootValue;
+ const childValue = manifest.find(
+ (concept) =>
+ concept.level === level &&
+ (!category || concept.category === category) &&
+ concept.specifications[key],
+ )?.specifications[key];
+ if (childValue) return childValue;
+ }
+ return null;
+}
+
+function comparisonRoot(level: ComparisonLevel) {
+ const roots = manifest.filter(
+ (concept) => concept.level === level && concept.open === level,
+ );
+ if (roots.length !== 1)
+ throw new Error(
+ `Comparison level "${level}" needs exactly one root catalogue concept`,
+ );
+ return roots[0];
+}
+
+export function comparisonItem(
+ group: ComparisonGroupId,
+ level: ComparisonLevel,
+): ComparisonItem {
+ if (levels[level].comparisonGroup !== group)
+ throw new Error(
+ `Comparison level "${level}" does not belong to "${group}"`,
+ );
+ const definition = levels[level];
+ const root = comparisonRoot(level);
+ const rows: readonly ComparisonRowDefinition[] = comparisonGroups[group].rows;
+
+ const specs = rows.map(({ id, label, keys, fallback, category }) => {
+ const value =
+ specificationValue(level, root.id, keys, category) ??
+ (fallback === 'name' ? 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.caption,
+ specs,
+ };
+}
+
+export function validateComparisonCatalogue() {
+ for (const level of levelIds) {
+ const group = levels[level].comparisonGroup;
+ if (group && !(group in comparisonGroups))
+ throw new Error(
+ `Comparison level "${level}" names unknown group "${group}"`,
+ );
+ }
+ for (const groupId of comparisonGroupIds) {
+ const groupLevels = comparisonLevels(groupId);
+ defaultComparisonPair(groupId);
+ const kinds = new Set(groupLevels.map((level) => levels[level].kind));
+ if (kinds.size !== 1)
+ throw new Error(
+ `Comparison group "${groupId}" mixes physical and logical models`,
+ );
+ for (const level of groupLevels) {
+ comparisonItem(groupId, level);
+ }
+ }
+}
+
+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/levels.ts b/lib/levels.ts
index 669a5b7..18b9237 100644
--- a/lib/levels.ts
+++ b/lib/levels.ts
@@ -61,6 +61,12 @@ export interface LevelDef {
phases: readonly (readonly [string, number])[];
/** Marked false while a branch is still a placeholder. */
detailed: boolean;
+ /**
+ * Models carrying the same value are eligible for the shared comparison
+ * workbench. The workbench discovers them from this catalogue instead of
+ * maintaining a second list of level and concept ids.
+ */
+ comparisonGroup?: string;
/**
* Set on a subsystem root, including nested roots such as an M.2 drive. It names the subsystem menu
* that this scale and everything beneath it are listed under, so a viewer
@@ -141,6 +147,7 @@ export const levels: Record = {
concept: 'ryzenpackage',
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'cpu',
},
ryzenio: {
id: 'ryzenio',
@@ -168,6 +175,7 @@ export const levels: Record = {
concept: 'corepackage',
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'cpu',
},
coreio: {
id: 'coreio',
@@ -194,6 +202,7 @@ export const levels: Record = {
spread: 1.9,
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'psu',
},
psubronze: {
id: 'psubronze',
@@ -209,6 +218,7 @@ export const levels: Record = {
spread: 1.9,
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'psu',
},
fan: {
id: 'fan',
@@ -297,6 +307,7 @@ export const levels: Record = {
concept: 'card',
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'gpu',
},
die: {
id: 'die',
@@ -364,6 +375,7 @@ export const levels: Record = {
concept: 'rxcard',
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'gpu',
},
navi48: {
id: 'navi48',
@@ -430,6 +442,7 @@ export const levels: Record = {
concept: 'arccard',
phases: dissectionPhases,
detailed: true,
+ comparisonGroup: 'gpu',
},
bmg: {
id: 'bmg',
diff --git a/lib/model-stage.ts b/lib/model-stage.ts
new file mode 100644
index 0000000..054d897
--- /dev/null
+++ b/lib/model-stage.ts
@@ -0,0 +1,205 @@
+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 class ModelStageScratch {
+ position = new T.Vector3();
+ inventoryTarget = new T.Vector3();
+ matrix = new T.Matrix4();
+ quaternion = new T.Quaternion();
+ scale = new T.Vector3();
+ extent = new T.Vector3();
+ centre = new T.Vector3();
+ point = new T.Vector3();
+ half = new T.Vector3();
+}
+
+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),
+ };
+}
+
+/** Apply the shared explode, reveal, inventory, and lay-flat pose to one piece. */
+export function applyPiecePose(
+ piece: Piece,
+ level: LevelId,
+ value: number,
+ scratch: ModelStageScratch,
+ scaleMultiplier = 1,
+) {
+ const destination = pieceDestination(
+ piece,
+ level,
+ value,
+ scratch.position,
+ scratch.inventoryTarget,
+ );
+ const reveal = piece.reveal
+ ? smoothstep(piece.reveal, piece.reveal + 0.07, value)
+ : 1;
+ const pieceScale = piece.visible
+ ? destination.scale * reveal * scaleMultiplier
+ : 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(value));
+ if (piece.batch) {
+ scratch.matrix.compose(
+ piece.object.position,
+ scratch.quaternion.identity(),
+ scratch.scale.setScalar(pieceScale),
+ );
+ piece.batch.setMatrixAt(piece.index!, scratch.matrix);
+ } else piece.object.visible = piece.visible;
+ return pieceScale;
+}
+
+/** 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(),
+ scratch = new ModelStageScratch(),
+ pieces: readonly Piece[] = model.pieces,
+) {
+ const grid = laidOutAmount(value);
+ target.makeEmpty();
+ for (const piece of pieces) {
+ if (!piece.visible) continue;
+ const destination = pieceDestination(
+ piece,
+ level,
+ value,
+ scratch.position,
+ scratch.inventoryTarget,
+ );
+ const posture = piecePosture(piece, grid, scratch.extent, scratch.centre);
+ scratch.half.copy(posture.extent).multiplyScalar(destination.scale * 0.52);
+ target.expandByPoint(
+ scratch.point
+ .copy(destination.position)
+ .addScaledVector(posture.centre, destination.scale)
+ .add(scratch.half),
+ );
+ target.expandByPoint(
+ scratch.point
+ .copy(destination.position)
+ .addScaledVector(posture.centre, destination.scale)
+ .sub(scratch.half),
+ );
+ }
+ if (target.isEmpty()) {
+ target.min.set(-4, -1, -2);
+ target.max.set(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..86a77e3 100644
--- a/lib/scene.ts
+++ b/lib/scene.ts
@@ -1,13 +1,29 @@
import * as T from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { buildModel, refreshBatches, type Piece } from './models';
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 { inventoryLayout, smoothstep, spatialInventory } from './layout';
+import { isPhysical } from './levels.ts';
+import { smoothstep } from './layout';
+import {
+ applyPiecePose,
+ disposeModelResources,
+ laidOutAmount,
+ piecePosture,
+ posedModelBounds,
+ prepareModelInventory,
+ ModelStageScratch,
+} from './model-stage';
+import {
+ animateObjects,
+ collectAnimatedObjects,
+ configureStageLighting,
+ createStageLoop,
+ createStageRuntime,
+ defaultCameraDirection,
+ fitCameraToBounds,
+} from './stage-runtime.ts';
/**
* How far off a part you may point and still mean it, in CSS pixels.
*
@@ -33,100 +49,12 @@ export function createViewer(
initial: ExplorerState,
callbacks: SceneCallbacks,
) {
- const scene = new T.Scene();
- // A phone runs the same geometry as a desktop, several hundred meshes and
- // a third of a million triangles, on a fraction of the fill rate, with no
- // fan and a battery. Drawing that at a phone's native 3x pixel ratio with a
- // 2048-pixel shadow map costs roughly nine times the fragment work of a
- // laptop and puts orbiting below the frame rate at which it still feels
- // attached to your finger. Fewer pixels and a smaller shadow map cost
- // almost nothing visible at arm's length, so quality follows the device.
- 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;
- // three.js now maps the retired soft constant to PCF internally and warns on
- // every load. Naming the renderer's real mode keeps the console quiet.
- renderer.shadowMap.type = T.PCFShadowMap;
- renderer.setClearColor(0, 0);
- renderer.outputColorSpace = T.SRGBColorSpace;
- renderer.toneMapping = T.ACESFilmicToneMapping;
- renderer.toneMappingExposure = 1.02;
- // Pointer interaction owns the canvas. The surrounding controls provide the
- // keyboard path, so the drawing surface must not become an empty tab stop.
- renderer.domElement.tabIndex = -1;
- host.appendChild(renderer.domElement);
- const pmrem = new T.PMREMGenerator(renderer),
- room = new RoomEnvironment(),
- env = pmrem.fromScene(room, 0.04);
- scene.environment = env.texture;
- scene.environmentIntensity = 1.0;
- room.dispose();
- pmrem.dispose();
- // A near-overhead key with a bright sky behind it flattens every upward face
- // in the machine to the same pale grey, whatever it is made of: the shroud,
- // the backplate and the lid all came out the same colour as each other and
- // none of them came out black. Raking the key across the build instead, and
- // leaving the ambient to the environment map, keeps a dark panel dark and
- // puts the brightness back where it belongs, in the highlight along an edge.
- const fill = new T.HemisphereLight(0xccd9e2, 0x23211f, 0.3);
- scene.add(fill);
- const key = new T.DirectionalLight(0xfff2e2, 2.05);
- key.position.set(-5, 7.5, 9);
- key.castShadow = true;
- key.shadow.mapSize.set(shadowSize, shadowSize);
- // The frustum has to hold the machine *and* the shadow it throws, not just
- // the machine: at thirteen units across and lit from one side, the cast
- // shadow reaches well past the case, and a frustum sized to the case alone
- // ends that shadow in a straight line across open floor.
- Object.assign(key.shadow.camera, {
- left: -19,
- right: 19,
- top: 19,
- bottom: -19,
- near: 0.1,
- far: 60,
- });
- key.shadow.bias = -0.0002;
- // Halving the shadow map doubles the world size of a shadow texel, so the
- // offset that keeps a surface from shadowing itself has to grow with it.
- key.shadow.normalBias = compact ? 0.03 : 0.017;
- scene.add(key);
- const rim = new T.DirectionalLight(0xb6d0e0, 1.75);
- rim.position.set(-6, 3.5, -5);
- scene.add(rim);
- // A dim light from below and in front. Without it every downward face goes
- // flat black and the parts read as silhouettes rather than as objects.
- const kick = new T.DirectionalLight(0xa2bdcf, 0.5);
- 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 runtime = createStageRuntime(host, callbacks.error);
+ const { scene, renderer, key, camera, controls } = runtime;
let state = initial,
model = buildModel(state.level),
amount = state.explode / 100,
- frameId = 0,
- settle = 80,
autoCamera = true,
- disposed = false,
dragStart: [number, number] = [0, 0],
rightDragStart: [number, number] = [0, 0],
rightGestureMoved = false,
@@ -152,22 +80,16 @@ export function createViewer(
scene.add(selectedBox, hoverBox);
const targetPosition = new T.Vector3(),
targetLook = new T.Vector3(),
- layoutPosition = new T.Vector3(),
- inventoryTarget = new T.Vector3(),
- matrix = new T.Matrix4(),
- scale = new T.Vector3(),
- quat = new T.Quaternion(),
bounds = new T.Box3(),
v = new T.Vector3(),
+ cameraDirection = new T.Vector3(),
color = new T.Color();
const raycaster = new T.Raycaster(),
pointer = new T.Vector2();
- const turning = {
- extent: new T.Vector3(),
- centre: new T.Vector3(),
- };
+ const stageScratch = new ModelStageScratch();
const activeTouches = new Set(),
selectionBounds = new T.Box3();
+ const boundsPieces: Piece[] = [];
const boxCenter = new T.Vector3(),
boxSize = new T.Vector3();
function matches(p: Piece, selection: Selection | null) {
@@ -186,46 +108,13 @@ 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. */
let layoutSignature = '';
function refresh() {
- // Hardware is lit like hardware; diagrams are lit flat so the blocks read
- // as a drawing rather than as objects sitting on a table.
- const hardwareScale = isPhysical(state.level);
- // Shine comes from specular contrast, not from turning everything up:
- // a strong environment for highlights, a restrained key so the diffuse
- // surfaces keep their tone instead of blowing out to chalk.
- // Dark hardware needs its contrast from specular, not from diffuse. A
- // strong environment lights the edges, the columns and every machined face
- // while leaving the coated panels as dark as they really are; turning the
- // key up instead would only wash those panels back to grey.
- scene.environmentIntensity =
- state.level === 'pc' ? 0.82 : hardwareScale ? 1.05 : 0.48;
- key.intensity = state.level === 'pc' ? 1.85 : hardwareScale ? 2.2 : 0.85;
- rim.intensity = hardwareScale ? 1.6 : 0.7;
- kick.intensity = hardwareScale ? 0.55 : 0.28;
+ configureStageLighting(runtime, state.level);
const visible = model.pieces.filter(shown);
// Packing the shelves is the one expensive thing in here, and `refresh`
// runs on every state change, which includes every step of the explode
@@ -241,24 +130,7 @@ export function createViewer(
visible.map((p) => p.key).join(',');
if (signature !== layoutSignature) {
layoutSignature = signature;
- const positions = inventoryLayout(visible.length, Math.max(0.6, aspect));
- const hardware = hardwareScale
- ? spatialInventory(
- visible.map((p) => ({
- extent: (p.lieExtent ?? p.extent).toArray() as [
- number,
- number,
- number,
- ],
- size: p.size,
- })),
- aspect,
- )
- : null;
- visible.forEach((p, i) => {
- p.inventory.set(...(hardware?.[i].position ?? positions[i]));
- p.inventoryScale = hardware?.[i].scale ?? 1.12 / p.size;
- });
+ prepareModelInventory(model, state.level, aspect, visible);
}
for (const p of model.pieces) p.visible = shown(p);
for (const child of model.root.children)
@@ -273,7 +145,7 @@ export function createViewer(
host.dataset.componentTypes = String(
new Set(visible.map((p) => p.concept)).size,
);
- settle = 90;
+ loop.wake(90);
autoCamera = true;
}
/**
@@ -284,96 +156,45 @@ 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 posture = (piece: Piece, grid: number) =>
+ piecePosture(piece, grid, stageScratch.extent, stageScratch.centre);
function fit() {
- bounds.makeEmpty();
let focus = state.focusRevision > 0 && !!state.selection;
- const candidates = model.pieces.filter(
- (p) => p.visible && (!focus || matches(p, state.selection)),
- );
- if (!candidates.length) {
+ boundsPieces.length = 0;
+ for (const piece of model.pieces)
+ if (piece.visible && (!focus || matches(piece, state.selection)))
+ boundsPieces.push(piece);
+ if (!boundsPieces.length) {
focus = false;
- candidates.push(...model.pieces.filter((p) => p.visible));
+ for (const piece of model.pieces)
+ if (piece.visible) boundsPieces.push(piece);
}
- const grid = laidOut(state.explode / 100);
- for (const p of candidates) {
- const dst = destination(p, state.explode / 100);
- const { extent, centre } = posture(p, grid);
- const half = extent.clone().multiplyScalar(dst.scale * 0.52);
- bounds.expandByPoint(
- v.copy(dst.position).addScaledVector(centre, dst.scale).add(half),
- );
- bounds.expandByPoint(
- v.copy(dst.position).addScaledVector(centre, dst.scale).sub(half),
- );
- }
- if (bounds.isEmpty()) {
- bounds.set(new T.Vector3(-4, -1, -2), new T.Vector3(4, 1, 2));
- }
- bounds.getCenter(targetLook);
+ posedModelBounds(
+ model,
+ state.level,
+ state.explode / 100,
+ bounds,
+ stageScratch,
+ boundsPieces,
+ );
const size = bounds.getSize(v);
const direction =
// Diagrams read from directly above once they are fully separated.
// Hardware does not: it is laid out in depth, so stay on the orbit.
state.explode > 85 && !isPhysical(state.level)
- ? new T.Vector3(0, 1, 0.001)
+ ? cameraDirection.set(0, 1, 0.001)
: state.view === 'top'
- ? new T.Vector3(0, 1, 0.001)
+ ? cameraDirection.set(0, 1, 0.001)
: state.view === 'front'
- ? new T.Vector3(0, 0.05, 1)
+ ? cameraDirection.set(0, 0.05, 1)
: state.view === 'back'
- ? new T.Vector3(0, 0.05, -1)
+ ? cameraDirection.set(0, 0.05, -1)
: state.level === 'motherboard' && state.explode < 15 && !focus
- ? new T.Vector3(0.18, 1.9, 0.55)
+ ? cameraDirection.set(0.18, 1.9, 0.55)
: // Look down on something flat like a card; stand nearer eye
// level for something tall like a tower, or the lid is all
// you see.
- 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,
- );
- direction.normalize();
- // Fit the bounding sphere rather than guessing from width and height: a
- // flat card and a tall tower then frame the same way, at any aspect ratio.
- 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);
+ defaultCameraDirection(size, cameraDirection);
// A selected part needs breathing room inside the smaller stage left by
// the detail panel. The old 0.8 multiplier cropped focused parts at every
// edge, even though the ordinary whole-model fit looked intentional.
@@ -383,10 +204,16 @@ export function createViewer(
camera.aspect < 1
? 1.05
: 0.8;
- const d = (radius / Math.sin(Math.min(vertical, horizontal) / 2)) * padding;
- targetPosition
- .copy(targetLook)
- .addScaledVector(direction, Math.max(focus ? 3.2 : 6, d));
+ fitCameraToBounds(
+ bounds,
+ camera,
+ targetPosition,
+ targetLook,
+ direction,
+ v,
+ padding,
+ focus ? 3.2 : 6,
+ );
}
function boxFor(p: Piece, target: T.Box3) {
// Use the part's authored bounds instead of measuring its descendants on
@@ -394,14 +221,12 @@ 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),
);
}
- let lastTime = performance.now();
- const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let animated: T.Object3D[] = [];
/**
* The airflow groups' own tick functions. Gathered with the rotors because
@@ -410,7 +235,7 @@ export function createViewer(
*/
let flows: ((seconds: number, strength: number) => void)[] = [];
const collectAnimations = () => {
- animated = [];
+ animated = collectAnimatedObjects(model.root);
flows = [];
model.root.traverse((object) => {
if (object.userData.airflowTick)
@@ -420,18 +245,10 @@ export function createViewer(
strength: number,
) => void,
);
- if (object.userData.spinRate || object.userData.seekAmplitude) {
- object.userData.restRotationY ??= object.rotation.y;
- animated.push(object);
- }
});
};
collectAnimations();
- function render() {
- if (disposed) return;
- const now = performance.now(),
- dt = Math.min(1, (now - lastTime) / 1000);
- lastTime = now;
+ function render(seconds: number, dt: number, reducedMotion: boolean) {
amount = reducedMotion
? state.explode / 100
: T.MathUtils.damp(amount, state.explode / 100, 10, dt);
@@ -471,10 +288,6 @@ export function createViewer(
let selected = false;
selectedBox.box.makeEmpty();
for (const p of model.pieces) {
- const dst = destination(p, amount);
- const reveal = p.reveal
- ? smoothstep(p.reveal, p.reveal + 0.07, amount)
- : 1;
// Everything but the part being opened clears away, then the new scale
// grows back in. Multiplying the scale keeps explode and reveal intact.
let ramp = 1;
@@ -485,14 +298,8 @@ export function createViewer(
? 1
: 1 - ease(dive.t)
: ease(dive.t);
- const s = p.visible ? dst.scale * reveal * ramp : 0;
- 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));
+ applyPiecePose(p, state.level, amount, stageScratch, ramp);
if (p.batch) {
- matrix.compose(p.object.position, quat, scale.setScalar(s));
- p.batch.setMatrixAt(p.index!, matrix);
color.set(
matches(p, state.selection)
? '#ffd790'
@@ -502,7 +309,7 @@ export function createViewer(
);
p.batch.setColorAt(p.index!, color);
batches.add(p.batch);
- } else p.object.visible = p.visible;
+ }
if (matches(p, state.selection) && p.visible) {
boxFor(p, selectionBounds);
selectedBox.box.union(selectionBounds);
@@ -511,18 +318,7 @@ export function createViewer(
}
refreshBatches(batches);
if (!reducedMotion && animated.length) {
- const seconds = now / 1000;
- for (const object of animated) {
- const rest = object.userData.restRotationY as number;
- if (object.userData.spinRate)
- object.rotation.y =
- rest + seconds * (object.userData.spinRate as number);
- else
- object.rotation.y =
- rest +
- Math.sin(seconds * 1.35) *
- (object.userData.seekAmplitude as number);
- }
+ animateObjects(animated, seconds);
// Operating parts keep the scene alive. requestAnimationFrame pauses in
// background tabs, and reduced-motion users receive the static model.
changed = true;
@@ -538,7 +334,7 @@ export function createViewer(
? airflowStrength(amount)
: 0;
// Reduced motion gets the chevrons, standing still.
- for (const tick of flows) tick(reducedMotion ? 0 : now / 1000, strength);
+ for (const tick of flows) tick(reducedMotion ? 0 : seconds, strength);
if (strength > 0.002 && !reducedMotion) changed = true;
}
selectedBox.visible = selected;
@@ -562,18 +358,16 @@ export function createViewer(
host.dataset.drawCalls = String(renderer.info.render.calls);
host.dataset.triangles = String(renderer.info.render.triangles);
host.dataset.explode = String(Math.round(amount * 100));
- if (changed || settle-- > 0) frameId = requestAnimationFrame(render);
- else frameId = 0;
+ return changed;
}
- function wake() {
- settle = 35;
- if (!frameId) frameId = requestAnimationFrame(render);
- }
- controls.addEventListener('start', () => {
- autoCamera = false;
- wake();
- });
- controls.addEventListener('change', wake);
+ const loop = createStageLoop(
+ controls,
+ () => {
+ autoCamera = false;
+ },
+ render,
+ );
+ const wake = loop.wake;
const resize = () => {
const w = host.clientWidth,
h = host.clientHeight;
@@ -689,12 +483,6 @@ export function createViewer(
callbacks.hover(null, 0, 0, false);
wake();
}
- function lost(e: Event) {
- e.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);
@@ -702,7 +490,6 @@ export function createViewer(
canvas.addEventListener('pointerleave', leave);
canvas.addEventListener('pointercancel', leave);
canvas.addEventListener('contextmenu', inspect);
- canvas.addEventListener('webglcontextlost', lost);
refresh();
resize();
return {
@@ -742,26 +529,20 @@ export function createViewer(
wake();
},
dispose() {
- disposed = true;
- cancelAnimationFrame(frameId);
+ loop.dispose();
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('contextmenu', inspect);
- canvas.removeEventListener('webglcontextlost', lost);
disposeModel();
selectedBox.geometry.dispose();
(selectedBox.material as T.Material).dispose();
hoverBox.geometry.dispose();
(hoverBox.material as T.Material).dispose();
- env.dispose();
- key.shadow.map?.dispose();
- renderer.dispose();
- canvas.remove();
+ runtime.dispose();
},
};
}
diff --git a/lib/stage-runtime.ts b/lib/stage-runtime.ts
new file mode 100644
index 0000000..6fc2e5c
--- /dev/null
+++ b/lib/stage-runtime.ts
@@ -0,0 +1,212 @@
+import * as T from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
+import { isPhysical, type LevelId } from './levels.ts';
+
+/** Shared WebGL, lighting, camera, and controls owned by every model stage. */
+export function createStageRuntime(
+ host: HTMLDivElement,
+ onError: (message: string) => void,
+) {
+ const scene = new T.Scene();
+ const compact = matchMedia('(pointer: coarse)').matches || innerWidth < 900;
+ const renderer = new T.WebGLRenderer({
+ antialias: true,
+ alpha: true,
+ powerPreference: 'high-performance',
+ });
+ renderer.setPixelRatio(Math.min(devicePixelRatio, compact ? 1.5 : 2));
+ 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.domElement.tabIndex = -1;
+ host.appendChild(renderer.domElement);
+
+ const pmrem = new T.PMREMGenerator(renderer);
+ const room = new RoomEnvironment();
+ const environment = pmrem.fromScene(room, 0.04);
+ scene.environment = environment.texture;
+ room.dispose();
+ pmrem.dispose();
+
+ const fill = new T.HemisphereLight(0xccd9e2, 0x23211f, 0.3);
+ scene.add(fill);
+ const key = new T.DirectionalLight(0xfff2e2, 2.05);
+ key.position.set(-5, 7.5, 9);
+ key.castShadow = true;
+ key.shadow.mapSize.set(compact ? 1024 : 2048, compact ? 1024 : 2048);
+ 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.75);
+ rim.position.set(-6, 3.5, -5);
+ scene.add(rim);
+ const kick = new T.DirectionalLight(0xa2bdcf, 0.5);
+ 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 contextLost = (event: Event) => {
+ event.preventDefault();
+ onError('The 3D context was interrupted. Reload the viewer to continue.');
+ };
+ renderer.domElement.addEventListener('webglcontextlost', contextLost);
+
+ return {
+ scene,
+ renderer,
+ environment,
+ key,
+ rim,
+ kick,
+ camera,
+ controls,
+ canvas: renderer.domElement,
+ dispose() {
+ renderer.domElement.removeEventListener('webglcontextlost', contextLost);
+ controls.dispose();
+ environment.dispose();
+ key.shadow.map?.dispose();
+ renderer.dispose();
+ renderer.forceContextLoss();
+ renderer.domElement.remove();
+ },
+ };
+}
+
+export type StageRuntime = ReturnType;
+
+/** Shared demand-driven frame scheduler and OrbitControls wake-up policy. */
+export function createStageLoop(
+ controls: OrbitControls,
+ onControlStart: () => void,
+ draw: (seconds: number, delta: number, reducedMotion: boolean) => boolean,
+) {
+ let frameId = 0,
+ remaining = 0,
+ lastTime = performance.now();
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ function render(now: number) {
+ const delta = Math.min(1, (now - lastTime) / 1000);
+ lastTime = now;
+ if (draw(now / 1000, delta, reducedMotion) || remaining) {
+ remaining = Math.max(0, remaining - 1);
+ frameId = requestAnimationFrame(render);
+ } else frameId = 0;
+ }
+
+ function wake(frames = 35) {
+ remaining = Math.max(remaining, frames);
+ if (!frameId) frameId = requestAnimationFrame(render);
+ }
+
+ const start = () => {
+ onControlStart();
+ wake();
+ };
+ const change = () => wake();
+ controls.addEventListener('start', start);
+ controls.addEventListener('change', change);
+
+ return {
+ wake,
+ dispose() {
+ cancelAnimationFrame(frameId);
+ controls.removeEventListener('start', start);
+ controls.removeEventListener('change', change);
+ },
+ };
+}
+
+export function configureStageLighting(runtime: StageRuntime, level: LevelId) {
+ const hardware = isPhysical(level);
+ runtime.scene.environmentIntensity =
+ level === 'pc' ? 0.82 : hardware ? 1.05 : 0.48;
+ runtime.key.intensity = level === 'pc' ? 1.85 : hardware ? 2.2 : 0.85;
+ runtime.rim.intensity = hardware ? 1.6 : 0.7;
+ runtime.kick.intensity = hardware ? 0.55 : 0.28;
+}
+
+export function collectAnimatedObjects(...roots: T.Object3D[]) {
+ const animated: T.Object3D[] = [];
+ for (const root of roots)
+ root.traverse((object) => {
+ if (!object.userData.spinRate && !object.userData.seekAmplitude) return;
+ object.userData.restRotationY ??= object.rotation.y;
+ animated.push(object);
+ });
+ return animated;
+}
+
+export function animateObjects(objects: T.Object3D[], seconds: number) {
+ for (const object of objects) {
+ 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);
+ }
+}
+
+export function defaultCameraDirection(size: T.Vector3, target: T.Vector3) {
+ return target.set(
+ 0.7,
+ T.MathUtils.lerp(
+ 1,
+ 0.44,
+ Math.min(1, size.y / Math.max(size.x, size.z, 0.001)),
+ ),
+ 1.2,
+ );
+}
+
+export function fitCameraToBounds(
+ bounds: T.Box3,
+ camera: T.PerspectiveCamera,
+ targetPosition: T.Vector3,
+ targetLook: T.Vector3,
+ direction: T.Vector3,
+ size: T.Vector3,
+ padding = 1,
+ minimumDistance = 6,
+) {
+ bounds.getCenter(targetLook);
+ bounds.getSize(size);
+ direction.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)) * padding;
+ targetPosition
+ .copy(targetLook)
+ .addScaledVector(direction, Math.max(minimumDistance, distance));
+ return size;
+}
diff --git a/tests/comparison.test.ts b/tests/comparison.test.ts
new file mode 100644
index 0000000..f03e22a
--- /dev/null
+++ b/tests/comparison.test.ts
@@ -0,0 +1,209 @@
+import { after, test } from 'node:test';
+import assert from 'node:assert/strict';
+import * as T from 'three';
+import {
+ comparisonGroupIds,
+ comparisonGroups,
+ comparisonItem,
+ comparisonLevels,
+ comparisonPanePoint,
+ initialComparisonState,
+ selectComparisonGroup,
+ selectComparisonItem,
+ setComparisonActiveSide,
+ setComparisonExplode,
+ swapComparisonSides,
+ type ComparisonLevel,
+ validateComparisonCatalogue,
+} from '../lib/comparison-state.ts';
+import { levels } from '../lib/levels.ts';
+import { manifest } from '../lib/manifest.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 allComparisonLevels = comparisonGroupIds.flatMap(comparisonLevels);
+const comparisonModels = Object.fromEntries(
+ allComparisonLevels.map((level) => [level, buildModel(level)]),
+) as Record>;
+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.equal(initialComparisonState.group, 'gpu');
+ assert.notEqual(initialComparisonState.left, initialComparisonState.right);
+ validateComparisonCatalogue();
+});
+
+await test('comparison models and catalogue roots are discovered from canonical data', () => {
+ assert.deepEqual(comparisonLevels('gpu'), ['card', 'rx9070', 'arcb580']);
+ assert.deepEqual(comparisonLevels('psu'), ['psu', 'psubronze']);
+ assert.deepEqual(comparisonLevels('cpu'), ['ryzen', 'corei9']);
+ for (const group of comparisonGroupIds)
+ for (const level of comparisonLevels(group)) {
+ assert.equal(levels[level].comparisonGroup, group);
+ assert.equal(
+ manifest.filter(
+ (concept) => concept.level === level && concept.open === level,
+ ).length,
+ 1,
+ );
+ }
+});
+
+await test('comparison transitions preserve unrelated state', () => {
+ const open = { ...initialComparisonState, specsOpen: true, explode: 42 };
+ const selected = selectComparisonItem(open, 'right', 'arcb580');
+ assert.deepEqual(selected, { ...open, right: 'arcb580' });
+ assert.equal(selectComparisonItem(selected, 'left', 'arcb580'), selected);
+ assert.equal(selectComparisonItem(selected, 'left', 'psu'), selected);
+ assert.deepEqual(swapComparisonSides(selected), {
+ ...selected,
+ left: 'arcb580',
+ right: 'card',
+ });
+ assert.deepEqual(setComparisonActiveSide(open, 'right'), {
+ ...open,
+ activeSide: 'right',
+ });
+ assert.deepEqual(setComparisonExplode(open, 140), {
+ ...open,
+ explode: 100,
+ });
+ assert.deepEqual(selectComparisonGroup(open, 'psu'), {
+ ...open,
+ group: 'psu',
+ left: 'psu',
+ right: 'psubronze',
+ activeSide: 'left',
+ explode: 0,
+ specsOpen: false,
+ });
+});
+
+await test('every comparison group resolves complete catalogue-backed specs', () => {
+ for (const group of comparisonGroupIds)
+ for (const level of comparisonLevels(group)) {
+ const item = comparisonItem(group, level);
+ assert.equal(item.level, level);
+ assert.equal(item.specs.length, comparisonGroups[group].rows.length);
+ assert.ok(item.specs.every((row) => row.value.trim().length > 0));
+ }
+ assert.equal(
+ comparisonItem('gpu', 'card').specs.find((row) => row.id === 'boardPower')
+ ?.value,
+ '575 W',
+ );
+ assert.equal(
+ comparisonItem('gpu', 'card').specs.find((row) => row.id === 'interface')
+ ?.value,
+ 'PCI Express 5.0',
+ );
+ assert.equal(
+ comparisonItem('psu', 'psu').specs.find((row) => row.id === 'output')
+ ?.value,
+ '850 W',
+ );
+ assert.equal(
+ comparisonItem('cpu', 'corei9').specs.find((row) => row.id === 'socket')
+ ?.value,
+ 'LGA 1851',
+ );
+ assert.equal(
+ comparisonItem('gpu', '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 every configured pair', () => {
+ for (const group of comparisonGroupIds)
+ for (const amount of [0, 0.5, 1]) {
+ const levels = comparisonLevels(group);
+ const boxes = levels.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),
+ `${group} minimum escaped at ${amount}`,
+ );
+ assert.ok(
+ common.containsPoint(box.max),
+ `${group} 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');
+});