diff --git a/app/components/AnatomyApp.tsx b/app/components/AnatomyApp.tsx
index a133e79..80fb10b 100644
--- a/app/components/AnatomyApp.tsx
+++ b/app/components/AnatomyApp.tsx
@@ -7,6 +7,7 @@ import {
BookOpen,
Bookmark,
BrainCircuit,
+ Check,
ChevronDown,
CircleHelp,
Compass,
@@ -139,11 +140,16 @@ export function AnatomyApp() {
setMobileLibrary(false)}>
-
+ {/* Picking an organ is a single choice among many, so the list is a
+ radio group — the check mark has a programmatic equivalent rather
+ than being colour and an icon alone. */}
+
{filteredOrgans.map((item) => (
selectOrgan(item.id)}
onPointerEnter={() => prefetchOrgan(item.id)}
@@ -154,7 +160,7 @@ export function AnatomyApp() {
{item.name} {item.system}
- {organId === item.id && }
+ {organId === item.id && }
))}
diff --git a/app/components/OrganViewer.tsx b/app/components/OrganViewer.tsx
index 35a02f4..4fac741 100644
--- a/app/components/OrganViewer.tsx
+++ b/app/components/OrganViewer.tsx
@@ -4,16 +4,19 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
Box,
CircleDashed,
+ Grid2x2,
Layers3,
Maximize2,
RotateCcw,
- ScanLine,
Search,
Sparkles,
X,
} from "lucide-react";
import type { Hotspot, Organ } from "../lib/anatomy-data";
-import type { AnatomyViewer } from "../lib/three/viewer";
+import type { AnatomyViewer, SectionAxis } from "../lib/three/viewer";
+import { SectionControls } from "./SectionControls";
+
+const SECTION_DEFAULTS = { axis: "free" as SectionAxis, depth: 0, flipped: false, showPlane: true };
type Props = {
organ: Organ;
@@ -32,7 +35,10 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState(0);
const [slowLoad, setSlowLoad] = useState(false);
- const [activeTool, setActiveTool] = useState
(null);
+ // The three viewer tools are independent toggles, so a single "active tool"
+ // could only ever describe one of them while the other two stayed on unseen.
+ const [active, setActive] = useState({ isolate: false, section: false, wireframe: false });
+ const [section, setSection] = useState(SECTION_DEFAULTS);
// A typical organ is ready well inside a second — flashing a loading panel for
// that reads as jank. It only appears if the fetch is genuinely slow; the flag
@@ -101,25 +107,62 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
if (!viewer) return;
if (tool === "rotate") onAutoRotate(!autoRotate);
if (tool === "zoom") viewer.zoom(-1);
- if (tool === "isolate") setActiveTool(viewer.toggleIsolate() ? tool : null);
- if (tool === "section") setActiveTool(viewer.toggleCrossSection() ? tool : null);
- if (tool === "layers") setActiveTool(viewer.toggleLayers() ? tool : null);
+ if (tool === "isolate") setActive((state) => ({ ...state, isolate: viewer.toggleIsolate() }));
+ if (tool === "section") {
+ const on = viewer.toggleCrossSection();
+ setActive((state) => ({ ...state, section: on }));
+ // A specimen that keeps turning is no use while you are reading its cut
+ // face — but this goes through the normal control, so it can be turned
+ // straight back on rather than being locked out.
+ if (on && autoRotate) onAutoRotate(false);
+ }
+ if (tool === "wireframe") setActive((state) => ({ ...state, wireframe: viewer.toggleWireframe() }));
if (tool === "compare") onCompare();
if (tool === "reset") {
+ // reset() clears every tool in the viewer too, so mirroring it here keeps
+ // the buttons honest.
viewer.reset();
- setActiveTool(null);
+ setActive({ isolate: false, section: false, wireframe: false });
+ setSection(SECTION_DEFAULTS);
}
};
+ const handleAxis = (axis: SectionAxis) => {
+ viewerRef.current?.setSectionAxis(axis);
+ setSection((state) => ({ ...state, axis }));
+ };
+
+ const handleDepth = (depth: number) => {
+ viewerRef.current?.setSectionDepth(depth);
+ setSection((state) => ({ ...state, depth }));
+ };
+
+ const handleFlip = () => {
+ const flipped = viewerRef.current?.toggleSectionFlip() ?? false;
+ setSection((state) => ({ ...state, flipped }));
+ };
+
+ const handleShowPlane = (shown: boolean) => {
+ const showPlane = viewerRef.current?.setSectionPlaneVisible(shown) ?? true;
+ setSection((state) => ({ ...state, showPlane }));
+ };
+
const tools = [
- { id: "rotate", label: "Rotate", icon: RotateCcw },
- { id: "zoom", label: "Zoom", icon: Search },
- { id: "isolate", label: "Isolate", icon: CircleDashed },
- { id: "section", label: "Cross-section", icon: ScanLine },
- { id: "layers", label: "Layers", icon: Layers3 },
- { id: "compare", label: "Compare", icon: Box },
- { id: "reset", label: "Reset", icon: RotateCcw },
- ];
+ { id: "rotate", label: "Rotate", icon: RotateCcw, toggle: true },
+ { id: "zoom", label: "Zoom", icon: Search, toggle: false },
+ { id: "isolate", label: "Isolate", icon: CircleDashed, toggle: true },
+ // The layered metaphor belongs to the tool that actually opens the organ up.
+ { id: "section", label: "Cross-section", icon: Layers3, toggle: true },
+ { id: "wireframe", label: "Wireframe", icon: Grid2x2, toggle: true },
+ { id: "compare", label: "Compare", icon: Box, toggle: true },
+ { id: "reset", label: "Reset", icon: RotateCcw, toggle: false },
+ ] as const;
+
+ const isActive = (id: string) => {
+ if (id === "compare") return compare;
+ if (id === "rotate") return autoRotate;
+ return id in active && active[id as keyof typeof active];
+ };
return (
@@ -127,13 +170,13 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
- {tools.map(({ id, label, icon: Icon }) => (
+ {tools.map(({ id, label, icon: Icon, toggle }) => (
handleTool(id)}
- aria-pressed={activeTool === id || (id === "compare" && compare)}
+ aria-pressed={toggle ? isActive(id) : undefined}
title={label}
>
@@ -142,6 +185,19 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
))}
+ {active.section && (
+
+ )}
+
Tip
Drag to rotate Scroll to zoom Click a dot to learn more
diff --git a/app/components/SectionControls.tsx b/app/components/SectionControls.tsx
new file mode 100644
index 0000000..4cbcc9f
--- /dev/null
+++ b/app/components/SectionControls.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { FlipHorizontal2 } from "lucide-react";
+import type { SectionAxis } from "../lib/three/viewer";
+
+/** The three named planes anatomy is taught in, plus one that follows the view.
+ * The hints are the point: they are the vocabulary a learner needs. */
+const AXES: { id: SectionAxis; label: string; hint: string }[] = [
+ { id: "sagittal", label: "Sagittal", hint: "Divides left from right" },
+ { id: "coronal", label: "Coronal", hint: "Divides front from back" },
+ { id: "axial", label: "Axial", hint: "Divides top from bottom" },
+ { id: "free", label: "Free", hint: "Squares up to the current view" },
+];
+
+type Props = {
+ axis: SectionAxis;
+ depth: number;
+ flipped: boolean;
+ showPlane: boolean;
+ onAxis: (axis: SectionAxis) => void;
+ onDepth: (depth: number) => void;
+ onFlip: () => void;
+ onShowPlane: (shown: boolean) => void;
+};
+
+export function SectionControls({ axis, depth, flipped, showPlane, onAxis, onDepth, onFlip, onShowPlane }: Props) {
+ const active = AXES.find((item) => item.id === axis) ?? AXES[3];
+ return (
+
+
+ {AXES.map(({ id, label, hint }) => (
+ onAxis(id)}
+ title={hint}
+ >
+ {label}
+
+ ))}
+
+
+
+ Depth
+ onDepth(Number(event.target.value))}
+ aria-label="Cutting plane depth"
+ // Percentages read better than the -1..1 the viewer works in.
+ aria-valuetext={`${Math.round(((depth + 1) / 2) * 100)}% through the specimen`}
+ />
+
+
+
+
+
+
+
+ onShowPlane(event.target.checked)}
+ />
+ Plane
+
+
+
{active.hint}
+
+ );
+}
diff --git a/app/globals.css b/app/globals.css
index e7a866b..e463687 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -190,7 +190,9 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.organ-glyph img { width: 100%; height: 100%; object-fit: cover; mix-blend-mode: multiply; transform: scale(1.14); }
.organ-item b { display: block; font: 500 17px/1.1 var(--font-serif), serif; }
.organ-item small { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; }
-.organ-item .favorite { color: #ef8d7d; }
+/* Marks the selected organ. A heart lived here, which read as "favourited" —
+ the Bookmark control in the panel heading owns that meaning. */
+.organ-item .selected-mark { color: color-mix(in srgb, var(--item-accent), #5b3a30 22%); }
/* `:not(.organ-card-image)` keeps the clickable artwork out of the footer
button styling — both are direct children of the card. */
.view-all, .learning-cards article > button:not(.organ-card-image) {
@@ -248,6 +250,59 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.tool-button.active { color: var(--coral); background: rgba(238,124,106,.08); }
.tool-button:nth-last-child(1) { border-top: 1px solid var(--line); border-radius: 0 0 12px 12px; }
+/* Sits along the bottom of the viewer, between the caption and the auto-rotate
+ switch, and only while the cross-section tool is open. */
+.section-controls {
+ position: absolute;
+ z-index: 3;
+ left: 50%;
+ bottom: 54px;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 9px 13px;
+ border: 1px solid var(--line);
+ border-radius: 15px;
+ background: rgba(255,251,244,.9);
+ backdrop-filter: blur(18px);
+ box-shadow: 0 12px 30px rgba(75,54,40,.1);
+ animation: section-in .32s ease both;
+}
+@keyframes section-in { from { opacity: 0; transform: translate(-50%, 8px); } }
+.section-axes { display: flex; gap: 2px; }
+.section-axes button {
+ border: 0; border-radius: 9px; padding: 6px 10px; background: transparent;
+ cursor: pointer; color: var(--muted); font: 500 11px/1 var(--font-serif), serif;
+}
+.section-axes button.active { color: var(--coral); background: rgba(238,124,106,.1); }
+.section-depth { display: flex; align-items: center; gap: 8px; }
+.section-depth span { color: var(--muted); font: 500 10px/1 var(--font-serif), serif; letter-spacing: .06em; text-transform: uppercase; }
+.section-depth input {
+ width: 132px; height: 4px; margin: 0; padding: 0;
+ appearance: none; border-radius: 3px; cursor: pointer;
+ background: linear-gradient(90deg, color-mix(in srgb, var(--coral), transparent 55%), var(--coral));
+}
+.section-depth input::-webkit-slider-thumb {
+ appearance: none; width: 15px; height: 15px; border-radius: 50%;
+ background: #fffdf9; border: 2px solid var(--coral); cursor: pointer;
+ box-shadow: 0 2px 5px rgba(93,69,43,.22);
+}
+.section-depth input::-moz-range-thumb {
+ width: 15px; height: 15px; border-radius: 50%;
+ background: #fffdf9; border: 2px solid var(--coral); cursor: pointer;
+}
+.section-flip {
+ display: grid; place-items: center; width: 30px; height: 30px;
+ border: 1px solid var(--line); border-radius: 9px; background: transparent;
+ cursor: pointer; color: var(--muted);
+}
+.section-flip.active { color: var(--coral); border-color: color-mix(in srgb, var(--coral), transparent 60%); background: rgba(238,124,106,.09); }
+.section-plane { display: flex; align-items: center; gap: 6px; cursor: pointer; }
+.section-plane input { width: 14px; height: 14px; margin: 0; accent-color: var(--coral); cursor: pointer; }
+.section-plane span { color: var(--muted); font: 500 10px/1 var(--font-serif), serif; letter-spacing: .06em; text-transform: uppercase; }
+.section-controls em { max-width: 150px; color: var(--muted); font: italic 11px/1.3 var(--font-serif), serif; }
+
.tip-note {
position: absolute;
right: 16px;
@@ -535,6 +590,15 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs
.learning-cards article { border-radius: 20px; }
.curiosity-card { display: none !important; }
.viewer-tools { top: auto; bottom: 12px; left: 10px; right: 10px; transform: none; width: auto; height: 61px; flex-direction: row; justify-content: space-around; padding: 4px; border-radius: 15px; }
+ /* Stacks above the caption/auto-rotate row, which itself sits above the tool
+ bar at this width. The plane hint is the first thing to go — the labels
+ already carry it. */
+ .section-controls { bottom: 128px; left: 10px; right: 10px; transform: none; gap: 8px; padding: 8px 10px; justify-content: space-between; }
+ .section-controls em { display: none; }
+ .section-axes button { padding: 6px 7px; font-size: 10px; }
+ .section-depth span { display: none; }
+ .section-depth input { width: 88px; }
+ @keyframes section-in { from { opacity: 0; transform: translateY(8px); } }
.tool-button { width: 51px; height: 51px; }
.tool-button:nth-last-child(1) { border: 0; border-radius: 12px; }
.tool-button span { display: none; }
diff --git a/app/layout.tsx b/app/layout.tsx
index 696d3aa..09dd7d5 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -16,7 +16,7 @@ const OG_IMAGE = {
url: "/og.jpg",
width: 1200,
height: 675,
- alt: "An anatomical heart specimen floating above a plinth, beside the Anatomy Atelier wordmark",
+ alt: "An anatomical heart specimen floating in warm light, beside the Anatomy Atelier wordmark",
};
/**
diff --git a/app/lib/three/hotspots.ts b/app/lib/three/hotspots.ts
index ea43674..5313e20 100644
--- a/app/lib/three/hotspots.ts
+++ b/app/lib/three/hotspots.ts
@@ -188,7 +188,15 @@ export class HotspotLayer {
* ring. Returns false while values are still easing so the viewer knows it
* has to schedule another frame.
*/
- update(camera: THREE.Camera, delta: number, selectedId: string | null, hoveredId: string | null) {
+ update(
+ camera: THREE.Camera,
+ delta: number,
+ selectedId: string | null,
+ hoveredId: string | null,
+ /** Cutting plane, in the same space as the anchors. Markers on the half
+ * being taken away fade out with it instead of floating in the gap. */
+ clip: THREE.Plane | null = null,
+ ) {
if (!this.markers.length) return true;
this.time += delta;
this.group.updateWorldMatrix(true, false);
@@ -217,7 +225,8 @@ export class HotspotLayer {
const radius = this.outward.length();
this.toCamera.copy(camera.position).sub(this.world).normalize();
const facing = radius > 1e-4 ? this.outward.divideScalar(radius).dot(this.toCamera) : 1;
- const target = THREE.MathUtils.smoothstep(facing, -0.05, 0.3);
+ const clipped = clip !== null && clip.distanceToPoint(marker.anchor) < 0;
+ const target = clipped ? 0 : THREE.MathUtils.smoothstep(facing, -0.05, 0.3);
const active = marker.hotspot.id === selectedId || marker.hotspot.id === hoveredId;
const emphasisTarget = active ? 1 : 0;
diff --git a/app/lib/three/loaders.ts b/app/lib/three/loaders.ts
index 586c16f..16bdf8f 100644
--- a/app/lib/three/loaders.ts
+++ b/app/lib/three/loaders.ts
@@ -15,6 +15,12 @@ export type LoadedOrgan = {
pivot: THREE.Group;
meshes: THREE.Mesh[];
mixer: THREE.AnimationMixer | null;
+ /**
+ * Extent in that same space. Organs are fitted by their longest axis, so a
+ * flat organ is nowhere near FIT_SIZE on its other two — the cutting plane
+ * needs the real box to know how far it has to travel to cross this organ.
+ */
+ bounds: THREE.Box3;
};
export class AnatomyAssetManager {
@@ -78,6 +84,11 @@ export class AnatomyAssetManager {
model.scale.setScalar(scale);
model.position.copy(center.multiplyScalar(-scale));
+ // Measured while the model is still parentless, so its world matrix is its
+ // own — which is exactly the space hotspots and the cutting plane work in.
+ model.updateWorldMatrix(false, true);
+ const bounds = new THREE.Box3().setFromObject(model);
+
// The pivot is what the viewer animates and what hotspots are parented to,
// so hotspot coordinates stay in the normalised FIT_SIZE space.
const pivot = new THREE.Group();
@@ -151,7 +162,7 @@ export class AnatomyAssetManager {
gltf.animations.forEach((clip) => mixer?.clipAction(clip).play());
}
- return { url, pivot, meshes, mixer };
+ return { url, pivot, meshes, mixer, bounds };
}
/** Undoes viewer tools (wireframe, clipping, fade) before a cached organ returns. */
@@ -165,6 +176,9 @@ export class AnatomyAssetManager {
material.depthWrite = true;
material.clippingPlanes = null;
material.clipShadows = false;
+ // Cross-section flips these shells to DoubleSide to expose their
+ // interior; a cached organ has to come back single-sided.
+ material.side = THREE.FrontSide;
if (material instanceof THREE.MeshStandardMaterial) material.wireframe = false;
material.needsUpdate = true;
});
diff --git a/app/lib/three/viewer.ts b/app/lib/three/viewer.ts
index e20448a..1bf9c1e 100644
--- a/app/lib/three/viewer.ts
+++ b/app/lib/three/viewer.ts
@@ -2,7 +2,7 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import gsap from "gsap";
import type { Hotspot } from "../anatomy-data";
-import { AnatomyAssetManager, type LoadedOrgan } from "./loaders";
+import { AnatomyAssetManager, FIT_SIZE, type LoadedOrgan } from "./loaders";
import { HotspotLayer } from "./hotspots";
type ViewerCallbacks = {
@@ -10,14 +10,38 @@ type ViewerCallbacks = {
onSelect: (hotspot: Hotspot | null) => void;
};
+/** Which way the cutting plane faces, in the organ's own space. */
+export type SectionAxis = "sagittal" | "coronal" | "axial" | "free";
+
const DOT_PIXELS = 34;
const CAMERA_FOV = 34;
const DEPTH_PREPASS = "depth-prepass";
-const PLINTH_Y = -2.5;
-const PLINTH_TOP = PLINTH_Y + 0.17;
-/** Slightly above eye level, so the plinth reads as a disc the organ sits on
- * rather than an edge-on band across the background. */
-const HOME_CAMERA = { x: 0, y: 1.05, z: 8.2 };
+/**
+ * Anatomical planes, as normals in organ space. Each points along the
+ * direction the discarded half lies in, so the default cut opens towards a
+ * viewer at the home camera rather than presenting its intact back.
+ */
+const SECTION_NORMALS: Record, [number, number, number]> = {
+ sagittal: [-1, 0, 0],
+ coronal: [0, 0, -1],
+ axial: [0, -1, 0],
+};
+/** Depth at which the specimen is still whole — one end of the slider. */
+const SECTION_OPEN = -1;
+/**
+ * How much of the specimen's half-extent the slider actually spans. Held under
+ * 1 so the ends of the travel still leave anatomy on screen instead of an empty
+ * frame that reads as a broken tool.
+ */
+const SECTION_TRAVEL = 0.92;
+/** Gap between the underside of the specimen and its contact shadow. */
+const SHADOW_DROP = 0.16;
+const SHADOW_OPACITY = 0.62;
+/** Studio dressing level while Isolate is on: present, but out of the way. */
+const ISOLATED_DRESSING = 0.12;
+/** The specimen floats free, so the camera sits nearly level with it — there is
+ * no longer a plinth that needs to read as a disc rather than an edge-on band. */
+const HOME_CAMERA = { x: 0, y: 0.3, z: 8.2 };
const HOME_TARGET = { x: 0, y: 0.02, z: 0 };
export class AnatomyViewer {
@@ -30,18 +54,68 @@ export class AnatomyViewer {
private callbacks: ViewerCallbacks;
private container: HTMLElement;
private organ: LoadedOrgan | null = null;
- private plinth!: THREE.Mesh;
private contactShadow!: THREE.Mesh;
+ private particles!: THREE.Points;
+ private glow!: THREE.PointLight;
private frame = 0;
private clock = new THREE.Clock();
private resizeObserver: ResizeObserver;
private intersectionObserver: IntersectionObserver;
- private clipPlane = new THREE.Plane(new THREE.Vector3(-1, 0, 0), 0);
- /** Writes depth only — used to resolve a fading organ to one surface. */
- private depthMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true, depthTest: true });
+ /**
+ * The cut, held in the organ's own space — which is what makes "sagittal"
+ * mean sagittal. `clipPlane` is this plane pushed out to world space, because
+ * that is the only space three.js will clip in.
+ */
+ private localPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);
+ private clipPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);
+ private sectionAxis: SectionAxis = "free";
+ /** Where the user has parked the plane, -1 (whole) to 1 (fully cut away). */
+ private sectionDepth = 0;
+ private sectionFlip = false;
+ /** What is actually on screen — sweeps to `sectionDepth` when the tool opens. */
+ private sweep = { depth: SECTION_OPEN };
+ /** Camera direction captured in organ space when "free" is chosen. */
+ private freeNormal = new THREE.Vector3(0, 0, -1);
+ /**
+ * A ghost of the cutting plane — a tinted pane with an outlined rim — so the
+ * cut reads as a tool with an orientation rather than a mystery bite. Held in
+ * organ space via `helperPose` and pushed to world alongside the clip plane.
+ */
+ private sectionHelper!: THREE.Group;
+ private sectionHelperFill!: THREE.MeshBasicMaterial;
+ private sectionHelperRim!: THREE.LineBasicMaterial;
+ /** Whether the user wants the plane drawn while the tool is open. */
+ private sectionPlaneShown = true;
+ private readonly helperPose = new THREE.Matrix4();
+ private readonly helperPosition = new THREE.Vector3();
+ private readonly helperQuaternion = new THREE.Quaternion();
+ private readonly helperScale = new THREE.Vector3();
+ private static readonly PLANE_FORWARD = new THREE.Vector3(0, 0, 1);
+ private readonly boundsCenter = new THREE.Vector3();
+ private readonly boundsHalf = new THREE.Vector3();
+ private readonly planeNormal = new THREE.Vector3();
+ /**
+ * Writes depth only — used to resolve a fading organ to one surface.
+ * Double-sided so that a cut organ still lays down depth for the interior
+ * surfaces its clipped-away front faces would otherwise have covered.
+ */
+ private depthMaterial = new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ depthWrite: true,
+ depthTest: true,
+ side: THREE.DoubleSide,
+ });
private crossSection = false;
private isolated = false;
+ private wireframe = false;
+
+ /** Y of the contact shadow plane, re-derived from each organ's footprint. */
+ private shadowY = -2.1;
+ /** Facing fade of the shadow, 0 once the camera drops to its plane. */
+ private shadowFade = 1;
+ /** Studio dressing level: 1 normally, near-0 while Isolate is on. */
+ private dressing = { value: 1 };
private width = 1;
private height = 1;
@@ -114,6 +188,7 @@ export class AnatomyViewer {
this.assets = new AnatomyAssetManager(this.renderer);
this.buildEnvironment();
+ this.buildSectionHelper();
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(container);
@@ -157,32 +232,29 @@ export class AnatomyViewer {
const warm = new THREE.PointLight(0xff8d70, 0.72, 11, 2);
warm.position.set(-3, -1.4, 3.5);
this.scene.add(warm);
- const glow = new THREE.PointLight(0xee7c6a, 0.5, 8, 2);
- glow.name = "organ-glow";
- glow.position.set(2.8, 0.4, 2.8);
- this.scene.add(glow);
+ this.glow = new THREE.PointLight(0xee7c6a, 0.5, 8, 2);
+ this.glow.name = "organ-glow";
+ this.glow.position.set(2.8, 0.4, 2.8);
+ this.scene.add(this.glow);
this.scene.environment = this.buildEnvironmentMap();
- this.plinth = new THREE.Mesh(
- new THREE.CylinderGeometry(2.3, 2.48, 0.34, 56),
- new THREE.MeshStandardMaterial({ color: 0xead7c1, roughness: 0.78, metalness: 0 }),
- );
- this.plinth.position.y = PLINTH_Y;
- this.scene.add(this.plinth);
-
+ // The specimen floats: a solid plinth used to sit here, and it walled off
+ // the underside of every organ from a full 360° orbit. What is left is a
+ // soft shadow disc that grounds the organ from ordinary viewing angles and
+ // fades out before the camera can pass through it.
this.contactShadow = new THREE.Mesh(
new THREE.PlaneGeometry(4.2, 4.2),
new THREE.MeshBasicMaterial({
map: contactShadowTexture(),
transparent: true,
depthWrite: false,
- opacity: 0.62,
+ opacity: SHADOW_OPACITY,
toneMapped: false,
}),
);
this.contactShadow.rotation.x = -Math.PI / 2;
- this.contactShadow.position.y = PLINTH_TOP + 0.005;
+ this.contactShadow.position.y = this.shadowY;
this.contactShadow.renderOrder = 1;
this.scene.add(this.contactShadow);
@@ -194,12 +266,11 @@ export class AnatomyViewer {
}
const particleGeometry = new THREE.BufferGeometry();
particleGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
- this.scene.add(
- new THREE.Points(
- particleGeometry,
- new THREE.PointsMaterial({ color: 0xe7a18e, size: 0.013, transparent: true, opacity: 0.16 }),
- ),
+ this.particles = new THREE.Points(
+ particleGeometry,
+ new THREE.PointsMaterial({ color: 0xe7a18e, size: 0.013, transparent: true, opacity: 0.16 }),
);
+ this.scene.add(this.particles);
}
/** A tiny warm-to-cool gradient probe: better material response than a bare
@@ -233,6 +304,38 @@ export class AnatomyViewer {
return environment;
}
+ /**
+ * A unit quad, posed and scaled per organ by `helperPose`. The fill is nearly
+ * transparent so the anatomy behind it stays legible; the rim carries the
+ * orientation. Neither material clips, so the pane spans the whole cut even
+ * where the specimen has been carved away.
+ */
+ private buildSectionHelper() {
+ const geometry = new THREE.PlaneGeometry(1, 1);
+ this.sectionHelperFill = new THREE.MeshBasicMaterial({
+ color: 0xee7c6a,
+ transparent: true,
+ opacity: 0.12,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ toneMapped: false,
+ });
+ this.sectionHelperRim = new THREE.LineBasicMaterial({
+ color: 0xee7c6a,
+ transparent: true,
+ opacity: 0.55,
+ toneMapped: false,
+ });
+ this.sectionHelper = new THREE.Group();
+ this.sectionHelper.add(new THREE.Mesh(geometry, this.sectionHelperFill));
+ this.sectionHelper.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry), this.sectionHelperRim));
+ // Posed straight from the organ's world matrix — local maths stays local.
+ this.sectionHelper.matrixAutoUpdate = false;
+ this.sectionHelper.visible = false;
+ this.sectionHelper.renderOrder = 2;
+ this.scene.add(this.sectionHelper);
+ }
+
// ---------------------------------------------------------------- organs
prefetch(url: string) {
@@ -261,6 +364,8 @@ export class AnatomyViewer {
});
this.assets.release(outgoing);
this.organ = null;
+ // Nothing left to cut — the pane comes back with the next organ's clip.
+ this.sectionHelper.visible = false;
this.dirty = true;
}
@@ -283,13 +388,24 @@ export class AnatomyViewer {
this.scene.add(organ.pivot);
organ.pivot.updateWorldMatrix(true, true);
+ // Measured before the dots are parented in, so their billboards cannot
+ // inflate the box the shadow is placed against.
+ this.anchorContactShadow(organ);
+
// Anchor the dots while the organ is still invisible, then play the intro.
this.hotspots.attach(organ.pivot, hotspots, organ.meshes);
this.hotspots.setPixelSize(DOT_PIXELS, this.height, CAMERA_FOV);
- if (this.crossSection) this.applyClipping(true);
+ // Tools are viewer state, not organ state — carry them onto the new organ.
+ // The plane is re-derived because travel is measured per organ.
+ if (this.crossSection) {
+ this.updateSectionPlane();
+ this.applyClipping(true);
+ }
+ if (this.wireframe) this.applyWireframe();
- const glow = this.scene.getObjectByName("organ-glow") as THREE.PointLight | undefined;
- glow?.color.set(accent);
+ this.glow.color.set(accent);
+ this.sectionHelperFill.color.set(accent);
+ this.sectionHelperRim.color.set(accent);
organ.pivot.scale.setScalar(0.58);
organ.pivot.position.z = -1.3;
@@ -305,6 +421,43 @@ export class AnatomyViewer {
.to(this.camera.position, { z: 8.2, duration: 0.9, ease: "power2.out" }, 0.08);
}
+ /**
+ * Drops the shadow just under the specimen. Organs are normalised into a
+ * FIT_SIZE cube by their longest axis, so a flat organ like the skin sits far
+ * shallower than the eyeball — a fixed height would leave its shadow adrift.
+ * Measured at full scale, before the intro animation shrinks the pivot.
+ */
+ private anchorContactShadow(organ: LoadedOrgan) {
+ const bounds = new THREE.Box3().setFromObject(organ.pivot);
+ if (bounds.isEmpty()) return;
+ this.shadowY = bounds.min.y - SHADOW_DROP;
+ this.contactShadow.position.y = this.shadowY;
+ this.dirty = true;
+ }
+
+ /**
+ * Fades the shadow out as the camera drops towards its plane. Without this,
+ * orbiting under the organ — the whole point of removing the plinth — would
+ * put a dark disc between the viewer and the specimen.
+ */
+ private updateContactShadow() {
+ const fade = THREE.MathUtils.smoothstep(this.camera.position.y - this.shadowY, 0.25, 1.7);
+ if (Math.abs(fade - this.shadowFade) < 0.002) return false;
+ this.shadowFade = fade;
+ this.applyDressing();
+ return true;
+ }
+
+ /** Applies the Isolate level (and the shadow's facing fade) to everything in
+ * the scene that is staging rather than specimen. */
+ private applyDressing() {
+ const shadow = this.contactShadow.material as THREE.MeshBasicMaterial;
+ shadow.opacity = SHADOW_OPACITY * this.shadowFade * this.dressing.value;
+ this.contactShadow.visible = shadow.opacity > 0.004;
+ (this.particles.material as THREE.PointsMaterial).opacity = 0.16 * this.dressing.value;
+ this.glow.intensity = 0.5 * this.dressing.value;
+ }
+
private materials(organ: LoadedOrgan) {
const list: THREE.Material[] = [];
organ.meshes.forEach((mesh) => {
@@ -387,6 +540,10 @@ export class AnatomyViewer {
this.applyAutoRotate(now);
if (this.controls.update(delta)) this.dirty = true;
+ if (this.updateContactShadow()) this.dirty = true;
+ // The pivot moves under the intro and reset tweens, and the cut is anchored
+ // to the organ rather than the world, so the world plane is re-derived.
+ if (this.crossSection) this.syncWorldPlane();
if (this.assets.hasAnimation) {
this.assets.update(delta);
this.dirty = true;
@@ -394,7 +551,8 @@ export class AnatomyViewer {
if (this.hoverProbe) this.resolveHover();
if (!this.dirty && now >= this.busyUntil) return;
- if (!this.hotspots.update(this.camera, delta, this.selectedId, this.hoveredId)) this.dirty = true;
+ const clip = this.crossSection ? this.localPlane : null;
+ if (!this.hotspots.update(this.camera, delta, this.selectedId, this.hoveredId, clip)) this.dirty = true;
else this.dirty = false;
if (now < this.busyUntil) this.dirty = true;
@@ -413,6 +571,9 @@ export class AnatomyViewer {
}
private applyAutoRotate(now: number) {
+ // Opening a cut turns rotation off through the normal control rather than
+ // overriding it here, so the toggle never claims to be on while nothing
+ // moves — and someone who does want to turn a sectioned specimen can.
this.controls.autoRotate = this.autoRotateWanted && !this.selectedId && now >= this.interactionUntil;
}
@@ -533,8 +694,17 @@ export class AnatomyViewer {
this.dirty = true;
}
+ /** Returns the viewer to its home framing *and* clears every tool, so the
+ * toolbar can never disagree with what is on screen. */
reset() {
this.select(null);
+ this.setIsolate(false);
+ this.setCrossSection(false);
+ this.setWireframe(false);
+ this.sectionAxis = "free";
+ this.sectionDepth = 0;
+ this.sectionFlip = false;
+ this.sectionPlaneShown = true;
this.tween(this.camera.position, { ...HOME_CAMERA, duration: 0.8, ease: "power3.out" });
this.tween(this.controls.target, { ...HOME_TARGET, duration: 0.8, ease: "power3.out" });
if (this.organ) this.tween(this.organ.pivot.rotation, { x: 0.05, y: -0.28, z: 0, duration: 0.8, ease: "power3.out" });
@@ -549,52 +719,213 @@ export class AnatomyViewer {
}
toggleIsolate() {
- this.isolated = !this.isolated;
- const plinth = this.plinth.material as THREE.MeshStandardMaterial;
- plinth.transparent = true;
- this.tween(plinth, { opacity: this.isolated ? 0.15 : 1, duration: 0.45 });
- this.tween(this.contactShadow.material, { opacity: this.isolated ? 0.08 : 0.55, duration: 0.45 });
+ return this.setIsolate(!this.isolated);
+ }
+
+ /** Isolate strips the staging — shadow, drifting particles, accent glow —
+ * down to almost nothing, leaving the specimen alone in the frame. */
+ setIsolate(enabled: boolean) {
+ if (this.isolated === enabled) return this.isolated;
+ this.isolated = enabled;
+ gsap.killTweensOf(this.dressing);
+ this.busy(0.5);
+ gsap.to(this.dressing, {
+ value: enabled ? ISOLATED_DRESSING : 1,
+ duration: 0.45,
+ ease: "power2.out",
+ onUpdate: () => {
+ this.applyDressing();
+ this.dirty = true;
+ },
+ });
return this.isolated;
}
toggleCrossSection() {
- this.crossSection = !this.crossSection;
- this.applyClipping(this.crossSection);
- gsap.fromTo(
- this.clipPlane,
- { constant: -1.8 },
- {
- constant: this.crossSection ? 0 : -1.8,
- duration: 0.85,
- ease: "power2.inOut",
- onUpdate: () => (this.dirty = true),
+ return this.setCrossSection(!this.crossSection);
+ }
+
+ /**
+ * Sweeps the cutting plane through the specimen. Enabling starts from whole
+ * and wipes in to wherever the depth control is parked; disabling wipes back
+ * out and only then drops the planes, so the organ never snaps back together.
+ */
+ setCrossSection(enabled: boolean) {
+ if (this.crossSection === enabled) return this.crossSection;
+ this.crossSection = enabled;
+ gsap.killTweensOf(this.sweep);
+ if (enabled) {
+ if (this.sectionAxis === "free") this.captureFreeNormal();
+ this.sweep.depth = SECTION_OPEN;
+ this.updateSectionPlane();
+ this.applyClipping(true);
+ }
+ gsap.to(this.sweep, {
+ depth: enabled ? this.sectionDepth : SECTION_OPEN,
+ duration: 0.85,
+ ease: "power2.inOut",
+ onUpdate: () => this.updateSectionPlane(),
+ onComplete: () => {
+ if (!this.crossSection) this.applyClipping(false);
+ this.dirty = true;
},
- );
+ });
this.busy(0.95);
return this.crossSection;
}
+ /** Sagittal, coronal, axial — or a plane square to how you are looking now. */
+ setSectionAxis(axis: SectionAxis) {
+ if (this.sectionAxis === axis && axis !== "free") return;
+ this.sectionAxis = axis;
+ if (axis === "free") this.captureFreeNormal();
+ this.updateSectionPlane();
+ this.busy(0.2);
+ }
+
+ /** -1 leaves the specimen whole, 0 halves it, 1 takes almost all of it. */
+ setSectionDepth(depth: number) {
+ this.sectionDepth = THREE.MathUtils.clamp(depth, -1, 1);
+ if (!this.crossSection) return;
+ // Direct, not tweened: a slider that lags behind the thumb feels broken.
+ gsap.killTweensOf(this.sweep);
+ this.sweep.depth = this.sectionDepth;
+ this.updateSectionPlane();
+ }
+
+ /** Shows or hides the ghost of the cutting plane itself. */
+ setSectionPlaneVisible(shown: boolean) {
+ this.sectionPlaneShown = shown;
+ this.sectionHelper.visible = shown && this.crossSection && !!this.organ;
+ this.dirty = true;
+ return this.sectionPlaneShown;
+ }
+
+ /** Swaps which half is discarded, so both faces are reachable without orbiting. */
+ toggleSectionFlip() {
+ this.sectionFlip = !this.sectionFlip;
+ this.updateSectionPlane();
+ this.busy(0.2);
+ return this.sectionFlip;
+ }
+
+ private sectionNormal() {
+ if (this.sectionAxis === "free") this.planeNormal.copy(this.freeNormal);
+ else this.planeNormal.fromArray(SECTION_NORMALS[this.sectionAxis]);
+ return this.sectionFlip ? this.planeNormal.negate() : this.planeNormal;
+ }
+
+ /**
+ * Rebuilds the organ-space plane from the current axis, flip and sweep, then
+ * pushes it out to world space. Travel is measured against this organ's own
+ * box projected onto the plane normal, so the same slider position means
+ * "halfway through" whether the specimen is an eyeball or a set of lungs —
+ * and every position lands inside the specimen rather than sailing past it.
+ */
+ private updateSectionPlane() {
+ const normal = this.sectionNormal();
+ const bounds = this.organ?.bounds;
+ let mid = 0;
+ let radius = FIT_SIZE / 2;
+ if (bounds && !bounds.isEmpty()) {
+ bounds.getCenter(this.boundsCenter);
+ bounds.getSize(this.boundsHalf).multiplyScalar(0.5);
+ mid = normal.dot(this.boundsCenter);
+ radius =
+ Math.abs(normal.x) * this.boundsHalf.x +
+ Math.abs(normal.y) * this.boundsHalf.y +
+ Math.abs(normal.z) * this.boundsHalf.z;
+ } else {
+ this.boundsCenter.set(0, 0, 0);
+ this.boundsHalf.setScalar(FIT_SIZE / 2);
+ }
+ this.localPlane.normal.copy(normal);
+ this.localPlane.constant = -(mid + this.sweep.depth * radius * SECTION_TRAVEL);
+
+ // The ghost pane rides the same plane: centred over the specimen, facing
+ // the normal, wide enough to cover the largest cut this box can produce.
+ const travel = this.sweep.depth * radius * SECTION_TRAVEL;
+ this.helperPosition.copy(this.boundsCenter).addScaledVector(normal, travel);
+ this.helperQuaternion.setFromUnitVectors(AnatomyViewer.PLANE_FORWARD, normal);
+ this.helperScale.setScalar(this.boundsHalf.length() * 2 * 1.08);
+ this.helperPose.compose(this.helperPosition, this.helperQuaternion, this.helperScale);
+
+ this.syncWorldPlane();
+ this.dirty = true;
+ }
+
+ /** three.js clips in world space, so the organ-space plane is carried through
+ * the pivot each frame — the cut then rides with the specimen. */
+ private syncWorldPlane() {
+ const pivot = this.organ?.pivot;
+ if (!pivot) {
+ this.clipPlane.copy(this.localPlane);
+ this.sectionHelper.matrix.copy(this.helperPose);
+ this.sectionHelper.matrixWorldNeedsUpdate = true;
+ return;
+ }
+ pivot.updateWorldMatrix(true, false);
+ this.clipPlane.copy(this.localPlane).applyMatrix4(pivot.matrixWorld);
+ // The pane inherits the pivot's whole transform, so it scales and settles
+ // with the intro animation just like the specimen it cuts.
+ this.sectionHelper.matrix.multiplyMatrices(pivot.matrixWorld, this.helperPose);
+ this.sectionHelper.matrixWorldNeedsUpdate = true;
+ }
+
+ /** Snapshots the current view direction into organ space, so a "free" cut
+ * opens towards the viewer and then stays put while they orbit around it. */
+ private captureFreeNormal() {
+ const direction = new THREE.Vector3().subVectors(this.controls.target, this.camera.position);
+ direction.y = 0;
+ if (direction.lengthSq() < 1e-6) direction.set(0, 0, -1);
+ direction.normalize();
+ const pivot = this.organ?.pivot;
+ if (pivot) {
+ pivot.updateWorldMatrix(true, false);
+ direction.applyMatrix3(new THREE.Matrix3().setFromMatrix4(pivot.matrixWorld).invert()).normalize();
+ }
+ this.freeNormal.copy(direction);
+ }
+
+ /**
+ * The organs are front-face-only shells, which a cut would otherwise reveal
+ * as a hole straight through to the background. Rendering both sides while
+ * clipped is what actually exposes the chambers, lobes, and inner walls.
+ */
private applyClipping(enabled: boolean) {
if (!this.organ) return;
+ // The pane lives and dies with the actual clip, not the toggle's state —
+ // so it stays on screen while the disable sweep carries it out of the
+ // specimen, and vanishes only once the cut itself is gone.
+ this.sectionHelper.visible = enabled && this.sectionPlaneShown;
const planes = enabled ? [this.clipPlane] : null;
- [...this.materials(this.organ), this.depthMaterial].forEach((material) => {
+ this.materials(this.organ).forEach((material) => {
material.clippingPlanes = planes;
+ material.side = enabled ? THREE.DoubleSide : THREE.FrontSide;
material.needsUpdate = true;
});
+ this.depthMaterial.clippingPlanes = planes;
+ this.depthMaterial.needsUpdate = true;
this.dirty = true;
}
- toggleLayers() {
- if (!this.organ) return false;
- let enabled = false;
+ toggleWireframe() {
+ return this.setWireframe(!this.wireframe);
+ }
+
+ setWireframe(enabled: boolean) {
+ if (this.wireframe === enabled) return this.wireframe;
+ this.wireframe = enabled;
+ this.applyWireframe();
+ return this.wireframe;
+ }
+
+ private applyWireframe() {
+ if (!this.organ) return;
this.materials(this.organ).forEach((material) => {
- if (material instanceof THREE.MeshStandardMaterial) {
- material.wireframe = !material.wireframe;
- enabled = material.wireframe;
- }
+ if (material instanceof THREE.MeshStandardMaterial) material.wireframe = this.wireframe;
});
this.dirty = true;
- return enabled;
}
dispose() {
@@ -602,6 +933,8 @@ export class AnatomyViewer {
this.loadRequest += 1;
cancelAnimationFrame(this.frame);
gsap.killTweensOf(this.camera.position);
+ gsap.killTweensOf(this.sweep);
+ gsap.killTweensOf(this.dressing);
this.controls.removeEventListener("start", this.onControlStart);
this.controls.dispose();
this.resizeObserver.disconnect();
@@ -617,9 +950,19 @@ export class AnatomyViewer {
this.hotspots.dispose();
this.depthMaterial.dispose();
+ this.sectionHelper.children.forEach((child) => {
+ if (child instanceof THREE.Mesh || child instanceof THREE.LineSegments) child.geometry.dispose();
+ });
+ this.sectionHelperFill.dispose();
+ this.sectionHelperRim.dispose();
this.assets.dispose();
this.scene.environment?.dispose();
- (this.contactShadow.material as THREE.MeshBasicMaterial).map?.dispose();
+ const shadowMaterial = this.contactShadow.material as THREE.MeshBasicMaterial;
+ shadowMaterial.map?.dispose();
+ shadowMaterial.dispose();
+ this.contactShadow.geometry.dispose();
+ this.particles.geometry.dispose();
+ (this.particles.material as THREE.PointsMaterial).dispose();
this.renderer.dispose();
canvas.remove();
}