first commit

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-07 20:46:08 -03:00
commit 0e854110d3
49 files changed
+2292

No files matched your search

@@ -0,0 +1,53 @@
import { useAtlasStore, type ClipAxis } from "../store/useAtlasStore.js";
const AXES: { value: ClipAxis; label: string }[] = [
{ value: "sagittal", label: "Sagital (esq./dir.)" },
{ value: "coronal", label: "Coronal (ant./post.)" },
{ value: "transverse", label: "Transversal (sup./inf.)" },
];
export function ClippingControls() {
const clipping = useAtlasStore((s) => s.clipping);
const setClippingEnabled = useAtlasStore((s) => s.setClippingEnabled);
const setClipAxis = useAtlasStore((s) => s.setClipAxis);
const setClipPosition = useAtlasStore((s) => s.setClipPosition);
return (
<div className="space-y-2 border-t border-white/10 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={clipping.enabled}
onChange={(e) => setClippingEnabled(e.target.checked)}
className="accent-accent"
/>
Dissecção virtual (plano de corte)
</label>
{clipping.enabled && (
<div className="space-y-2 pl-1">
<select
value={clipping.axis}
onChange={(e) => setClipAxis(e.target.value as ClipAxis)}
className="w-full text-sm bg-white/5 border border-white/10 rounded px-2 py-1"
>
{AXES.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
<input
type="range"
min={-1}
max={1}
step={0.01}
value={clipping.position}
onChange={(e) => setClipPosition(Number(e.target.value))}
className="w-full accent-accent"
/>
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useSystems } from "../hooks/useStructures.js";
import { useAtlasStore } from "../store/useAtlasStore.js";
export function LayerToggle() {
const systems = useSystems();
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
const toggleSystem = useAtlasStore((s) => s.toggleSystem);
return (
<div className="space-y-1.5">
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-2">Camadas</h3>
{systems.map((sys) => (
<label
key={sys.slug}
className="flex items-center gap-2 text-sm cursor-pointer select-none rounded px-2 py-1 hover:bg-white/5"
>
<input
type="checkbox"
checked={visibleSystems.has(sys.slug)}
onChange={() => toggleSystem(sys.slug)}
className="accent-accent"
/>
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: sys.colorHex }}
/>
<span>{sys.namePt}</span>
{sys._count && (
<span className="ml-auto text-xs text-slate-500">{sys._count.structures}</span>
)}
</label>
))}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { NavLink, Outlet } from "react-router-dom";
const linkClass = ({ isActive }: { isActive: boolean }) =>
`px-3 py-1.5 rounded text-sm ${isActive ? "bg-accent text-white" : "text-slate-300 hover:bg-white/5"}`;
export function Layout() {
return (
<div className="h-screen flex flex-col">
<header className="flex items-center gap-4 px-4 py-2 border-b border-white/10 bg-panel">
<h1 className="text-sm font-semibold tracking-wide text-white">🫀 Atlas 3D de Anatomia</h1>
<nav className="flex gap-1">
<NavLink to="/" end className={linkClass}>
Atlas
</NavLink>
<NavLink to="/planos-anatomicos" className={linkClass}>
Planos e Eixos
</NavLink>
<NavLink to="/quiz" className={linkClass}>
Quiz
</NavLink>
</nav>
</header>
<main className="flex-1 min-h-0">
<Outlet />
</main>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useMemo, useState } from "react";
import Fuse from "fuse.js";
import type { Structure } from "../lib/api.js";
import { useAtlasStore } from "../store/useAtlasStore.js";
export function SearchNav({ structures }: { structures: Structure[] }) {
const [query, setQuery] = useState("");
const selectStructure = useAtlasStore((s) => s.selectStructure);
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
const fuse = useMemo(
() =>
new Fuse(structures, {
keys: ["namePt", "nameEn", "synonyms"],
threshold: 0.35,
}),
[structures],
);
const results = query.trim() ? fuse.search(query).slice(0, 20).map((r) => r.item) : [];
const tree = useMemo(() => {
const root = new Map<string, Map<string, Structure[]>>();
for (const s of structures) {
const system = s.system.namePt;
const region = s.regionPath[s.regionPath.length - 1] ?? "Geral";
if (!root.has(system)) root.set(system, new Map());
const regionMap = root.get(system)!;
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(s);
}
return root;
}, [structures]);
function goTo(structure: Structure) {
if (!visibleSystems.has(structure.system.slug)) {
setVisibleSystems([...visibleSystems, structure.system.slug]);
}
selectStructure(structure.id);
focusOnStructure(structure.slug);
}
return (
<div className="flex flex-col gap-3 h-full min-h-0">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar estrutura (ex: fêmur, nervo ciático)..."
className="w-full rounded bg-white/5 border border-white/10 px-3 py-2 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
/>
<div className="overflow-y-auto flex-1 min-h-0 pr-1">
{query.trim() ? (
<ul className="space-y-1">
{results.map((s) => (
<li key={s.id}>
<button
onClick={() => goTo(s)}
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
>
{s.namePt}
<span className="text-slate-500 text-xs"> — {s.system.namePt}</span>
</button>
</li>
))}
{results.length === 0 && (
<li className="text-sm text-slate-500 px-2">Nenhuma estrutura encontrada.</li>
)}
</ul>
) : (
<div className="space-y-3">
{[...tree.entries()].map(([system, regions]) => (
<details key={system} open={false} className="text-sm">
<summary className="cursor-pointer text-slate-300 hover:text-white">{system}</summary>
<div className="pl-3 mt-1 space-y-2">
{[...regions.entries()].map(([region, items]) => (
<details key={region}>
<summary className="cursor-pointer text-slate-400 hover:text-white text-xs uppercase tracking-wide">
{region}
</summary>
<ul className="pl-3 mt-1 space-y-0.5">
{items.map((s) => (
<li key={s.id}>
<button
onClick={() => goTo(s)}
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
>
{s.namePt}
</button>
</li>
))}
</ul>
</details>
))}
</div>
</details>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { useAtlasStore } from "../store/useAtlasStore.js";
import type { Structure } from "../lib/api.js";
export function StructureInfoPanel({ bySlug }: { bySlug: Map<string, Structure> }) {
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
const selectStructure = useAtlasStore((s) => s.selectStructure);
const structure = selectedStructureId
? [...bySlug.values()].find((s) => s.id === selectedStructureId)
: null;
if (!structure) {
return (
<div className="text-sm text-slate-400">
Clique em uma estrutura no modelo 3D para ver os detalhes aqui.
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold text-white">{structure.namePt}</h2>
<p className="text-sm text-slate-400 italic">{structure.nameEn}</p>
</div>
<button
onClick={() => selectStructure(null)}
className="text-slate-400 hover:text-white text-sm"
aria-label="Fechar"
>
✕
</button>
</div>
<span
className="inline-block text-xs px-2 py-1 rounded-full"
style={{ backgroundColor: structure.system.colorHex + "33", color: structure.system.colorHex }}
>
{structure.system.namePt}
</span>
{structure.description && <p className="text-sm text-slate-300">{structure.description}</p>}
{structure.regionPath.length > 0 && (
<p className="text-xs text-slate-500">{structure.regionPath.join(" › ")}</p>
)}
{structure.synonyms.length > 0 && (
<p className="text-xs text-slate-500">Sinônimos: {structure.synonyms.join(", ")}</p>
)}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
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>
);
}
@@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import type { ThreeEvent } from "@react-three/fiber";
import { modelUrl } from "../../lib/api.js";
interface Props {
modelFile: string;
selectedSlug: string | null;
onSelect: (slug: string) => void;
clippingPlanes: THREE.Plane[];
}
const HIGHLIGHT = new THREE.Color("#22d3ee");
export function SystemModel({ modelFile, selectedSlug, onSelect, clippingPlanes }: Props) {
const { scene } = useGLTF(modelUrl(modelFile));
const clonedRef = useRef<THREE.Group>();
if (!clonedRef.current) {
const clone = scene.clone(true);
clone.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (mesh.isMesh && mesh.material) {
mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone();
}
});
clonedRef.current = clone;
}
const root = clonedRef.current!;
useEffect(() => {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.clippingPlanes = clippingPlanes;
mat.clipShadows = true;
const isSelected = mesh.name === selectedSlug;
mat.emissive = isSelected ? HIGHLIGHT : new THREE.Color(0x000000);
mat.emissiveIntensity = isSelected ? 0.6 : 0;
});
}, [root, selectedSlug, clippingPlanes]);
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
const name = e.object.name;
if (name) onSelect(name);
};
return <primitive object={root} onClick={handleClick} />;
}