From bcd71d511e7d01997bde43765cb66b09f157e6e5 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Thu, 6 Aug 2026 11:24:48 -0700 Subject: [PATCH 1/3] fix: unblock the underside, make the cut real, and de-heart the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three reports in issue #8. 1. The plinth walled off the bottom of every specimen, so a full 360° orbit was impossible. It is gone. The contact shadow that grounded the organ stays, but is now anchored to each organ's own footprint and fades out as the camera drops toward its plane, so it can never sit between the viewer and the specimen. Isolate used to be defined as "fade the plinth"; it now dims the remaining staging — shadow, particles, accent glow. Home framing drops to near eye level, since there is no longer a disc that needs to be seen at an angle. 2. "Layers" only toggled wireframe, and the tool that does cut — Cross- section — was broken in ways that hid it: the sweep started fully clipped so the organ blinked out, turning it off dropped the planes before the reverse tween, and front-face-only shells left the cut reading as a hole through to the background. The cut now wipes in from whole, wipes back out before releasing, renders both sides so the interior is actually exposed, aims itself away from the camera so it opens toward the viewer from any orbit, and holds auto-rotate while it is active. The wireframe tool keeps its behaviour under an honest label. 3. The selected organ was marked with a filled heart, which reads as "favourited" — a meaning the Bookmark control in the panel heading already owns. It is a check mark now, and the list carries radio semantics so selection is exposed to assistive tech rather than being colour and an icon alone. Also makes the three viewer tools independent toggles instead of one "active tool" that could only ever describe one of them, and has reset() clear them in the viewer as well as in the toolbar. --- app/components/AnatomyApp.tsx | 10 +- app/components/OrganViewer.tsx | 45 +++--- app/globals.css | 4 +- app/layout.tsx | 2 +- app/lib/three/loaders.ts | 3 + app/lib/three/viewer.ts | 250 +++++++++++++++++++++++++-------- 6 files changed, 237 insertions(+), 77 deletions(-) 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() { -
+ {/* 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) => ( ))}
diff --git a/app/components/OrganViewer.tsx b/app/components/OrganViewer.tsx index 35a02f4..d249060 100644 --- a/app/components/OrganViewer.tsx +++ b/app/components/OrganViewer.tsx @@ -4,10 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Box, CircleDashed, + Grid2x2, Layers3, Maximize2, RotateCcw, - ScanLine, Search, Sparkles, X, @@ -32,7 +32,9 @@ 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 }); // 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 +103,34 @@ 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") setActive((state) => ({ ...state, section: viewer.toggleCrossSection() })); + 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 }); } }; 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 +138,13 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
- {tools.map(({ id, label, icon: Icon }) => ( + {tools.map(({ id, label, icon: Icon, toggle }) => (
+ {active.section && ( + + )} +