tegumentar 3d

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-11 17:11:03 -03:00
1 parent 805eb560a0
commit 15aff84de5
1 file changed
+128 -108
+128 -108
View File
@@ -1,6 +1,6 @@
import { useMemo, type ReactNode } from "react";
import { Canvas } from "@react-three/fiber";
import { CameraControls } from "@react-three/drei";
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";
@@ -9,11 +9,23 @@ import type { Structure } from "../lib/api.js";
// 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): em vez de tentar esculpir uma "pele" realista, é um
// bloco de tecido em corte — como a ilustração clássica de livro-texto —
// com cada camada/anexo como uma forma geométrica simples e reconhecível,
// clicável, usando as MESMAS 20 estruturas (nomes/descrições reais) que já
// existiam no diagrama 2D. Ilustrativo, não derivado de dado real.
// (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);
@@ -57,26 +69,47 @@ function Part({
);
}
// Bloco de tecido: 2 x 2 (largura x profundidade), cortado ao meio pra
// expor as camadas de lado. Eixo Y é a profundidade da pele (0 = topo).
const BLOCK = 1.0; // meia-largura/profundidade do bloco (de -BLOCK a BLOCK)
// 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;
// Vasos/nervos como tubos ao longo de uma curva — evita usar cylinderGeometry
// reto demais pra estruturas que na pele são ramificadas/sinuosas.
// 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);
}
// Posições fixas (não aleatórias) dos glóbulos de gordura na hipoderme.
const FAT_CELLS: [number, number, number, number][] = [
[-0.5, -0.72, -0.4, 0.09], [-0.2, -0.85, -0.55, 0.11], [0.15, -0.7, -0.35, 0.08],
[0.45, -0.88, -0.5, 0.1], [-0.55, -0.9, -0.15, 0.08], [0.1, -0.95, -0.6, 0.09],
[-0.15, -0.65, -0.6, 0.07], [0.5, -0.65, -0.25, 0.08],
// 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> }) {
@@ -88,10 +121,13 @@ export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Struc
[bySlugAll],
);
const vesselTube = useMemo(() => tube([[0.75, -0.35, -0.75], [0.55, -0.5, -0.3], [0.3, -0.6, 0.1], [0.05, -0.65, 0.4]], 0.03), []);
const arteryTube = useMemo(() => tube([[0.85, -1.0, -0.85], [0.7, -0.7, -0.6], [0.55, -0.5, -0.3]], 0.035), []);
const veinTube = useMemo(() => tube([[0.9, -1.0, -0.6], [0.75, -0.68, -0.35], [0.55, -0.48, -0.15]], 0.035), []);
const nerveFiberTube = useMemo(() => tube([[-0.85, -0.95, 0.6], [-0.6, -0.6, 0.3], [-0.3, -0.3, 0.1], [-0.1, -0.15, 0]], 0.02), []);
// 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;
@@ -112,39 +148,52 @@ export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Struc
</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.6, 1.7, 2.6], fov: 42 }}>
<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} />
{/* Hipoderme — camada mais funda, com glóbulos de gordura */}
{/* 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) => (
<mesh position={[0, (DERME_BOT + HIPODERME_BOT) / 2, 0]}>
<boxGeometry args={[BLOCK * 2, DERME_BOT - HIPODERME_BOT, BLOCK * 2]} />
<meshStandardMaterial {...materialProps("#fde68a", sel, 0.9)} />
</mesh>
<>
<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>
{/* Derme — camada intermediária */}
<Part slug="derme" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh position={[0, (EPIDERME_BOT + DERME_BOT) / 2, 0]}>
<boxGeometry args={[BLOCK * 2, EPIDERME_BOT - DERME_BOT, BLOCK * 2]} />
<meshStandardMaterial {...materialProps("#f4a896", sel, 0.92)} />
</mesh>
)}
{(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 no topo da derme, contra a epiderme */}
{/* 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.6, -0.2, 0.2, 0.6].map((x, i) => (
<mesh key={i} position={[x, EPIDERME_BOT + 0.02, 0.5]}>
<coneGeometry args={[0.06, 0.08, 12]} />
{[
[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>
))}
@@ -152,131 +201,102 @@ export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Struc
)}
</Part>
{/* Epiderme — camada mais fina, no topo, dividida em 3 sub-camadas visíveis */}
<Part slug="estrato-germinativo" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh position={[0, EPIDERME_BOT + 0.02, 0]}>
<boxGeometry args={[BLOCK * 2, 0.04, BLOCK * 2]} />
<meshStandardMaterial {...materialProps("#c98a5e", sel)} />
</mesh>
)}
</Part>
<Part slug="camada-pigmentar" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh position={[0, EPIDERME_BOT + 0.07, 0]}>
<boxGeometry args={[BLOCK * 2, 0.06, BLOCK * 2]} />
<meshStandardMaterial {...materialProps("#a8703f", sel)} />
</mesh>
)}
</Part>
<Part slug="estrato-corneo" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh position={[0, EPIDERME_TOP - 0.01, 0]}>
<boxGeometry args={[BLOCK * 2, 0.02, BLOCK * 2]} />
<meshStandardMaterial {...materialProps("#f2d9b8", sel)} />
</mesh>
)}
</Part>
{/* Complexo pilossebáceo — pelo + folículo + papila + eretor + sebácea,
cortado na metade frontal do bloco pra ficar visível por dentro. */}
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.35, (EPIDERME_TOP + HIPODERME_BOT + 0.2) / 2, 0.45]} rotation={[0, 0, 0.12]}>
<cylinderGeometry args={[0.07, 0.05, 1.05, 16, 1, true]} />
<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.41, HIPODERME_BOT + 0.22, 0.45]}>
<sphereGeometry args={[0.06, 16, 16]} />
<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.3, EPIDERME_TOP + 0.4, 0.45]} rotation={[0, 0, 0.12]}>
<cylinderGeometry args={[0.012, 0.022, 0.75, 8]} />
<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.2, EPIDERME_BOT - 0.15, 0.3]} rotation={[0, 0, -0.55]}>
<boxGeometry args={[0.025, 0.32, 0.05]} />
<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.14, EPIDERME_BOT - 0.08, 0.38]}>
<sphereGeometry args={[0.08, 16, 16]} />
<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 derme profunda/hipoderme,
com ducto reto até a superfície e poro no topo. */}
{/* 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) => (
<group position={[0.4, HIPODERME_BOT + 0.3, -0.1]}>
<mesh>
<torusKnotGeometry args={[0.11, 0.03, 64, 8, 2, 3]} />
<meshStandardMaterial {...materialProps("#38bdf8", sel, 0.95)} />
</mesh>
</group>
<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.4, EPIDERME_TOP + 0.005, -0.1]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.02, 0.035, 16]} />
<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 (usa o mesmo slug). */}
<mesh position={[0.4, (HIPODERME_BOT + 0.3 + EPIDERME_TOP) / 2, -0.1]} raycast={() => null}>
<cylinderGeometry args={[0.025, 0.025, EPIDERME_TOP - (HIPODERME_BOT + 0.3), 10]} />
<meshStandardMaterial color="#38bdf8" transparent opacity={0.5} />
{/* 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 */}
{/* 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.15, HIPODERME_BOT + 0.18, 0.35]} rotation={[0.3, 0.4, 0]}>
{[0.16, 0.12, 0.08, 0.04].map((r, i) => (
<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("#fef3c7", sel, 0.5)}
wireframe={i % 2 === 0}
/>
<meshStandardMaterial {...materialProps(i % 2 === 0 ? "#fef3c7" : "#fde68a", sel, 0.85)} />
</mesh>
))}
</group>
)}
</Part>
{/* Terminação nervosa de tato — pequeno disco na junção derme-epiderme */}
{/* 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.55, EPIDERME_BOT - 0.02, -0.2]} rotation={[Math.PI / 2, 0, 0]}>
<capsuleGeometry args={[0.045, 0.06, 4, 8]} />
<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 derme */}
{/* 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}>
@@ -285,7 +305,7 @@ export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Struc
)}
</Part>
{/* Vascularização — rede de vasos + ramo arterial + ramo venoso, tubos sinuosos */}
{/* Vascularização — rede de vasos + ramo arterial + ramo venoso */}
<Part slug="vasos-sanguineos-linfaticos" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh geometry={vesselTube}>
@@ -308,12 +328,12 @@ export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Struc
)}
</Part>
{/* Glóbulos de gordura na hipoderme — só decorativos (compõem a textura
da camada, sem estrutura própria no banco). */}
{FAT_CELLS.map(([x, y, z, r], i) => (
{/* 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="#fef9c3" transparent opacity={0.5} />
<meshStandardMaterial color="#fde9b8" />
</mesh>
))}
@@ -322,7 +342,7 @@ export function SkinDiagram3D({ bySlug: bySlugAll }: { bySlug: Map<string, Struc
</div>
<p className="text-[11px] text-slate-500 mt-2">
Modelo ilustrativo (não derivado de dado real) — bloco de pele em corte, proporções esquemáticas pensadas pra reconhecer cada camada e anexo.
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>