modelo 3d tegumentar

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-11 16:52:49 -03:00
1 parent e8ef385237
commit 805eb560a0
7 files changed
+3959 -99

No files matched your search

+3626
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

-69
View File
@@ -1,69 +0,0 @@
import { useAtlasStore } from "../store/useAtlasStore.js";
import { SKIN_HOTSPOTS } from "../data/skinHotspots.js";
import type { Structure } from "../lib/api.js";
// O sistema tegumentar não tem malha 3D (Z-Anatomy não cobre pele — ver
// nota em data-pipeline/seed/build-manifest.mjs) e duas tentativas de bloco
// 3D esquemático não ficaram boas o bastante pra estudo. Em vez disso, é um
// diagrama 2D clássico (domínio público, NCI/SEER) com pontos clicáveis —
// cada um abre o mesmo StructureInfoPanel usado pelos outros sistemas.
export function SkinDiagram({ bySlug }: { bySlug: Map<string, Structure> }) {
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
const selectStructure = useAtlasStore((s) => s.selectStructure);
const toggleSystem = useAtlasStore((s) => s.toggleSystem);
const tegumentar = [...bySlug.values()].filter((s) => s.system.slug === "tegumentar");
if (tegumentar.length === 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">
Corte Transversal da Pele — clique nos pontos pra explorar
</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="relative w-full select-none">
<img
src="/tegumentar/skin-cross-section.jpg"
alt="Corte transversal da pele mostrando epiderme, derme, hipoderme e seus anexos"
className="w-full h-auto rounded-lg border border-white/10"
draggable={false}
/>
{tegumentar.map((structure) => {
const hotspot = SKIN_HOTSPOTS[structure.slug];
if (!hotspot) return null;
const isSelected = structure.id === selectedStructureId;
return (
<button
key={structure.id}
onClick={() => selectStructure(structure.id)}
title={structure.namePt}
aria-label={structure.namePt}
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%` }}
className={`absolute -translate-x-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 transition-all
${
isSelected
? "bg-accent border-white scale-125 shadow-[0_0_0_4px_rgba(79,124,255,0.35)]"
: "bg-accent/70 border-white/70 hover:scale-125 hover:bg-accent"
}`}
/>
);
})}
</div>
<p className="text-[11px] text-slate-500 mt-2">
Imagem de domínio público (National Cancer Institute / SEER Training Modules), legendas traduzidas para PT-BR.
</p>
</div>
</div>
);
}
+330
View File
@@ -0,0 +1,330 @@
import { useMemo, type ReactNode } from "react";
import { Canvas } from "@react-three/fiber";
import { CameraControls } 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): 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.
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>
);
}
// 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)
const EPIDERME_TOP = 0;
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.
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],
];
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],
);
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), []);
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.6, 1.7, 2.6], 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 */}
<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>
)}
</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>
)}
</Part>
{/* Papilas dérmicas — pequenas ondulações no topo da derme, contra a epiderme */}
<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]} />
<meshStandardMaterial {...materialProps("#e0806a", sel)} />
</mesh>
))}
</>
)}
</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. */}
<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]} />
<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]} />
<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]} />
<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]} />
<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]} />
<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. */}
<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>
)}
</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]} />
<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} />
</mesh>
{/* Corpúsculo de Pacini — receptor em camadas concêntricas ("cebola"), fundo da hipoderme */}
<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) => (
<mesh key={i} scale={[1, 0.55, 1]}>
<sphereGeometry args={[r, 16, 12]} />
<meshStandardMaterial
{...materialProps("#fef3c7", sel, 0.5)}
wireframe={i % 2 === 0}
/>
</mesh>
))}
</group>
)}
</Part>
{/* Terminação nervosa de tato — pequeno disco 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]} />
<meshStandardMaterial {...materialProps("#fef08a", sel)} />
</mesh>
)}
</Part>
{/* Fibra nervosa — filamento sinuoso subindo da hipoderme até a derme */}
<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, tubos sinuosos */}
<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>
{/* 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) => (
<mesh key={i} position={[x, y, z]} raycast={() => null}>
<sphereGeometry args={[r, 12, 12]} />
<meshStandardMaterial color="#fef9c3" transparent opacity={0.5} />
</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 em corte, proporções esquemáticas pensadas pra reconhecer cada camada e anexo.
</p>
</div>
</div>
);
}
-28
View File
@@ -1,28 +0,0 @@
// Posição (em % da largura/altura da imagem) de cada marcador clicável
// sobre frontend/public/tegumentar/skin-cross-section.jpg — medidas
// diretamente sobre a imagem (domínio público, NCI/SEER Training Modules).
// Isso é só apresentação (onde desenhar o ponto em cima da figura); os
// dados da estrutura em si (nome, descrição) vêm do banco, como qualquer
// outra estrutura do atlas — ver data-pipeline/seed/build-manifest.mjs.
export const SKIN_HOTSPOTS: Record<string, { x: number; y: number }> = {
"estrato-corneo": { x: 37.9, y: 15.0 },
"camada-pigmentar": { x: 37.5, y: 18.6 },
"papila-dermica": { x: 48.3, y: 27.2 },
epiderme: { x: 75.0, y: 32.2 },
"terminacao-nervosa-tato": { x: 74.6, y: 38.7 },
derme: { x: 75.4, y: 45.8 },
"glandula-sebacea": { x: 69.2, y: 47.3 },
"foliculo-piloso": { x: 65.8, y: 53.7 },
"papila-do-pelo": { x: 65.8, y: 58.7 },
hipoderme: { x: 75.4, y: 65.9 },
"estrato-germinativo": { x: 37.9, y: 33.7 },
"musculo-eretor-do-pelo": { x: 42.1, y: 43.0 },
"fibra-nervosa": { x: 38.8, y: 49.4 },
"vasos-sanguineos-linfaticos": { x: 35.0, y: 78.1 },
"glandula-sudoripara": { x: 47.1, y: 80.2 },
"corpusculo-de-pacini": { x: 51.3, y: 71.6 },
arteria: { x: 70.4, y: 78.1 },
veia: { x: 75.0, y: 80.9 },
pelo: { x: 65.8, y: 9.3 },
"poro-sudoriparo": { x: 69.2, y: 27.9 },
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { ClippingControls } from "../components/ClippingControls.js";
import { SearchNav } from "../components/SearchNav.js";
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
import { QuickRegionNav } from "../components/QuickRegionNav.js";
import { SkinDiagram } from "../components/SkinDiagram.js";
import { SkinDiagram3D } from "../components/SkinDiagram3D.js";
import { MicroscopyDiagram } from "../components/MicroscopyDiagram.js";
import { CellDiagram3D } from "../components/CellDiagram3D.js";
import { trackVisit } from "../lib/analytics.js";
@@ -73,7 +73,7 @@ export default function AtlasPage() {
<div className="relative h-full min-h-0">
<Scene bySlug={bySlug} />
<QuickRegionNav />
<SkinDiagram bySlug={bySlug} />
<SkinDiagram3D bySlug={bySlug} />
<MicroscopyDiagram bySlug={bySlug} systemSlug="histologia" title="Histologia (Tecidos)" />
<CellDiagram3D bySlug={bySlug} />
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/celldiagram3d.tsx","./src/components/clippingcontrols.tsx","./src/components/cookieconsent.tsx","./src/components/feedbackwidget.tsx","./src/components/layertoggle.tsx","./src/components/layout.tsx","./src/components/microscopydiagram.tsx","./src/components/onlinecounter.tsx","./src/components/quickregionnav.tsx","./src/components/searchnav.tsx","./src/components/skindiagram3d.tsx","./src/components/structureinfopanel.tsx","./src/components/viewer/exammarkers.tsx","./src/components/viewer/mannequin.tsx","./src/components/viewer/scene.tsx","./src/components/viewer/systemmodel.tsx","./src/data/microscopycredits.ts","./src/data/microscopyhotspots.ts","./src/data/physicalexam.ts","./src/hooks/usestructures.ts","./src/hooks/usesystemstructures.ts","./src/lib/analytics.ts","./src/lib/api.ts","./src/routes/adminstatspage.tsx","./src/routes/atlaspage.tsx","./src/routes/physicalexampage.tsx","./src/routes/planespage.tsx","./src/routes/quizpage.tsx","./src/store/useatlasstore.ts"],"version":"5.9.3"}