novo modelo 3d
This commit is contained in:
1 parent
bc7e77b007
commit
08cb8c426f
11 files changed
+301
-256
No files matched your search
@@ -3,17 +3,8 @@
|
||||
# Alpine essa dependência é bem mais dolorosa de resolver.
|
||||
FROM node:20-bookworm
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends assimp-utils ca-certificates python3 python3-venv \
|
||||
&& apt-get install -y --no-install-recommends assimp-utils ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Ambiente Python isolado só pra buscar as organelas reais da célula
|
||||
# (fetch-cell-organelles.py) — cloud-volume/trimesh não têm nada a ver com
|
||||
# o resto do pipeline (100% Node/glTF-transform), então ficam num venv
|
||||
# próprio em vez de poluir o Python do sistema.
|
||||
RUN python3 -m venv /opt/venv-cell \
|
||||
&& /opt/venv-cell/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /opt/venv-cell/bin/pip install --no-cache-dir cloud-volume trimesh
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
"extract:manifest": "node scripts/extract-manifest.mjs",
|
||||
"build:tegumentar": "node seed/build-manifest.mjs",
|
||||
"build:microscopy": "node seed/build-manifest-microscopy.mjs",
|
||||
"cell:fetch": "/opt/venv-cell/bin/python scripts/fetch-cell-organelles.py",
|
||||
"cell:model": "node scripts/build-cell-model.mjs",
|
||||
"cell:manifest": "node seed/build-manifest-cell.mjs",
|
||||
"build:cell": "node seed/build-manifest-cell.mjs",
|
||||
"merge:manifest": "node scripts/merge-manifests.mjs",
|
||||
"seed:db": "node scripts/seed-db.mjs",
|
||||
"run:all": "npm run fetch:models && npm run convert:models && npm run extract:manifest && npm run build:tegumentar && npm run build:microscopy && npm run cell:fetch && npm run cell:model && npm run cell:manifest && npm run merge:manifest && npm run seed:db"
|
||||
"run:all": "npm run fetch:models && npm run convert:models && npm run extract:manifest && npm run build:tegumentar && npm run build:microscopy && npm run build:cell && npm run merge:manifest && npm run seed:db"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.0.4",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// Lê citologia-raw.glb (gerado por fetch-cell-organelles.py a partir de
|
||||
// dados REAIS de FIB-SEM do Janelia OpenOrganelle/CellMap — ver aquele
|
||||
// script para a curadoria completa e a licença CC BY 4.0), simplifica e
|
||||
// comprime com o MESMO recipe já usado pras malhas do Z-Anatomy em
|
||||
// extract-manifest.mjs, e salva o resultado final em seed/models/citologia.glb.
|
||||
//
|
||||
// Diferente do Z-Anatomy, aqui não precisamos marcar nós excluídos nem
|
||||
// resolver nomes duplicados — o .glb de entrada já tem exatamente 5 nós,
|
||||
// um por organela, com o nome final (ex: "celula-nucleo") definido no
|
||||
// próprio script Python. weld/simplify/dedup/meshopt só afetam a
|
||||
// geometria, não o nome dos nós, então o "meshName" no manifesto
|
||||
// (build-manifest-cell.mjs) pode confiar que bate exatamente com isso.
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { prune, dedup, weld, simplify, meshopt } from "@gltf-transform/functions";
|
||||
import { MeshoptSimplifier, MeshoptEncoder } from "meshoptimizer";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
await MeshoptSimplifier.ready;
|
||||
await MeshoptEncoder.ready;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const IN_PATH = path.resolve(__dirname, "../converted/citologia/citologia-raw.glb");
|
||||
const OUT_DIR = path.resolve(__dirname, "../seed/models");
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const io = new NodeIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({ "meshopt.encoder": MeshoptEncoder });
|
||||
|
||||
console.log(`Lendo ${IN_PATH}...`);
|
||||
const doc = await io.read(IN_PATH);
|
||||
|
||||
const meshNames = doc
|
||||
.getRoot()
|
||||
.listNodes()
|
||||
.map((n) => n.getName())
|
||||
.filter(Boolean);
|
||||
console.log(`Organelas encontradas: ${meshNames.join(", ")}`);
|
||||
|
||||
await doc.transform(
|
||||
weld(),
|
||||
// As organelas maiores (membrana, RE) chegam com bem mais geometria do
|
||||
// que o necessário pra estudo interativo — mesmo ratio conservador já
|
||||
// usado no resto do atlas.
|
||||
simplify({ simplifier: MeshoptSimplifier, ratio: 0.5, error: 0.01 }),
|
||||
prune(),
|
||||
dedup(),
|
||||
meshopt({ encoder: MeshoptEncoder, level: "medium" }),
|
||||
);
|
||||
|
||||
const outPath = path.join(OUT_DIR, "citologia.glb");
|
||||
await io.write(outPath, doc);
|
||||
console.log(`✓ ${outPath} salvo.`);
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Busca organelas REAIS de uma célula HeLa inteira, reconstruída em 3D a
|
||||
# partir de FIB-SEM (microscopia eletrônica volumétrica) e segmentada por
|
||||
# machine learning pelo projeto CellMap/COSEM do Janelia Research Campus
|
||||
# (HHMI) — publicado como OpenOrganelle, CC BY 4.0.
|
||||
# https://openorganelle.janelia.org/ | dataset "jrc_hela-2"
|
||||
# DOI da coleção: https://doi.org/10.25378/janelia.c.7456966
|
||||
#
|
||||
# POR QUE ISSO E NÃO GEOMETRIA PROCEDURAL: é o mesmo princípio já usado pro
|
||||
# resto do atlas (malhas reais do Z-Anatomy) — aqui não existe um "Z-Anatomy
|
||||
# da célula" pronto pra converter de FBX, mas o Janelia publica a segmentação
|
||||
# 3D real como malhas no formato Neuroglancer "precomputed" (multi-resolução,
|
||||
# comprimido com Draco). Este script usa a biblioteca `cloud-volume` (Seung
|
||||
# Lab) pra baixar e decodificar essas malhas direto do bucket público, sem
|
||||
# fabricar nenhuma forma.
|
||||
#
|
||||
# Escolhas de curadoria (documentadas pra poder ajustar depois):
|
||||
# - Cada organela é uma "camada" de segmentação própria no dataset
|
||||
# (nucleus_seg, mito_seg, er_seg, golgi_seg, pm_seg), com um ID por
|
||||
# instância. Pegamos a maior instância de núcleo/membrana (a célula
|
||||
# mais completa no campo de visão) e TODAS as instâncias de
|
||||
# mitocôndria/RE/Golgi (são realmente múltiplas organelas de verdade,
|
||||
# não fragmentos de ruído — juntar todas reproduz a distribuição real).
|
||||
# - LOD (nível de detalhe) escolhido por organela: mais fino pra
|
||||
# estruturas pequenas/poucas (núcleo, Golgi), mais grosso pras que têm
|
||||
# MUITA geometria agregada (mitocôndrias, RE) — o pipeline Node.js
|
||||
# (build-cell-model.mjs) simplifica/comprime tudo de qualquer forma.
|
||||
# - Todas as peças são recentralizadas usando o MESMO deslocamento (o
|
||||
# centro da membrana plasmática) — cada organela NÃO pode ser
|
||||
# recentralizada individualmente, isso destruiria a posição relativa
|
||||
# real dentro da célula.
|
||||
# - Escala: nanômetros (unidade nativa do dataset) → micrômetros (÷1000),
|
||||
# só por conveniência numérica — a câmera do app se ajusta ao tamanho
|
||||
# real do conteúdo de qualquer forma (ver Scene.tsx/CameraController).
|
||||
#
|
||||
# Vesículas (~40 mil instâncias) e endossomos (~5.600) ficaram de fora
|
||||
# desta primeira versão por volume de dados — dá pra adicionar depois como
|
||||
# mais uma organela seguindo exatamente o mesmo padrão abaixo.
|
||||
import os
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import cloudvolume as cv
|
||||
import trimesh
|
||||
|
||||
DATASET = "jrc_hela-2"
|
||||
BASE = f"precomputed://https://janelia-cosem-datasets.s3.amazonaws.com/{DATASET}/neuroglancer/mesh"
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "converted", "citologia")
|
||||
|
||||
# (nome do nó no glb, camada de segmentação, IDs de instância, LOD)
|
||||
GROUPS = [
|
||||
("celula-membrana-plasmatica", "pm_seg", [2], 4),
|
||||
("celula-nucleo", "nucleus_seg", [1], 3),
|
||||
("celula-reticulo-endoplasmatico", "er_seg", list(range(1, 25)), 4),
|
||||
("celula-complexo-de-golgi", "golgi_seg", list(range(1, 9)), 3),
|
||||
("celula-mitocondrias", "mito_seg", list(range(1, 422)), 4),
|
||||
]
|
||||
|
||||
SCALE_NM_TO_UM = 1.0 / 1000.0
|
||||
|
||||
|
||||
def make_volume(seg_layer):
|
||||
# CloudVolume espera o "info" completo de uma camada de imagem/
|
||||
# segmentação (com "scales"), mas só precisamos da parte de malha —
|
||||
# os valores de "scales" abaixo são um placeholder mínimo válido só
|
||||
# pra passar da checagem interna da biblioteca; a resolução REAL usada
|
||||
# pra posicionar os vértices vem do "transform" dentro do info.json de
|
||||
# cada camada de malha (lido automaticamente pelo cloud-volume).
|
||||
info = {
|
||||
"type": "segmentation",
|
||||
"data_type": "uint64",
|
||||
"num_channels": 1,
|
||||
"scales": [{
|
||||
"chunk_sizes": [[64, 64, 64]],
|
||||
"encoding": "raw",
|
||||
"key": "1_1_1",
|
||||
"resolution": [4, 4, 5.24],
|
||||
"size": [1, 1, 1],
|
||||
"voxel_offset": [0, 0, 0],
|
||||
}],
|
||||
"mesh": seg_layer,
|
||||
}
|
||||
return cv.CloudVolume(BASE, info=info, use_https=True)
|
||||
|
||||
|
||||
def fetch_group(name, seg_layer, ids, lod):
|
||||
t0 = time.time()
|
||||
vol = make_volume(seg_layer)
|
||||
meshes = vol.mesh.get(ids, lod=lod)
|
||||
parts = []
|
||||
for m in meshes.values():
|
||||
if m is None or len(m.vertices) == 0 or len(m.faces) == 0:
|
||||
continue
|
||||
# A decodificação Draco em algumas LODs mais grosseiras às vezes
|
||||
# produz vértices NaN/Inf pra fragmentos degenerados — zera-los
|
||||
# em vez de excluir a organela inteira mantém a peça utilizável.
|
||||
vertices = np.nan_to_num(m.vertices, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
parts.append(trimesh.Trimesh(vertices=vertices, faces=m.faces, process=False))
|
||||
if not parts:
|
||||
raise RuntimeError(f"{name}: nenhuma geometria válida encontrada (verifique os IDs/LOD)")
|
||||
combined = trimesh.util.concatenate(parts)
|
||||
print(f" {name}: {len(combined.vertices)} vértices, {len(combined.faces)} faces ({time.time() - t0:.1f}s)", flush=True)
|
||||
return combined
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Baixando organelas reais de {DATASET} (Janelia OpenOrganelle/CellMap, CC BY 4.0)...")
|
||||
results = {}
|
||||
for name, seg_layer, ids, lod in GROUPS:
|
||||
results[name] = fetch_group(name, seg_layer, ids, lod)
|
||||
|
||||
center = results["celula-membrana-plasmatica"].bounds.mean(axis=0)
|
||||
|
||||
scene = trimesh.Scene()
|
||||
for name, mesh in results.items():
|
||||
mesh.vertices = (mesh.vertices - center) * SCALE_NM_TO_UM
|
||||
scene.add_geometry(mesh, node_name=name, geom_name=name)
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
out_path = os.path.join(OUT_DIR, "citologia-raw.glb")
|
||||
scene.export(out_path)
|
||||
print(f"✓ {out_path} salvo — pronto para o build-cell-model.mjs simplificar/comprimir.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,4 +17,4 @@ writeFileSync(
|
||||
JSON.stringify({ structures }, null, 2),
|
||||
);
|
||||
|
||||
console.log(`manifest.json final: ${structures.length} estruturas (${real.structures.length} reais + ${tegumentar.structures.length} tegumentar + ${microscopy.structures.length} histologia + ${cell.structures.length} citologia 3D).`);
|
||||
console.log(`manifest.json final: ${structures.length} estruturas (${real.structures.length} reais + ${tegumentar.structures.length} tegumentar + ${microscopy.structures.length} histologia + ${cell.structures.length} citologia).`);
|
||||
@@ -1,27 +1,20 @@
|
||||
// Gera manifest-cell.json — as 5 organelas do sistema "citologia", com
|
||||
// malha 3D REAL (ao contrário de "histologia", que continua sendo
|
||||
// fotomicrografias 2D — ver build-manifest-microscopy.mjs).
|
||||
// Gera manifest-cell.json — as organelas do sistema "citologia".
|
||||
//
|
||||
// Fonte: célula HeLa (jrc_hela-2) reconstruída em 3D a partir de FIB-SEM
|
||||
// real e segmentada por machine learning pelo projeto CellMap/COSEM do
|
||||
// Janelia Research Campus (HHMI) — OpenOrganelle, CC BY 4.0.
|
||||
// https://openorganelle.janelia.org/ — DOI da coleção:
|
||||
// https://doi.org/10.25378/janelia.c.7456966
|
||||
// Extração das malhas: data-pipeline/scripts/fetch-cell-organelles.py
|
||||
// Simplificação/compressão final: data-pipeline/scripts/build-cell-model.mjs
|
||||
//
|
||||
// O nome de cada nó do citologia.glb (definido no script Python) é usado
|
||||
// aqui como "meshName" — sem simplificação de nome/exclusão como no
|
||||
// esquelético, já que são só 5 peças, cada uma com nome final único.
|
||||
// Chegamos a integrar uma malha 3D REAL (segmentação FIB-SEM de uma célula
|
||||
// HeLa completa, Janelia OpenOrganelle/CellMap, CC BY 4.0), mas o
|
||||
// resultado visual atrapalhava mais do que ajudava pra estudar: a
|
||||
// membrana real de uma célula aderente em cultura é achatada e irregular
|
||||
// (não a "bolinha" clássica de diagrama didático) e cobria as organelas
|
||||
// de dentro. A pedido do usuário, citologia agora é um modelo 3D
|
||||
// ILUSTRATIVO — sem malha (modelFile "n/a", mesmo padrão do tegumentar),
|
||||
// renderizado com primitivas do Three.js em
|
||||
// frontend/src/components/CellDiagram3D.tsx (mesmo princípio do boneco
|
||||
// esquemático de Planos Anatômicos, ver Mannequin.tsx).
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MODEL_FILE = "citologia.glb";
|
||||
|
||||
const CREDIT =
|
||||
"Malha 3D real, segmentada por FIB-SEM (célula HeLa, jrc_hela-2) pelo projeto CellMap/Janelia Research Campus — OpenOrganelle, CC BY 4.0.";
|
||||
|
||||
function structure(slug, namePt, nameEn, description) {
|
||||
return {
|
||||
@@ -30,9 +23,9 @@ function structure(slug, namePt, nameEn, description) {
|
||||
namePt,
|
||||
nameEn,
|
||||
synonyms: [],
|
||||
description: `${description} ${CREDIT}`,
|
||||
regionPath: ["Citologia", "Célula (HeLa) — FIB-SEM real"],
|
||||
modelFile: MODEL_FILE,
|
||||
description,
|
||||
regionPath: ["Citologia", "Célula (modelo ilustrativo)"],
|
||||
modelFile: "n/a",
|
||||
meshName: slug,
|
||||
};
|
||||
}
|
||||
@@ -48,25 +41,49 @@ const structures = [
|
||||
"celula-nucleo",
|
||||
"Núcleo",
|
||||
"Nucleus",
|
||||
"Organela que guarda o DNA da célula e comanda a síntese proteica; a forma irregular e volumosa vista aqui é real e típica de células HeLa (uma linhagem tumoral, com núcleo frequentemente atípico).",
|
||||
"Organela que guarda o DNA da célula, organizado em cromatina, e comanda a síntese proteica ao transcrever RNA mensageiro; delimitado por um envoltório com poros nucleares.",
|
||||
),
|
||||
structure(
|
||||
"celula-reticulo-endoplasmatico",
|
||||
"Retículo Endoplasmático",
|
||||
"Endoplasmic Reticulum",
|
||||
"Rede de membranas contínua com o envoltório nuclear, espalhada por todo o citoplasma; participa da síntese de proteínas (porção rugosa) e lipídios, além do armazenamento de cálcio.",
|
||||
"celula-nucleolo",
|
||||
"Nucléolo",
|
||||
"Nucleolus",
|
||||
"Região densa dentro do núcleo, sem membrana própria, onde o RNA ribossômico é transcrito e as subunidades do ribossomo começam a ser montadas.",
|
||||
),
|
||||
structure(
|
||||
"celula-complexo-de-golgi",
|
||||
"celula-reticulo-rugoso",
|
||||
"Retículo Endoplasmático Rugoso",
|
||||
"Rough Endoplasmic Reticulum",
|
||||
"Rede de membranas contínua com o envoltório nuclear, coberta de ribossomos (daí o aspecto \"rugoso\"); sintetiza e processa proteínas destinadas à secreção, à membrana ou a outras organelas.",
|
||||
),
|
||||
structure(
|
||||
"celula-reticulo-liso",
|
||||
"Retículo Endoplasmático Liso",
|
||||
"Smooth Endoplasmic Reticulum",
|
||||
"Extensão do retículo endoplasmático sem ribossomos na superfície; sintetiza lipídios e esteroides, e participa da desintoxicação de substâncias e do armazenamento de cálcio.",
|
||||
),
|
||||
structure(
|
||||
"celula-golgi",
|
||||
"Complexo de Golgi",
|
||||
"Golgi Apparatus",
|
||||
"Conjunto de sáculos membranosos empilhados, tipicamente posicionado perto do núcleo (posição confirmada nesta reconstrução real); processa, modifica e empacota proteínas vindas do retículo endoplasmático.",
|
||||
"Conjunto de sáculos membranosos empilhados, tipicamente próximo ao núcleo; processa, modifica (ex: glicosilação) e empacota proteínas vindas do retículo endoplasmático, endereçando-as ao destino final.",
|
||||
),
|
||||
structure(
|
||||
"celula-mitocondrias",
|
||||
"Mitocôndrias",
|
||||
"Mitochondria",
|
||||
"População completa de mitocôndrias segmentadas nesta célula — organelas responsáveis pela produção de ATP por respiração celular; note a variação real de tamanho e forma entre elas, incluindo fragmentação, comum em linhagens tumorais.",
|
||||
"Organelas de dupla membrana responsáveis pela produção de ATP por respiração celular — a \"usina de energia\" da célula; têm DNA próprio e se multiplicam por divisão.",
|
||||
),
|
||||
structure(
|
||||
"celula-ribossomos",
|
||||
"Ribossomos",
|
||||
"Ribosomes",
|
||||
"Complexos de RNA ribossômico e proteínas que traduzem RNA mensageiro em proteínas; podem estar livres no citosol ou aderidos ao retículo endoplasmático rugoso.",
|
||||
),
|
||||
structure(
|
||||
"celula-lisossomos",
|
||||
"Lisossomos",
|
||||
"Lysosomes",
|
||||
"Vesículas com enzimas digestivas (hidrolases ácidas) que degradam material vindo de dentro ou de fora da célula — o \"sistema de reciclagem e digestão\" celular.",
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ export const SYSTEMS = [
|
||||
{ slug: "tegumentar", namePt: "Sistema Tegumentar", nameEn: "Integumentary System", colorHex: "#e0a96d", order: 9 },
|
||||
{ slug: "regioes", namePt: "Regiões Anatômicas do Corpo", nameEn: "Body Regions", colorHex: "#f472b6", order: 10 },
|
||||
{ slug: "referencias", namePt: "Planos e Linhas de Referência", nameEn: "Reference Planes and Lines", colorHex: "#a3e635", order: 11 },
|
||||
// Citologia: malha 3D REAL (segmentação FIB-SEM de uma célula HeLa
|
||||
// inteira, Janelia OpenOrganelle/CellMap) — ver build-manifest-cell.mjs.
|
||||
// Citologia: sem malha (modelFile "n/a") — modelo 3D ILUSTRATIVO
|
||||
// desenhado com primitivas do Three.js (não há malha 3D real e aberta
|
||||
// de célula pronta pra uso didático) — ver build-manifest-cell.mjs e
|
||||
// CellDiagram3D.tsx.
|
||||
// Histologia: sem malha 3D (não existe dataset aberto de malha 3D real
|
||||
// de tecido histológico), usa fotomicrografias REAIS 2D com hotspots
|
||||
// — ver build-manifest-microscopy.mjs e MicroscopyDiagram.tsx.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user