107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
import { Suspense, useEffect, useMemo, useRef } from "react";
|
|
import { Canvas, useThree } from "@react-three/fiber";
|
|
import { CameraControls, Grid } from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import { useAtlasStore, type ClipAxis } from "../../store/useAtlasStore.js";
|
|
import { SystemModel } from "./SystemModel.js";
|
|
import type { Structure } from "../../lib/api.js";
|
|
|
|
const AXIS_CONFIG: Record<ClipAxis, { normal: [number, number, number]; min: number; max: number }> = {
|
|
sagittal: { normal: [1, 0, 0], min: -0.4, max: 0.4 },
|
|
coronal: { normal: [0, 0, 1], min: -0.3, max: 0.3 },
|
|
transverse: { normal: [0, -1, 0], min: -1.8, max: 0 },
|
|
};
|
|
|
|
function useClippingPlanes(): THREE.Plane[] {
|
|
const { enabled, axis, position } = useAtlasStore((s) => s.clipping);
|
|
return useMemo(() => {
|
|
if (!enabled) return [];
|
|
const cfg = AXIS_CONFIG[axis];
|
|
const t = (position + 1) / 2; // normalize -1..1 -> 0..1
|
|
const constant = cfg.min + t * (cfg.max - cfg.min);
|
|
const normal = new THREE.Vector3(...cfg.normal);
|
|
return [new THREE.Plane(normal, -constant)];
|
|
}, [enabled, axis, position]);
|
|
}
|
|
|
|
function FocusController() {
|
|
const controlsRef = useRef<CameraControls>(null);
|
|
const { scene } = useThree();
|
|
const focusRequest = useAtlasStore((s) => s.focusRequest);
|
|
|
|
useEffect(() => {
|
|
if (!focusRequest || !controlsRef.current) return;
|
|
const obj = scene.getObjectByName(focusRequest.slug);
|
|
if (!obj) return;
|
|
const box = new THREE.Box3().setFromObject(obj);
|
|
if (box.isEmpty()) return;
|
|
controlsRef.current.fitToBox(box, true, {
|
|
paddingLeft: 0.4,
|
|
paddingRight: 0.4,
|
|
paddingTop: 0.4,
|
|
paddingBottom: 0.4,
|
|
});
|
|
}, [focusRequest, scene]);
|
|
|
|
return (
|
|
<CameraControls
|
|
ref={controlsRef}
|
|
makeDefault
|
|
minDistance={0.15}
|
|
maxDistance={4}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
|
|
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
|
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
|
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
|
const clippingPlanes = useClippingPlanes();
|
|
|
|
const selectedSlug = selectedStructureId
|
|
? [...bySlug.values()].find((s) => s.id === selectedStructureId)?.slug ?? null
|
|
: null;
|
|
|
|
const modelFiles = useMemo(
|
|
() => [...visibleSystems].map((slug) => `${slug}.glb`),
|
|
[visibleSystems],
|
|
);
|
|
|
|
return (
|
|
<Canvas
|
|
shadows
|
|
camera={{ position: [1.2, 1.4, 1.6], fov: 45, near: 0.01, far: 20 }}
|
|
gl={{ localClippingEnabled: true }}
|
|
>
|
|
<color attach="background" args={["#0b1220"]} />
|
|
<ambientLight intensity={0.55} />
|
|
<directionalLight position={[2, 3, 2]} intensity={1.1} castShadow />
|
|
<directionalLight position={[-2, 1, -2]} intensity={0.4} />
|
|
<Suspense fallback={null}>
|
|
{modelFiles.map((modelFile) => (
|
|
<SystemModel
|
|
key={modelFile}
|
|
modelFile={modelFile}
|
|
selectedSlug={selectedSlug}
|
|
clippingPlanes={clippingPlanes}
|
|
onSelect={(slug) => {
|
|
const structure = bySlug.get(slug);
|
|
if (structure) selectStructure(structure.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</Suspense>
|
|
<Grid
|
|
position={[0, 0, 0]}
|
|
args={[4, 4]}
|
|
cellColor="#1e293b"
|
|
sectionColor="#334155"
|
|
fadeDistance={6}
|
|
infiniteGrid
|
|
/>
|
|
<FocusController />
|
|
</Canvas>
|
|
);
|
|
}
|