novo modelo 3d

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-11 15:50:15 -03:00
1 parent bc7e77b007
commit 08cb8c426f
11 files changed
+301 -256

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
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";
// Não existe (ver discussão com o usuário) uma malha 3D real e aberta de
// célula com todas as organelas prontas pra estudo — chegamos a integrar
// uma segmentação real de FIB-SEM (célula HeLa, Janelia OpenOrganelle),
// mas o resultado visual ficou confuso pra estudar (a membrana real de uma
// célula aderente em cultura é achatada/irregular, não a "bolinha" que se
// espera de um diagrama didático, e cobria as organelas de dentro). Por
// pedido do usuário, citologia agora é um modelo 3D ILUSTRATIVO — desenhado
// à mão com primitivas do Three.js, mesmo princípio já usado no boneco
// esquemático de Planos Anatômicos (ver Mannequin.tsx) — mais reconhecível
// pra quem está estudando, mesmo não vindo de dado real.
const HIGHLIGHT = new THREE.Color("#22d3ee");
const BLACK = new THREE.Color(0x000000);
// Função comum (NÃO hook) de propósito — é chamada dentro de .map() e de
// callbacks de render-prop abaixo, onde um hook (useMemo/useState) violaria
// as Regras de Hooks do React (número de chamadas por render precisa ser
// sempre igual e não pode depender de laços/condicionais).
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 Organelle({
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>
);
}
// Pontos de mitocôndrias/lisossomos espalhados pelo citoplasma — fixos
// (não aleatórios a cada render) pra não "pular" toda vez que o componente
// atualiza.
const MITOCHONDRIA_SPOTS: [number, number, number, number][] = [
[0.55, 0.35, 0.2, 0.4], [-0.5, 0.2, -0.35, -0.2], [0.3, -0.45, 0.4, 1.1],
[-0.35, -0.5, -0.2, 2.0], [0.6, -0.1, -0.45, 0.8], [-0.6, 0.4, 0.3, -1.3],
[0.15, 0.55, -0.5, 2.4], [-0.15, -0.2, 0.6, -0.6],
];
const LYSOSOME_SPOTS: [number, number, number][] = [
[0.4, -0.55, -0.15], [-0.45, -0.35, 0.35], [0.5, 0.15, 0.45], [-0.3, 0.5, -0.4],
];
const RIBOSOME_SPOTS: [number, number, number][] = [
[0.2, 0.42, 0.32], [0.28, 0.5, 0.18], [0.12, 0.55, 0.3], [0.3, 0.35, 0.42],
[-0.5, -0.2, -0.5], [0.55, -0.35, 0.1], [-0.2, 0.6, -0.3], [0.4, 0.1, -0.55],
[-0.55, 0.3, 0.25], [0.1, -0.6, -0.25],
];
export function CellDiagram3D({ 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 === "citologia").map((s) => [s.slug, s])),
[bySlugAll],
);
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">
Citologia (A Célula) — modelo ilustrativo, gire e clique nas organelas
</h2>
<button
onClick={() => toggleSystem("citologia")}
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: [1.6, 1.1, 1.8], fov: 45 }}>
<color attach="background" args={["#0b1220"]} />
<ambientLight intensity={0.7} />
<directionalLight position={[2, 3, 2]} intensity={1} />
<directionalLight position={[-2, -1, -2]} intensity={0.3} />
{/* Núcleo + nucléolo */}
<Organelle slug="celula-nucleo" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh position={[0.15, 0.1, 0]}>
<sphereGeometry args={[0.38, 32, 32]} />
<meshStandardMaterial {...materialProps("#8b5cf6", sel, 0.88)} />
</mesh>
)}
</Organelle>
<Organelle slug="celula-nucleolo" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<mesh position={[0.22, 0.16, 0.06]}>
<sphereGeometry args={[0.11, 20, 20]} />
<meshStandardMaterial {...materialProps("#5b21b6", sel)} />
</mesh>
)}
</Organelle>
{/* Retículo endoplasmático rugoso — anéis achatados perto do núcleo */}
<Organelle slug="celula-reticulo-rugoso" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<group position={[-0.15, 0.05, 0.1]} rotation={[0.3, 0.4, 0]}>
{[0, 1, 2].map((i) => (
<mesh key={i} position={[0, i * 0.12 - 0.1, 0]}>
<torusGeometry args={[0.32 + i * 0.05, 0.035, 8, 24, Math.PI * 1.4]} />
<meshStandardMaterial {...materialProps("#2dd4bf", sel, 0.95)} />
</mesh>
))}
</group>
)}
</Organelle>
{/* Retículo endoplasmático liso — tubos sem ribossomos, mais afastado */}
<Organelle slug="celula-reticulo-liso" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<group position={[-0.45, -0.15, -0.2]} rotation={[0.2, -0.5, 0.3]}>
{[0, 1].map((i) => (
<mesh key={i} position={[i * 0.1, i * 0.14, 0]}>
<torusGeometry args={[0.22 + i * 0.04, 0.03, 8, 20, Math.PI * 1.6]} />
<meshStandardMaterial {...materialProps("#a3e635", sel, 0.95)} />
</mesh>
))}
</group>
)}
</Organelle>
{/* Complexo de Golgi — pilha de discos curvos perto do núcleo */}
<Organelle slug="celula-golgi" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<group position={[0.5, -0.15, -0.25]} rotation={[0.4, 0.6, 0.1]}>
{[0, 1, 2, 3, 4].map((i) => (
<mesh key={i} position={[0, i * 0.055, 0]}>
<torusGeometry args={[0.16 - i * 0.008, 0.022, 8, 20, Math.PI * 1.5]} />
<meshStandardMaterial {...materialProps("#f59e0b", sel, 0.95)} />
</mesh>
))}
</group>
)}
</Organelle>
{/* Mitocôndrias — várias, espalhadas */}
<Organelle slug="celula-mitocondrias" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<>
{MITOCHONDRIA_SPOTS.map(([x, y, z, rot], i) => (
<mesh key={i} position={[x, y, z]} rotation={[0, rot, rot * 0.5]} scale={[1, 0.55, 0.55]}>
<sphereGeometry args={[0.13, 16, 16]} />
<meshStandardMaterial {...materialProps("#ef4444", sel, 0.95)} />
</mesh>
))}
</>
)}
</Organelle>
{/* Lisossomos — pequenas esferas isoladas */}
<Organelle slug="celula-lisossomos" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<>
{LYSOSOME_SPOTS.map(([x, y, z], i) => (
<mesh key={i} position={[x, y, z]}>
<sphereGeometry args={[0.055, 14, 14]} />
<meshStandardMaterial {...materialProps("#eab308", sel, 0.95)} />
</mesh>
))}
</>
)}
</Organelle>
{/* Ribossomos — pontinhos livres pelo citoplasma e no RE rugoso */}
<Organelle slug="celula-ribossomos" bySlug={bySlug} onSelect={selectStructure}>
{(sel) => (
<>
{RIBOSOME_SPOTS.map(([x, y, z], i) => (
<mesh key={i} position={[x, y, z]}>
<sphereGeometry args={[0.025, 8, 8]} />
<meshStandardMaterial {...materialProps("#cbd5e1", sel, 1)} />
</mesh>
))}
</>
)}
</Organelle>
{/* Membrana plasmática — envoltório translúcido por fora de tudo.
Sem raycast: sendo a casca que ENVOLVE todas as outras
organelas, ela sempre seria o primeiro acerto de qualquer
raio de clique, escondendo as organelas de dentro atrás
dela (o problema que aconteceu com a malha real de FIB-SEM).
Puramente decorativa aqui; ainda dá pra abrir a descrição
dela pela busca. */}
<mesh raycast={() => null}>
<sphereGeometry args={[0.85, 32, 32]} />
<meshStandardMaterial color="#93c5fd" transparent opacity={0.12} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
<CameraControls makeDefault minDistance={0.5} maxDistance={6} />
</Canvas>
</div>
<p className="text-[11px] text-slate-500 mt-2">
Modelo ilustrativo (não derivado de dado real) — proporções e posições relativas são esquemáticas, pensadas pra reconhecer cada organela.
</p>
</div>
</div>
);
}
+8 -14
View File
@@ -3,14 +3,15 @@ import { Canvas, useThree } from "@react-three/fiber";
import { CameraControls, Grid } from "@react-three/drei";
import * as THREE from "three";
import { useAtlasStore, type BodyRegion, type ClipAxis } from "../../store/useAtlasStore.js";
import { SystemModel, CELL_MEMBRANE_SLUG } from "./SystemModel.js";
import { SystemModel } from "./SystemModel.js";
import type { Structure } from "../../lib/api.js";
// Sistemas apresentados como diagrama 2D (fotomicrografia real com
// hotspots) em vez de malha 3D — ver SkinDiagram.tsx e
// MicroscopyDiagram.tsx. Citologia usa malha 3D real (ver
// build-manifest-cell.mjs), então não entra aqui.
const NON_3D_SYSTEMS = new Set(["tegumentar", "histologia"]);
// Sistemas sem .glb pra carregar — tegumentar/histologia são
// fotomicrografias 2D reais com hotspots (ver SkinDiagram.tsx e
// MicroscopyDiagram.tsx) e citologia é um modelo 3D ilustrativo desenhado
// à mão em Three.js (ver CellDiagram3D.tsx), não uma malha carregada de
// arquivo.
const NON_3D_SYSTEMS = new Set(["tegumentar", "histologia", "citologia"]);
// Normais de referência em anatomia: sagital separa esq./dir., coronal
// (= frontal) separa ant./post., transversal separa sup./inf.
@@ -260,14 +261,7 @@ function ClickSelector({
ndc.set(x, y);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(scene.children, true);
const valid = hits.filter((h) => h.object.visible && h.object.name && bySlug.has(h.object.name));
// A membrana plasmática da célula é semitransparente de propósito
// (ver SystemModel.tsx) justamente pra deixar ver as organelas por
// dentro — clicar nela não pode "capturar" o clique antes de
// chegar na organela real por trás; só vira o resultado do clique
// se não houver mais nada na frente/atrás dela nesse raio.
const hit =
valid.find((h) => h.object.name !== CELL_MEMBRANE_SLUG) ?? valid[0];
const hit = hits.find((h) => h.object.visible && h.object.name && bySlug.has(h.object.name));
if (hit && (!best || hit.distance < best.distance)) {
best = { distance: hit.distance, slug: hit.object.name };
}
@@ -17,13 +17,6 @@ interface Props {
const HIGHLIGHT = new THREE.Color("#22d3ee");
const BLACK = new THREE.Color(0x000000);
// A membrana plasmática é a malha REAL (FIB-SEM) de fora de uma célula
// HeLa cultivada — achatada e irregular (célula aderente em cultura, não
// uma bolinha lisa), não um erro. Mas sólida e opaca ela cobria e escondia
// as organelas de dentro (núcleo, mitocôndrias etc.), que é o oposto do
// que se quer pra estudar a célula — por isso ela é semitransparente.
export const CELL_MEMBRANE_SLUG = "celula-membrana-plasmatica";
export function SystemModel({
modelFile,
structuresInFile,
@@ -127,13 +120,6 @@ export function SystemModel({
const isSelected = structure.slug === selectedSlug;
mat.emissive = isSelected ? HIGHLIGHT : BLACK;
mat.emissiveIntensity = isSelected ? 0.6 : 0;
if (structure.slug === CELL_MEMBRANE_SLUG) {
mat.transparent = true;
mat.opacity = 0.18;
mat.depthWrite = false;
mat.side = THREE.DoubleSide;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [root, bySlugInFile, byMeshNameInFile, visibleSystems, selectedSlug, clippingPlanes]);
+2
View File
@@ -9,6 +9,7 @@ import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
import { QuickRegionNav } from "../components/QuickRegionNav.js";
import { SkinDiagram } from "../components/SkinDiagram.js";
import { MicroscopyDiagram } from "../components/MicroscopyDiagram.js";
import { CellDiagram3D } from "../components/CellDiagram3D.js";
import { trackVisit } from "../lib/analytics.js";
// Em telas pequenas não há espaço para os 3 painéis lado a lado — os
@@ -74,6 +75,7 @@ export default function AtlasPage() {
<QuickRegionNav />
<SkinDiagram bySlug={bySlug} />
<MicroscopyDiagram bySlug={bySlug} systemSlug="histologia" title="Histologia (Tecidos)" />
<CellDiagram3D bySlug={bySlug} />
<button
onClick={() => setLeftOpen(true)}