351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
import { useMemo, type ReactNode } from "react";
|
|
import { Canvas } from "@react-three/fiber";
|
|
import { CameraControls, RoundedBox } from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import { useAtlasStore } from "../store/useAtlasStore.js";
|
|
import type { Structure } from "../lib/api.js";
|
|
|
|
// Histórico (ver data-pipeline/seed/build-manifest.mjs): tentamos duas vezes
|
|
// um bloco 3D esquemático pro tegumentar e nenhuma ficou boa o bastante,
|
|
// então virou diagrama 2D com hotspots. A pedido do usuário, tentamos de
|
|
// novo seguindo o MESMO princípio que funcionou pra citologia
|
|
// (CellDiagram3D.tsx): um bloco de tecido em corte, como a ilustração
|
|
// clássica de livro-texto, usando as MESMAS 20 estruturas (nomes/
|
|
// descrições reais) que já existiam no diagrama 2D. Ilustrativo, não
|
|
// derivado de dado real.
|
|
//
|
|
// PRIMEIRA VERSÃO (caixas cheias e translúcidas empilhadas) tinha o MESMO
|
|
// problema já visto na célula real de FIB-SEM: a derme/hipoderme, sendo
|
|
// sólidas e cobrindo tudo, sempre são o primeiro acerto de qualquer raio de
|
|
// clique, escondendo as estruturas internas — mas aqui não dá pra só
|
|
// desabilitar o raycast delas (o usuário PRECISA poder clicar em "derme" e
|
|
// "hipoderme" também, ao contrário da membrana da célula). A solução é
|
|
// geométrica, não de material: as camadas formam um "L" (falta o quadrante
|
|
// x>0 E z>0 em todas as profundidades), como o corte-em-cunha clássico dos
|
|
// livros-texto (um "pedaço retirado" do bloco). Todo o conteúdo interno
|
|
// (folículo, glândulas, vasos, nervo) fica dentro desse vão aberto, sem
|
|
// nada sólido entre a câmera e eles — resolve a oclusão sem sacrificar a
|
|
// clicabilidade das camadas.
|
|
const HIGHLIGHT = new THREE.Color("#22d3ee");
|
|
const BLACK = new THREE.Color(0x000000);
|
|
|
|
// Função comum (NÃO hook) — chamada dentro de .map()/render-prop, onde um
|
|
// hook violaria as Regras de Hooks do React (ver mesmo padrão em
|
|
// CellDiagram3D.tsx).
|
|
function materialProps(baseColor: string, selected: boolean, opacity = 1) {
|
|
return {
|
|
color: baseColor,
|
|
transparent: opacity < 1,
|
|
opacity,
|
|
emissive: selected ? HIGHLIGHT : BLACK,
|
|
emissiveIntensity: selected ? 0.7 : 0,
|
|
};
|
|
}
|
|
|
|
function Part({
|
|
slug,
|
|
bySlug,
|
|
onSelect,
|
|
children,
|
|
}: {
|
|
slug: string;
|
|
bySlug: Map<string, Structure>;
|
|
onSelect: (id: string) => void;
|
|
children: (isSelected: boolean) => ReactNode;
|
|
}) {
|
|
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
|
const structure = bySlug.get(slug);
|
|
const isSelected = !!structure && structure.id === selectedStructureId;
|
|
|
|
return (
|
|
<group
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (structure) onSelect(structure.id);
|
|
}}
|
|
>
|
|
{children(isSelected)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// Eixo Y é a profundidade da pele (0 = superfície, negativo = mais fundo).
|
|
const EPIDERME_TOP = 0;
|
|
const ESTRATO_CORNEO_BOT = -0.02;
|
|
const CAMADA_PIGMENTAR_BOT = -0.08;
|
|
const EPIDERME_BOT = -0.12;
|
|
const DERME_BOT = -0.55;
|
|
const HIPODERME_BOT = -1.0;
|
|
|
|
// Uma camada é um "L": bloco de trás (toda a largura, z de -1 a 0) + bloco
|
|
// da frente-esquerda (metade da largura, z de 0 a 1). O quadrante x>0,z>0
|
|
// (canto frente-direita, voltado pra câmera) fica de fora — é o vão aberto
|
|
// onde ficam expostas as estruturas internas.
|
|
function Slab({ yTop, yBot, color, sel, radius }: { yTop: number; yBot: number; color: string; sel: boolean; radius: number }) {
|
|
const thickness = yTop - yBot;
|
|
const centerY = (yTop + yBot) / 2;
|
|
return (
|
|
<>
|
|
<RoundedBox args={[2, thickness, 1]} radius={radius} smoothness={2} position={[0, centerY, -0.5]}>
|
|
<meshStandardMaterial {...materialProps(color, sel)} />
|
|
</RoundedBox>
|
|
<RoundedBox args={[1, thickness, 1]} radius={radius} smoothness={2} position={[-0.5, centerY, 0.5]}>
|
|
<meshStandardMaterial {...materialProps(color, sel)} />
|
|
</RoundedBox>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Vasos/nervos como tubos ao longo de uma curva — mais orgânico que um
|
|
// cilindro reto pra estruturas que na pele são ramificadas/sinuosas.
|
|
function tube(points: [number, number, number][], radius: number) {
|
|
const curve = new THREE.CatmullRomCurve3(points.map(([x, y, z]) => new THREE.Vector3(x, y, z)));
|
|
return new THREE.TubeGeometry(curve, 24, radius, 8, false);
|
|
}
|
|
|
|
// Pequenos "caroços" de gordura saltando das paredes de corte da hipoderme
|
|
// pro vão aberto — dão textura à camada sem precisar de transparência
|
|
// (a camada em si agora é opaca, já que a oclusão foi resolvida pela
|
|
// geometria em L, não por transparência).
|
|
const FAT_BUMPS: [number, number, number, number][] = [
|
|
[0.15, -0.65, 0.02, 0.06], [0.4, -0.78, 0.02, 0.07], [0.65, -0.68, 0.02, 0.055],
|
|
[0.02, -0.88, 0.55, 0.06], [0.02, -0.62, 0.8, 0.055], [0.02, -0.9, 0.9, 0.07],
|
|
];
|
|
|
|
export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Structure> }) {
|
|
const toggleSystem = useAtlasStore((s) => s.toggleSystem);
|
|
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
|
|
|
const bySlug = useMemo(
|
|
() => new Map([...bySlugAll.values()].filter((s) => s.system.slug === "tegumentar").map((s) => [s.slug, s])),
|
|
[bySlugAll],
|
|
);
|
|
|
|
// Tudo dentro do vão aberto (x>0.05, z>0.05) fica em ar livre — nenhuma
|
|
// camada sólida entre a câmera (posicionada em x,z positivos) e essas
|
|
// estruturas, então o clique sempre alcança a estrutura certa.
|
|
const vesselTube = useMemo(() => tube([[0.35, -0.35, 0.45], [0.28, -0.45, 0.4], [0.22, -0.55, 0.35], [0.16, -0.6, 0.3]], 0.026), []);
|
|
const arteryTube = useMemo(() => tube([[0.4, -0.85, 0.2], [0.3, -0.65, 0.24], [0.22, -0.5, 0.28]], 0.03), []);
|
|
const veinTube = useMemo(() => tube([[0.4, -0.85, 0.42], [0.3, -0.65, 0.4], [0.22, -0.48, 0.36]], 0.03), []);
|
|
const nerveFiberTube = useMemo(() => tube([[0.2, -0.82, 0.62], [0.2, -0.5, 0.66], [0.2, -0.25, 0.68], [0.2, -0.14, 0.7]], 0.018), []);
|
|
|
|
if (bySlug.size === 0) return null;
|
|
|
|
return (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center p-4 bg-ink/70">
|
|
<div className="relative bg-panel border border-white/10 rounded-xl shadow-2xl p-3 sm:p-4 max-w-2xl w-full">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-sm font-semibold text-white">
|
|
Sistema Tegumentar (Pele) — modelo ilustrativo, gire e clique nas estruturas
|
|
</h2>
|
|
<button
|
|
onClick={() => toggleSystem("tegumentar")}
|
|
className="text-slate-400 hover:text-white text-sm px-2 py-1"
|
|
aria-label="Fechar diagrama"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="w-full aspect-square rounded-lg border border-white/10 overflow-hidden bg-[#0b1220]">
|
|
<Canvas dpr={[1, 1.5]} camera={{ position: [2.3, 1.5, 2.3], fov: 42 }}>
|
|
<color attach="background" args={["#0b1220"]} />
|
|
<ambientLight intensity={0.75} />
|
|
<directionalLight position={[3, 4, 3]} intensity={1} />
|
|
<directionalLight position={[-3, -1, -2]} intensity={0.3} />
|
|
|
|
{/* Camadas — cada uma em formato de "L" (ver Slab), deixando o
|
|
quadrante frente-direita aberto pra expor o interior. */}
|
|
<Part slug="hipoderme" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<>
|
|
<Slab yTop={DERME_BOT} yBot={HIPODERME_BOT} color="#eab676" sel={sel} radius={0.04} />
|
|
{/* Piso do vão — fecha o buraco no fundo da cunha; sem ele o
|
|
corte pareceria vazado e tudo perto do fundo pareceria
|
|
flutuando no vazio. */}
|
|
<mesh position={[0.5, HIPODERME_BOT, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<planeGeometry args={[1, 1]} />
|
|
<meshStandardMaterial {...materialProps("#eab676", sel)} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
</>
|
|
)}
|
|
</Part>
|
|
<Part slug="derme" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => <Slab yTop={EPIDERME_BOT} yBot={DERME_BOT} color="#e08a72" sel={sel} radius={0.04} />}
|
|
</Part>
|
|
<Part slug="estrato-germinativo" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => <Slab yTop={CAMADA_PIGMENTAR_BOT} yBot={EPIDERME_BOT} color="#c98a5e" sel={sel} radius={0.015} />}
|
|
</Part>
|
|
<Part slug="camada-pigmentar" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => <Slab yTop={ESTRATO_CORNEO_BOT} yBot={CAMADA_PIGMENTAR_BOT} color="#a8703f" sel={sel} radius={0.02} />}
|
|
</Part>
|
|
<Part slug="estrato-corneo" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => <Slab yTop={EPIDERME_TOP} yBot={ESTRATO_CORNEO_BOT} color="#f2d9b8" sel={sel} radius={0.006} />}
|
|
</Part>
|
|
|
|
{/* Papilas dérmicas — pequenas ondulações saltando da parede de
|
|
corte da derme (na junção com a epiderme) pro vão aberto. */}
|
|
<Part slug="papila-dermica" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<>
|
|
{[
|
|
[0.15, 0.02], [0.4, 0.02], [0.65, 0.02],
|
|
[0.02, 0.2], [0.02, 0.45], [0.02, 0.7],
|
|
].map(([x, z], i) => (
|
|
<mesh key={i} position={[x, EPIDERME_BOT + 0.02, z]}>
|
|
<sphereGeometry args={[0.045, 12, 12]} />
|
|
<meshStandardMaterial {...materialProps("#e0806a", sel)} />
|
|
</mesh>
|
|
))}
|
|
</>
|
|
)}
|
|
</Part>
|
|
|
|
{/* Complexo pilossebáceo — pelo + folículo + papila + eretor + sebácea,
|
|
todo dentro do vão aberto, perto do canto (mais visível contra
|
|
as duas paredes de corte). */}
|
|
<Part slug="foliculo-piloso" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.28, -0.41, 0.14]}>
|
|
<cylinderGeometry args={[0.06, 0.042, 0.82, 16, 1, true]} />
|
|
<meshStandardMaterial {...materialProps("#d8b48c", sel, 0.85)} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="papila-do-pelo" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.28, -0.8, 0.14]}>
|
|
<sphereGeometry args={[0.05, 16, 16]} />
|
|
<meshStandardMaterial {...materialProps("#dc2626", sel)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="pelo" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.28, 0.17, 0.14]}>
|
|
<cylinderGeometry args={[0.012, 0.02, 0.34, 8]} />
|
|
<meshStandardMaterial {...materialProps("#3f2a1d", sel)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="musculo-eretor-do-pelo" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.44, -0.3, 0.18]} rotation={[0, 0, -0.6]}>
|
|
<capsuleGeometry args={[0.013, 0.2, 4, 8]} />
|
|
<meshStandardMaterial {...materialProps("#b91c1c", sel)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="glandula-sebacea" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.5, -0.22, 0.16]} scale={[1, 1.3, 1]}>
|
|
<sphereGeometry args={[0.06, 16, 16]} />
|
|
<meshStandardMaterial {...materialProps("#fbbf24", sel, 0.95)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
|
|
{/* Glândula sudorípara — tubo enovelado (coil) na hipoderme, com
|
|
ducto reto até a superfície e poro no topo. */}
|
|
<Part slug="glandula-sudoripara" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.14, -0.8, 0.32]}>
|
|
<torusKnotGeometry args={[0.075, 0.02, 64, 8, 2, 3]} />
|
|
<meshStandardMaterial {...materialProps("#38bdf8", sel, 0.95)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="poro-sudoriparo" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.14, EPIDERME_TOP + 0.005, 0.32]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<ringGeometry args={[0.018, 0.03, 16]} />
|
|
<meshStandardMaterial {...materialProps("#38bdf8", sel)} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
{/* Ducto reto ligando a glândula sudorípara ao poro — parte visual
|
|
da glândula, sem estrutura própria no banco. */}
|
|
<mesh position={[0.14, (-0.8 + EPIDERME_TOP) / 2, 0.32]} raycast={() => null}>
|
|
<cylinderGeometry args={[0.018, 0.018, EPIDERME_TOP - -0.8, 10]} />
|
|
<meshStandardMaterial color="#38bdf8" />
|
|
</mesh>
|
|
|
|
{/* Corpúsculo de Pacini — receptor em camadas concêntricas ("cebola"),
|
|
fundo da hipoderme, junto à parede de corte. */}
|
|
<Part slug="corpusculo-de-pacini" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<group position={[0.18, -0.85, 0.62]} rotation={[0.3, 0.4, 0]}>
|
|
{[0.13, 0.1, 0.065, 0.035].map((r, i) => (
|
|
<mesh key={i} scale={[1, 0.55, 1]}>
|
|
<sphereGeometry args={[r, 16, 12]} />
|
|
<meshStandardMaterial {...materialProps(i % 2 === 0 ? "#fef3c7" : "#fde68a", sel, 0.85)} />
|
|
</mesh>
|
|
))}
|
|
</group>
|
|
)}
|
|
</Part>
|
|
|
|
{/* Terminação nervosa de tato — pequena cápsula na junção derme-epiderme */}
|
|
<Part slug="terminacao-nervosa-tato" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh position={[0.2, EPIDERME_BOT - 0.03, 0.7]} rotation={[Math.PI / 2, 0, 0]}>
|
|
<capsuleGeometry args={[0.04, 0.05, 4, 8]} />
|
|
<meshStandardMaterial {...materialProps("#fef08a", sel)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
|
|
{/* Fibra nervosa — filamento sinuoso subindo da hipoderme até a
|
|
terminação de tato */}
|
|
<Part slug="fibra-nervosa" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh geometry={nerveFiberTube}>
|
|
<meshStandardMaterial {...materialProps("#facc15", sel)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
|
|
{/* Vascularização — rede de vasos + ramo arterial + ramo venoso */}
|
|
<Part slug="vasos-sanguineos-linfaticos" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh geometry={vesselTube}>
|
|
<meshStandardMaterial {...materialProps("#22c55e", sel, 0.95)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="arteria" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh geometry={arteryTube}>
|
|
<meshStandardMaterial {...materialProps("#dc2626", sel, 0.95)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
<Part slug="veia" bySlug={bySlug} onSelect={selectStructure}>
|
|
{(sel) => (
|
|
<mesh geometry={veinTube}>
|
|
<meshStandardMaterial {...materialProps("#2563eb", sel, 0.95)} />
|
|
</mesh>
|
|
)}
|
|
</Part>
|
|
|
|
{/* Caroços de gordura saltando das paredes de corte da hipoderme —
|
|
só textura decorativa, sem estrutura própria no banco. */}
|
|
{FAT_BUMPS.map(([x, y, z, r], i) => (
|
|
<mesh key={i} position={[x, y, z]} raycast={() => null}>
|
|
<sphereGeometry args={[r, 12, 12]} />
|
|
<meshStandardMaterial color="#fde9b8" />
|
|
</mesh>
|
|
))}
|
|
|
|
<CameraControls makeDefault minDistance={0.8} maxDistance={8} />
|
|
</Canvas>
|
|
</div>
|
|
|
|
<p className="text-[11px] text-slate-500 mt-2">
|
|
Modelo ilustrativo (não derivado de dado real) — bloco de pele com uma cunha removida (como em livros-texto), proporções esquemáticas pensadas pra reconhecer cada camada e anexo.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|