adiciona modelo real

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-07 21:30:18 -03:00
1 parent 71d13753c9
commit 3a659a52c7
18 files changed
+605 -169

No files matched your search

+25 -12
View File
@@ -2,11 +2,17 @@
Plataforma web dockerizada para estudo de anatomia com um visualizador 3D interativo, camadas por sistema, dissecção virtual (plano de corte), busca/navegação por região e um modo quiz com repetição espaçada (SM-2).
## Aviso importante sobre o conteúdo 3D
## Conteúdo 3D: modelos reais do projeto Z-Anatomy
O dataset aberto **BodyParts3D** (lifesciencedb.jp/bp3d, CC BY-SA 2.1 Japan) não oferece um endpoint de download programável — os arquivos só são acessíveis por uma aplicação de visualização legada, exigindo download manual estrutura por estrutura. Por isso, o dataset semente incluído aqui (`data-pipeline/seed/build-manifest.mjs`) usa **geometria paramétrica simples (caixas/esferas/cilindros) posicionada nas proporções e localizações anatômicas corretas** de um corpo humano padrão — os nomes, sistemas, regiões e relações são anatomia real; só a malha 3D é esquemática, não fotorrealista.
Os modelos 3D vêm do **Z-Anatomy-Sample** (github.com/LluisV/Z-Anatomy-Sample), um reempacotamento aberto do dataset BodyParts3D/FMA (Database Center for Life Science, Univ. de Tóquio) em malhas segmentadas e nomeadas por estrutura, organizadas por sistema. Ao contrário do site oficial do BodyParts3D (que só permite download manual, estrutura por estrutura, por uma aplicação legada), o Z-Anatomy-Sample publica os arquivos FBX diretamente no GitHub, permitindo baixar e converter tudo via script.
A plataforma (frontend, backend, banco, Docker, todas as interações) está completa e pronta para receber malhas 3D reais. Para evoluir para segmentações reais do BodyParts3D (ou do projeto **Z-Anatomy**, que reempacota o mesmo dataset em Blender com nomes mais organizados), veja "Expandindo o dataset" abaixo — nenhum código do frontend/backend precisa mudar, só os arquivos `.glb` e o manifesto.
**Licença: CC BY-SA 4.0.** Isso significa: (1) é obrigatório dar crédito ao projeto original — já incluído no rodapé da aplicação e aqui; (2) qualquer redistribuição das malhas (mesmo modificadas, como as recolorizações que fazemos aqui) deve continuar sob a mesma licença CC BY-SA 4.0. Se for redistribuir este projeto publicamente, mantenha essa nota.
O pipeline (`data-pipeline/`) baixa os `.fbx` reais, converte para `.glb` via `assimp`, recolore cada malha por sistema (para um visual consistente, já que o FBX não carrega a paleta de cores usada no app Unity original do Z-Anatomy) e gera o manifesto do banco automaticamente a partir dos milhares de nomes de estrutura reais contidos nos arquivos — sem geometria placeholder, exceto pelo sistema tegumentar (veja abaixo).
**Tradução dos nomes**: como isso é uma ferramenta de estudo para prova, uma tradução errada de termo médico é pior do que nenhuma tradução — por isso só traduzimos para PT-BR quando o nome bate exatamente com um dicionário curado (`data-pipeline/scripts/translate-pt.mjs`, ~120 termos comuns). Estruturas fora desse dicionário aparecem com o nome original em inglês/latim, que também é nomenclatura anatômica padrão usada no ensino de anatomia.
**O que não veio do Z-Anatomy-Sample**: a amostra pública não inclui camadas de pele — por isso o sistema **tegumentar** continua usando geometria esquemática simples (`data-pipeline/seed/build-manifest.mjs`), como um placeholder claramente sinalizado. Também não importamos (ainda) os arquivos "Regions of human body" e "Reference lines, planes and movements" do mesmo repositório — dá para adicionar depois seguindo o mesmo padrão em `data-pipeline/scripts/sources.mjs`.
## Arquitetura
@@ -36,12 +42,15 @@ docker network inspect dockernet | grep -A2 IPv4Address
# ajuste ANATOMIA_WEB_IP no .env se 10.15.0.93 já estiver em uso
docker compose up -d --build anatomia-db anatomia-api anatomia-web
docker compose run --rm anatomia-pipeline # gera os modelos GLB semente e popula o banco
docker compose build anatomia-pipeline
docker compose run --rm anatomia-pipeline # baixa+converte os modelos reais e popula o banco
```
O `anatomia-pipeline` baixa ~230MB de arquivos FBX do GitHub e os converte com `assimp` — a primeira execução pode levar alguns minutos. Acompanhe com `docker compose run --rm anatomia-pipeline` mesmo (ele já roda em foreground) ou olhe os logs se rodar em background.
Depois, no host, copie `deploy/nginx-vhost-atlas.conf` para `/home/docker/docker/nginx/sites/30-atlas.conf` (ajustando o nome do arquivo para não colidir com os já existentes) e garanta que o DNS de `atlas.martinsevieira.adv.br` aponte para este servidor. O container `anatomia-web` já sobe com `LETSENCRYPT_HOST=atlas.martinsevieira.adv.br`, então o `acme-companion` existente no host emite o certificado automaticamente assim que o vhost estiver ativo e respondendo ao desafio ACME (veja os comentários no próprio arquivo `deploy/nginx-vhost-atlas.conf` para os detalhes/pré-requisitos).
Para reprocessar o dataset semente depois de editar `data-pipeline/seed/build-manifest.mjs`, rode `docker compose run --rm anatomia-pipeline` novamente.
Para reprocessar o dataset depois de mudar `data-pipeline/scripts/sources.mjs`, `translate-pt.mjs` ou `seed/build-manifest.mjs` (tegumentar), rode `docker compose run --rm anatomia-pipeline` novamente — ele reaproveita os `.fbx`/`.glb` já baixados/convertidos se ainda existirem no container (perdidos ao término do `--rm`, então uma nova execução sempre rebaixa/reconverte do zero; isso é intencional para manter o serviço fora do stack "sempre ligado", mas significa que cada execução leva alguns minutos).
### Rodando fora desse host (dev local, sem "dockernet")
@@ -69,7 +78,7 @@ Depois `docker compose up -d --build anatomia-db anatomia-api anatomia-web && do
## Funcionalidades
- **Camadas por sistema**: esquelético, muscular, nervoso, cardiovascular, respiratório, tegumentar — ligar/desligar independentemente.
- **Camadas por sistema**: esquelético, muscular, nervoso, cardiovascular, respiratório, órgãos viscerais (digestório/urinário/endócrino/reprodutor), articulações, linfático, tegumentar — ligar/desligar independentemente.
- **Labels clicáveis**: clique em qualquer estrutura no modelo 3D para ver nome, sistema, região e descrição.
- **Busca e navegação**: busca fuzzy + árvore por sistema/região; selecionar uma estrutura move a câmera até ela.
- **Dissecção virtual**: plano de corte deslizante nos três planos anatômicos (sagital, coronal, transversal).
@@ -78,10 +87,14 @@ Depois `docker compose up -d --build anatomia-db anatomia-api anatomia-web && do
## Expandindo o dataset
O schema (`backend/prisma/schema.prisma`) já suporta qualquer sistema anatômico adicional (digestório, urinário, endócrino, reprodutor, linfático, sentidos) — basta adicionar dados, sem mudar código:
1. **Mais arquivos do Z-Anatomy-Sample** (ex: "Regions of human body", "Reference lines, planes and movements"): adicione a entrada em `data-pipeline/scripts/sources.mjs` (`SOURCES` + `SOURCE_TARGETS`), rode o pipeline de novo.
2. **Melhorar a tradução PT-BR**: acrescente entradas em `data-pipeline/scripts/translate-pt.mjs` (`EXACT_PT`) — é correspondência exata de propósito, para não arriscar tradução errada de termo médico.
3. **Sistema tegumentar com malha real**: se aparecer uma fonte aberta com camadas de pele, substitua a geração esquemática em `data-pipeline/seed/build-manifest.mjs` pelo mesmo padrão de extração usado em `scripts/extract-manifest.mjs`.
4. **Novos sistemas anatômicos**: adicione em `data-pipeline/seed/systems.mjs` — o schema (`backend/prisma/schema.prisma`) já suporta qualquer sistema sem mudar código.
1. **Estruturas novas com geometria esquemática**: edite `data-pipeline/seed/build-manifest.mjs`, adicione entradas com `add(...)`/`addBilateral(...)` e rode o pipeline novamente.
2. **Malhas 3D reais (BodyParts3D/Z-Anatomy)**: exporte os `.obj` desejados (manualmente pelo visualizador do BodyParts3D, ou do projeto Blender Z-Anatomy), converta para `.glb` (`obj2gltf` ou exportação direta do Blender), e substitua a geração procedural daquela estrutura por esse arquivo real — nomeie o node/mesh igual ao `slug` já usado no manifesto para que clique, quiz e busca continuem funcionando sem alterações de código.
## Créditos
Modelos 3D derivados do projeto **Z-Anatomy** (github.com/LluisV/Z-Anatomy-Sample), com base no dataset BodyParts3D/FMA (Database Center for Life Science, Universidade de Tóquio) — licença **CC BY-SA 4.0**. As malhas foram convertidas de FBX para glTF e recoloridas por sistema anatômico para este atlas.
## Desenvolvimento local (sem Docker)
@@ -96,8 +109,8 @@ npm run dev
cd frontend && npm install
npm run dev # http://localhost:5173, faz proxy de /api e /models para :3333
# Dataset semente (em outro terminal, com DATABASE_URL exportada)
# Dataset (em outro terminal, com DATABASE_URL exportada)
# Requer o binário "assimp" instalado localmente (ex: apt/brew install assimp).
cd data-pipeline && npm install
npm run build:seed-models
npm run seed:db
npm run run:all
```
+8 -2
View File
@@ -1,6 +1,12 @@
FROM node:20-alpine
# Base Debian (não Alpine): precisamos do pacote "assimp-utils" (binário
# `assimp`) para converter os FBX reais do Z-Anatomy para glTF/GLB — no
# 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 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD ["sh", "-c", "node seed/build-manifest.mjs && node scripts/build-seed-models.mjs && node scripts/seed-db.mjs"]
CMD ["npm", "run", "run:all"]
+8 -2
View File
@@ -4,11 +4,17 @@
"type": "module",
"private": true,
"scripts": {
"build:seed-models": "node scripts/build-seed-models.mjs",
"seed:db": "node scripts/seed-db.mjs"
"fetch:models": "node scripts/fetch-real-models.mjs",
"convert:models": "node scripts/convert-fbx.mjs",
"extract:manifest": "node scripts/extract-manifest.mjs",
"build:tegumentar": "node seed/build-manifest.mjs && node scripts/build-seed-models.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 merge:manifest && npm run seed:db"
},
"dependencies": {
"@gltf-transform/core": "^4.0.4",
"@gltf-transform/extensions": "^4.0.4",
"@gltf-transform/functions": "^4.0.4",
"pg": "^8.12.0"
}
+6 -3
View File
@@ -5,7 +5,10 @@ import { fileURLToPath } from "node:url";
import { buildGeometry } from "./geometry.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const manifestPath = path.resolve(__dirname, "../seed/manifest.json");
const manifestPath = path.resolve(
__dirname,
process.argv[2] ?? "../seed/manifest-tegumentar.json",
);
const outDir = path.resolve(__dirname, "../seed/models");
mkdirSync(outDir, { recursive: true });
@@ -80,9 +83,9 @@ for (const [systemSlug, items] of bySystem) {
.setIndices(idxAccessor)
.setMaterial(material);
const mesh = doc.createMesh(item.slug).addPrimitive(prim);
const mesh = doc.createMesh(item.meshName).addPrimitive(prim);
const node = doc
.createNode(item.slug)
.createNode(item.meshName)
.setMesh(mesh)
.setTranslation(item.position)
.setRotation(eulerToQuat(item.rotation ?? [0, 0, 0]));
+24
View File
@@ -0,0 +1,24 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SOURCES } from "./sources.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW_DIR = path.resolve(__dirname, "../raw");
const CONVERTED_DIR = path.resolve(__dirname, "../converted");
mkdirSync(CONVERTED_DIR, { recursive: true });
for (const { fbx, key } of SOURCES) {
const inPath = path.join(RAW_DIR, fbx);
const outPath = path.join(CONVERTED_DIR, `${key}.glb`);
if (existsSync(outPath)) {
console.log(`✓ já convertido: ${fbx}`);
continue;
}
console.log(`⚙ convertendo ${fbx} → ${key}.glb ...`);
execFileSync("assimp", ["export", inPath, outPath, "-f", "glb2"], { stdio: "inherit" });
console.log(`✓ ${key}.glb gerado`);
}
console.log("Conversão FBX → glTF concluída.");
+136
View File
@@ -0,0 +1,136 @@
// Lê os .glb convertidos do FBX real (Z-Anatomy), recolore por sistema,
// garante nomes de nó únicos por arquivo (Z-Anatomy tem estruturas
// repetidas — costelas, vértebras, falanges — que podem compartilhar o
// mesmo nome bruto) e gera:
// - data-pipeline/seed/models/<arquivo>.glb (final, servido pela API)
// - data-pipeline/seed/manifest-real.json (entradas para o seed do banco)
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { prune, dedup } from "@gltf-transform/functions";
import { writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SOURCES, SOURCE_TARGETS, RESPIRATORY_KEYWORDS } from "./sources.mjs";
import { translateToPt } from "./translate-pt.mjs";
import { SYSTEMS } from "../seed/systems.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONVERTED_DIR = path.resolve(__dirname, "../converted");
const MODELS_OUT_DIR = path.resolve(__dirname, "../seed/models");
mkdirSync(MODELS_OUT_DIR, { recursive: true });
const systemBySlug = new Map(SYSTEMS.map((s) => [s.slug, s]));
function hexToRgb(hex) {
const n = parseInt(hex.replace("#", ""), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1];
}
const COMBINING_MARKS = new RegExp("[\\u0300-\\u036f]", "g");
function slugify(s) {
return s
.normalize("NFD")
.replace(COMBINING_MARKS, "") // remove diacríticos (á -> a, ç -> c, ...)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function classifySystem(key, rawName) {
const target = SOURCE_TARGETS[key];
if (target.system) return target.system;
// Só o "visceral" tem system === null: decide por palavra-chave.
const lower = rawName.toLowerCase();
return RESPIRATORY_KEYWORDS.some((kw) => lower.includes(kw)) ? "respiratorio" : "visceral";
}
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const manifest = [];
let totalStructures = 0;
for (const { fbx, key } of SOURCES) {
const inPath = path.join(CONVERTED_DIR, `${key}.glb`);
const target = SOURCE_TARGETS[key];
console.log(`Processando ${fbx} (${key})...`);
const doc = io.read(inPath);
const root = doc.getRoot();
const scene = root.getDefaultScene() ?? root.listScenes()[0];
if (!scene) {
console.warn(` ⚠ nenhuma cena encontrada em ${inPath}, pulando.`);
continue;
}
// Um material por sistema alvo deste arquivo (recoloração consistente,
// independente de como a FBX/Unity original colorizava as malhas).
const materialCache = new Map();
function materialFor(systemSlug) {
if (materialCache.has(systemSlug)) return materialCache.get(systemSlug);
const sys = systemBySlug.get(systemSlug);
const mat = doc
.createMaterial(systemSlug)
.setBaseColorFactor(hexToRgb(sys?.colorHex ?? "#cccccc"))
.setRoughnessFactor(0.55)
.setMetallicFactor(0.05);
materialCache.set(systemSlug, mat);
return mat;
}
const nameCounts = new Map(); // dedupe de nomes brutos duplicados no MESMO arquivo
let countThisFile = 0;
function visit(node) {
const mesh = node.getMesh();
if (mesh) {
const rawName = (node.getName() || mesh.getName() || "Estrutura sem nome").trim();
const n = (nameCounts.get(rawName) ?? 0) + 1;
nameCounts.set(rawName, n);
const uniqueMeshName = n === 1 ? rawName : `${rawName} (${n})`;
node.setName(uniqueMeshName);
const systemSlug = classifySystem(key, rawName);
const material = materialFor(systemSlug);
for (const prim of mesh.listPrimitives()) prim.setMaterial(material);
const sys = systemBySlug.get(systemSlug);
const slugPart = slugify(uniqueMeshName) || `sem-nome-${totalStructures}`;
const slug = `${systemSlug}-${slugPart}`;
manifest.push({
system: systemSlug,
slug,
namePt: translateToPt(rawName) + (n > 1 ? ` (${n})` : ""),
nameEn: rawName + (n > 1 ? ` (${n})` : ""),
synonyms: [],
description: "",
regionPath: [sys?.namePt ?? systemSlug],
modelFile: target.modelFile,
meshName: uniqueMeshName,
});
countThisFile += 1;
totalStructures += 1;
}
for (const child of node.listChildren()) visit(child);
}
for (const topNode of scene.listChildren()) visit(topNode);
console.log(` ${countThisFile} estruturas nomeadas encontradas.`);
if (countThisFile === 0) {
console.warn(` ⚠ nenhuma malha nomeada encontrada em ${fbx} — confira o log do assimp/conversão.`);
}
await doc.transform(prune(), dedup());
const outPath = path.join(MODELS_OUT_DIR, target.modelFile);
await io.write(outPath, doc);
console.log(` ✓ ${target.modelFile} salvo em seed/models/`);
}
writeFileSync(
path.resolve(__dirname, "../seed/manifest-real.json"),
JSON.stringify({ structures: manifest }, null, 2),
);
console.log(`\nTotal: ${totalStructures} estruturas reais extraídas do Z-Anatomy.`);
@@ -0,0 +1,32 @@
import { createWriteStream, existsSync, mkdirSync, statSync } from "node:fs";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { RAW_BASE, SOURCES } from "./sources.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW_DIR = path.resolve(__dirname, "../raw");
mkdirSync(RAW_DIR, { recursive: true });
async function download(fbx) {
const dest = path.join(RAW_DIR, fbx);
if (existsSync(dest) && statSync(dest).size > 0) {
console.log(`✓ já baixado: ${fbx}`);
return;
}
const url = RAW_BASE + fbx;
console.log(`↓ baixando ${fbx}...`);
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(`Falha ao baixar ${fbx}: HTTP ${res.status} (${url})`);
}
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
console.log(`✓ ${fbx} baixado (${(statSync(dest).size / 1024 / 1024).toFixed(1)} MB)`);
}
for (const { fbx } of SOURCES) {
await download(fbx);
}
console.log("Todos os modelos-fonte baixados.");
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SEED_DIR = path.resolve(__dirname, "../seed");
const real = JSON.parse(readFileSync(path.join(SEED_DIR, "manifest-real.json"), "utf-8"));
const tegumentar = JSON.parse(readFileSync(path.join(SEED_DIR, "manifest-tegumentar.json"), "utf-8"));
const structures = [...real.structures, ...tegumentar.structures];
writeFileSync(
path.join(SEED_DIR, "manifest.json"),
JSON.stringify({ structures }, null, 2),
);
console.log(`manifest.json final: ${structures.length} estruturas (${real.structures.length} reais + ${tegumentar.structures.length} tegumentar).`);
+4 -3
View File
@@ -6,10 +6,11 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
import { SYSTEMS as systems } from "../seed/systems.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const manifestPath = path.resolve(__dirname, "../seed/manifest.json");
const { systems, structures } = JSON.parse(readFileSync(manifestPath, "utf-8"));
const { structures } = JSON.parse(readFileSync(manifestPath, "utf-8"));
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
@@ -80,8 +81,8 @@ try {
st.synonyms ?? [],
st.description ?? null,
st.regionPath ?? [],
`${st.system}.glb`,
st.slug,
st.modelFile,
st.meshName,
systemId,
],
);
+47
View File
@@ -0,0 +1,47 @@
// Fonte real dos modelos: Z-Anatomy-Sample (github.com/LluisV/Z-Anatomy-Sample),
// CC-BY-SA 4.0 — reempacota o dataset BodyParts3D/FMA em malhas organizadas
// por sistema, com nomes de objeto reais em inglês. Servidos como blobs FBX
// binários comuns (não Git LFS), então dá para baixar via raw.githubusercontent.com.
//
// Deixamos de fora "Regions of human body100.fbx" e "Reference lines,
// planes and movements 1.fbx" nesta primeira importação (nomes com espaço,
// conteúdo mais voltado a navegação/didática do que estruturas discretas) —
// dá para adicionar depois seguindo o mesmo padrão.
export const RAW_BASE =
"https://raw.githubusercontent.com/LluisV/Z-Anatomy-Sample/main/Assets/Models/1.0%20Models/";
export const SOURCES = [
{ fbx: "SkeletalSystem100.fbx", key: "skeletal" },
{ fbx: "MuscularSystem100.fbx", key: "muscular" },
{ fbx: "NervousSystem100.fbx", key: "nervous" },
{ fbx: "CardioVascular41.fbx", key: "cardio" },
{ fbx: "VisceralSystem100.fbx", key: "visceral" },
{ fbx: "Joints100.fbx", key: "joints" },
{ fbx: "LymphoidOrgans100.fbx", key: "lymphoid" },
];
// Palavras-chave (substring, case-insensitive) que classificam um nó do
// VisceralSystem100.fbx como respiratório em vez de "visceral genérico".
export const RESPIRATORY_KEYWORDS = [
"lung",
"trachea",
"bronch",
"larynx",
"pharynx",
"diaphragm",
"pleura",
"alveol",
];
// key (fonte) -> { modelFile, system } — "visceral" é especial: o system
// real é decidido por nó (respiratorio vs visceral) via RESPIRATORY_KEYWORDS,
// mas os dois compartilham o mesmo arquivo .glb final.
export const SOURCE_TARGETS = {
skeletal: { modelFile: "esqueletico.glb", system: "esqueletico" },
muscular: { modelFile: "muscular.glb", system: "muscular" },
nervous: { modelFile: "nervoso.glb", system: "nervoso" },
cardio: { modelFile: "cardiovascular.glb", system: "cardiovascular" },
visceral: { modelFile: "visceral-organs.glb", system: null },
joints: { modelFile: "articular.glb", system: "articular" },
lymphoid: { modelFile: "linfatico.glb", system: "linfatico" },
};
+174
View File
@@ -0,0 +1,174 @@
// Tradução EN→PT-BR por correspondência EXATA (não palavra-por-palavra).
//
// Decisão deliberada: este é um app de estudo para prova de anatomia — uma
// tradução errada de um termo médico é pior do que nenhuma tradução. Por
// isso só traduzimos quando o nome bate 100% (após normalizar espaços e
// remover prefixo/sufixo "Left"/"Right") com uma entrada conhecida deste
// dicionário. Fora daqui, o nome original em inglês/latim é mantido —
// nomenclatura anatômica internacional também é usada normalmente no
// ensino de anatomia no Brasil, então isso não compromete o estudo.
const EXACT_PT = {
// Ossos
"skull": "Crânio",
"mandible": "Mandíbula",
"maxilla": "Maxila",
"frontal bone": "Osso Frontal",
"parietal bone": "Osso Parietal",
"occipital bone": "Osso Occipital",
"temporal bone": "Osso Temporal",
"sphenoid bone": "Osso Esfenoide",
"ethmoid bone": "Osso Etmoide",
"zygomatic bone": "Osso Zigomático",
"nasal bone": "Osso Nasal",
"hyoid bone": "Osso Hioide",
"clavicle": "Clavícula",
"scapula": "Escápula",
"sternum": "Esterno",
"rib": "Costela",
"humerus": "Úmero",
"radius": "Rádio",
"ulna": "Ulna",
"femur": "Fêmur",
"patella": "Patela",
"tibia": "Tíbia",
"fibula": "Fíbula",
"pelvis": "Pelve",
"sacrum": "Sacro",
"coccyx": "Cóccix",
"ilium": "Ílio",
"ischium": "Ísquio",
"pubis": "Púbis",
"vertebra": "Vértebra",
"atlas": "Atlas (C1)",
"axis": "Áxis (C2)",
// Músculos
"deltoid": "Deltoide",
"trapezius": "Trapézio",
"biceps brachii": "Bíceps Braquial",
"triceps brachii": "Tríceps Braquial",
"pectoralis major": "Peitoral Maior",
"pectoralis minor": "Peitoral Menor",
"rectus abdominis": "Reto Abdominal",
"external oblique": "Oblíquo Externo do Abdome",
"internal oblique": "Oblíquo Interno do Abdome",
"latissimus dorsi": "Latíssimo do Dorso",
"gluteus maximus": "Glúteo Máximo",
"gluteus medius": "Glúteo Médio",
"gluteus minimus": "Glúteo Mínimo",
"sartorius": "Sartório",
"gracilis": "Grácil",
"biceps femoris": "Bíceps Femoral",
"semitendinosus": "Semitendíneo",
"semimembranosus": "Semimembranáceo",
"gastrocnemius": "Gastrocnêmio",
"soleus": "Sóleo",
"tibialis anterior": "Tibial Anterior",
"sternocleidomastoid": "Esternocleidomastóideo",
"masseter": "Masseter",
"temporalis": "Temporal (músculo)",
"frontalis": "Frontal (músculo)",
"diaphragm": "Diafragma",
// Sistema nervoso
"brain": "Encéfalo",
"cerebrum": "Cérebro",
"cerebellum": "Cerebelo",
"brainstem": "Tronco Encefálico",
"spinal cord": "Medula Espinhal",
"sciatic nerve": "Nervo Isquiático (Ciático)",
"femoral nerve": "Nervo Femoral",
"median nerve": "Nervo Mediano",
"ulnar nerve": "Nervo Ulnar",
"radial nerve": "Nervo Radial",
"phrenic nerve": "Nervo Frênico",
"vagus nerve": "Nervo Vago",
"facial nerve": "Nervo Facial",
"trigeminal nerve": "Nervo Trigêmeo",
"optic nerve": "Nervo Óptico",
"olfactory nerve": "Nervo Olfatório",
"brachial plexus": "Plexo Braquial",
// Cardiovascular
"heart": "Coração",
"aorta": "Aorta",
"superior vena cava": "Veia Cava Superior",
"inferior vena cava": "Veia Cava Inferior",
"carotid artery": "Artéria Carótida",
"jugular vein": "Veia Jugular",
"subclavian artery": "Artéria Subclávia",
"brachial artery": "Artéria Braquial",
"femoral artery": "Artéria Femoral",
"pulmonary artery": "Artéria Pulmonar",
"pulmonary vein": "Veia Pulmonar",
"coronary artery": "Artéria Coronária",
// Respiratório
"trachea": "Traqueia",
"larynx": "Laringe",
"pharynx": "Faringe",
"lung": "Pulmão",
"bronchus": "Brônquio",
// Vísceras
"liver": "Fígado",
"stomach": "Estômago",
"kidney": "Rim",
"bladder": "Bexiga",
"pancreas": "Pâncreas",
"spleen": "Baço",
"esophagus": "Esôfago",
"colon": "Cólon",
"small intestine": "Intestino Delgado",
"large intestine": "Intestino Grosso",
"thyroid": "Tireoide",
"adrenal gland": "Glândula Suprarrenal",
"ovary": "Ovário",
"testis": "Testículo",
"uterus": "Útero",
"prostate": "Próstata",
// Articulações / linfático
"knee joint": "Articulação do Joelho",
"elbow joint": "Articulação do Cotovelo",
"shoulder joint": "Articulação do Ombro",
"hip joint": "Articulação do Quadril",
"ankle joint": "Articulação do Tornozelo",
"wrist joint": "Articulação do Punho",
"lymph node": "Linfonodo",
"thymus": "Timo",
"tonsil": "Tonsila",
};
function normalize(s) {
return s.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Traduz um nome anatômico em inglês para PT-BR quando há correspondência
* exata (ignorando prefixo/sufixo Left/Right), senão retorna o original.
*/
export function translateToPt(rawName) {
const trimmed = rawName.trim().replace(/\s+/g, " ");
const norm = normalize(trimmed);
let side = null;
let base = norm;
if (/^left\b/.test(norm)) {
side = "Esquerdo";
base = norm.replace(/^left\s*/, "");
} else if (/^right\b/.test(norm)) {
side = "Direito";
base = norm.replace(/^right\s*/, "");
} else if (/\bleft$/.test(norm)) {
side = "Esquerdo";
base = norm.replace(/\s*left$/, "");
} else if (/\bright$/.test(norm)) {
side = "Direito";
base = norm.replace(/\s*right$/, "");
}
const translated = EXACT_PT[base];
if (!translated) return trimmed;
return side ? `${translated} ${side}` : translated;
}
+9 -134
View File
@@ -1,40 +1,13 @@
// Generates manifest.json: the curated seed dataset of anatomical structures.
//
// IMPORTANT — read this before treating the geometry as "real anatomy":
// The BodyParts3D dataset (lifesciencedb.jp/bp3d) does not expose a scriptable
// download endpoint (it's served through a legacy Ext.js viewer app with no
// stable direct-download URLs per structure). Rather than block the whole
// platform on manually clicking through thousands of downloads, this seed
// dataset uses simple parametric primitives (box/sphere/cylinder) placed at
// anatomically correct positions/proportions on a standing human figure
// (1.8 m tall, origin at the feet, y-up, +z = anterior).
//
// The names, systems, regions and relationships ARE real anatomy — only the
// mesh shapes are schematic stand-ins. To upgrade to real segmented meshes:
// obtain BodyParts3D OBJ files (manually exported from the viewer, or via
// Z-Anatomy's Blender project which re-packages the same CC BY-SA dataset
// with saner file names), then replace the shape generator's output for a
// given `slug` with `scripts/convert-obj.mjs <file.obj> <slug>` (see
// data-pipeline/README.md). No frontend/backend code needs to change.
// Gera manifest-tegumentar.json: o Z-Anatomy-Sample não inclui camadas de
// pele (epiderme/derme/hipoderme), então para o sistema tegumentar seguimos
// usando geometria esquemática simples (ver data-pipeline/README rodapé).
// Todos os outros sistemas vêm de malhas reais — veja scripts/extract-manifest.mjs.
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @typedef {{type:'box',w:number,h:number,d:number}|{type:'sphere',r:number}|{type:'cylinder',rt:number,rb:number,h:number}} Shape */
const systems = [
{ slug: "esqueletico", namePt: "Sistema Esquelético", nameEn: "Skeletal System", colorHex: "#e8e2d0", order: 1 },
{ slug: "muscular", namePt: "Sistema Muscular", nameEn: "Muscular System", colorHex: "#b5433a", order: 2 },
{ slug: "nervoso", namePt: "Sistema Nervoso", nameEn: "Nervous System", colorHex: "#f2c14e", order: 3 },
{ slug: "cardiovascular", namePt: "Sistema Cardiovascular", nameEn: "Cardiovascular System", colorHex: "#c1121f", order: 4 },
{ slug: "respiratorio", namePt: "Sistema Respiratório", nameEn: "Respiratory System", colorHex: "#4f7cff", order: 5 },
{ slug: "tegumentar", namePt: "Sistema Tegumentar", nameEn: "Integumentary System", colorHex: "#e0a96d", order: 6 },
];
/** @type {any[]} */
const structures = [];
function add(system, slug, namePt, nameEn, region, shape, position, opts = {}) {
@@ -46,120 +19,22 @@ function add(system, slug, namePt, nameEn, region, shape, position, opts = {}) {
synonyms: opts.synonyms ?? [],
description: opts.description ?? "",
regionPath: region,
modelFile: `${system}.glb`,
meshName: slug,
shape,
position,
rotation: opts.rotation ?? [0, 0, 0],
color: opts.color,
});
}
// Adds a left/right pair mirrored across the sagittal (x=0) plane.
function addBilateral(system, baseSlug, baseNamePt, baseNameEn, region, shape, position, opts = {}) {
const [x, y, z] = position;
add(system, `${baseSlug}_direito`, `${baseNamePt} Direito`, `Right ${baseNameEn}`, region, shape, [x, y, z], opts);
add(system, `${baseSlug}_esquerdo`, `${baseNamePt} Esquerdo`, `Left ${baseNameEn}`, region, shape, [-x, y, z], opts);
}
// ---------------------------------------------------------------------------
// SISTEMA ESQUELÉTICO
// ---------------------------------------------------------------------------
const AX = ["Sistema Esquelético"];
add("esqueletico", "cranio", "Crânio", "Skull", [...AX, "Cabeça"], { type: "sphere", r: 0.11 }, [0, 1.72, 0]);
add("esqueletico", "mandibula", "Mandíbula", "Mandible", [...AX, "Cabeça"], { type: "box", w: 0.09, h: 0.04, d: 0.08 }, [0, 1.62, 0.03]);
add("esqueletico", "coluna_cervical", "Coluna Cervical", "Cervical Spine", [...AX, "Pescoço"], { type: "cylinder", rt: 0.02, rb: 0.02, h: 0.12 }, [0, 1.55, -0.02]);
add("esqueletico", "coluna_toracica", "Coluna Torácica", "Thoracic Spine", [...AX, "Tronco"], { type: "cylinder", rt: 0.025, rb: 0.025, h: 0.35 }, [0, 1.3, -0.03]);
add("esqueletico", "coluna_lombar", "Coluna Lombar", "Lumbar Spine", [...AX, "Tronco"], { type: "cylinder", rt: 0.03, rb: 0.03, h: 0.18 }, [0, 1.0, -0.02]);
add("esqueletico", "sacro", "Sacro", "Sacrum", [...AX, "Pelve"], { type: "box", w: 0.1, h: 0.08, d: 0.05 }, [0, 0.85, -0.03]);
add("esqueletico", "gradeado_costal", "Gradeado Costal (Costelas)", "Rib Cage", [...AX, "Tórax"], { type: "box", w: 0.3, h: 0.3, d: 0.18 }, [0, 1.25, 0]);
add("esqueletico", "esterno", "Esterno", "Sternum", [...AX, "Tórax"], { type: "box", w: 0.05, h: 0.2, d: 0.02 }, [0, 1.28, 0.1]);
addBilateral("esqueletico", "clavicula", "Clavícula", "Clavicle", [...AX, "Cintura Escapular"], { type: "cylinder", rt: 0.012, rb: 0.012, h: 0.16 }, [0.12, 1.42, 0.04], { rotation: [0, 0, 1.3] });
addBilateral("esqueletico", "escapula", "Escápula", "Scapula", [...AX, "Cintura Escapular"], { type: "box", w: 0.1, h: 0.14, d: 0.015 }, [0.15, 1.35, -0.06]);
addBilateral("esqueletico", "umero", "Úmero", "Humerus", [...AX, "Membro Superior", "Braço"], { type: "cylinder", rt: 0.025, rb: 0.02, h: 0.28 }, [0.22, 1.15, 0]);
addBilateral("esqueletico", "radio", "Rádio", "Radius", [...AX, "Membro Superior", "Antebraço"], { type: "cylinder", rt: 0.015, rb: 0.013, h: 0.24 }, [0.24, 0.85, 0.02]);
addBilateral("esqueletico", "ulna", "Ulna", "Ulna", [...AX, "Membro Superior", "Antebraço"], { type: "cylinder", rt: 0.016, rb: 0.013, h: 0.25 }, [0.24, 0.85, -0.02]);
addBilateral("esqueletico", "carpo", "Ossos do Carpo", "Carpal Bones", [...AX, "Membro Superior", "Mão"], { type: "sphere", r: 0.02 }, [0.24, 0.68, 0]);
addBilateral("esqueletico", "mao", "Metacarpo e Falanges da Mão", "Hand Metacarpals/Phalanges", [...AX, "Membro Superior", "Mão"], { type: "box", w: 0.05, h: 0.1, d: 0.015 }, [0.24, 0.6, 0]);
add("esqueletico", "pelve", "Pelve (Ossos do Quadril)", "Pelvis (Hip Bones)", [...AX, "Pelve"], { type: "box", w: 0.28, h: 0.15, d: 0.15 }, [0, 0.78, 0]);
addBilateral("esqueletico", "femur", "Fêmur", "Femur", [...AX, "Membro Inferior", "Coxa"], { type: "cylinder", rt: 0.032, rb: 0.026, h: 0.42 }, [0.09, 0.5, 0]);
addBilateral("esqueletico", "patela", "Patela", "Patella", [...AX, "Membro Inferior", "Joelho"], { type: "sphere", r: 0.025 }, [0.09, 0.28, 0.04]);
addBilateral("esqueletico", "tibia", "Tíbia", "Tibia", [...AX, "Membro Inferior", "Perna"], { type: "cylinder", rt: 0.024, rb: 0.018, h: 0.35 }, [0.09, 0.12, 0]);
addBilateral("esqueletico", "fibula", "Fíbula", "Fibula", [...AX, "Membro Inferior", "Perna"], { type: "cylinder", rt: 0.012, rb: 0.01, h: 0.34 }, [0.13, 0.12, -0.01]);
addBilateral("esqueletico", "tarso", "Ossos do Tarso", "Tarsal Bones", [...AX, "Membro Inferior", "Pé"], { type: "sphere", r: 0.025 }, [0.09, 0.03, 0.02]);
addBilateral("esqueletico", "pe", "Metatarso e Falanges do Pé", "Foot Metatarsals/Phalanges", [...AX, "Membro Inferior", "Pé"], { type: "box", w: 0.08, h: 0.04, d: 0.18 }, [0.09, 0.02, 0.1]);
// ---------------------------------------------------------------------------
// SISTEMA MUSCULAR
// ---------------------------------------------------------------------------
const MX = ["Sistema Muscular"];
add("muscular", "musculo_frontal", "Músculo Frontal", "Frontalis Muscle", [...MX, "Cabeça"], { type: "box", w: 0.1, h: 0.04, d: 0.01 }, [0, 1.78, 0.08]);
add("muscular", "musculo_temporal", "Músculo Temporal", "Temporalis Muscle", [...MX, "Cabeça"], { type: "box", w: 0.03, h: 0.06, d: 0.06 }, [0.1, 1.74, 0]);
add("muscular", "musculo_masseter", "Músculo Masseter", "Masseter Muscle", [...MX, "Cabeça"], { type: "box", w: 0.02, h: 0.04, d: 0.04 }, [0.09, 1.63, 0.02]);
addBilateral("muscular", "esternocleidomastoideo", "Esternocleidomastóideo", "Sternocleidomastoid", [...MX, "Pescoço"], { type: "cylinder", rt: 0.015, rb: 0.02, h: 0.12 }, [0.04, 1.53, 0.03], { rotation: [0.3, 0, 0.15] });
add("muscular", "trapezio", "Trapézio", "Trapezius", [...MX, "Dorso"], { type: "box", w: 0.32, h: 0.28, d: 0.03 }, [0, 1.42, -0.08]);
addBilateral("muscular", "deltoide", "Deltoide", "Deltoid", [...MX, "Ombro"], { type: "sphere", r: 0.06 }, [0.22, 1.34, 0]);
add("muscular", "peitoral_maior", "Peitoral Maior", "Pectoralis Major", [...MX, "Tórax"], { type: "box", w: 0.26, h: 0.18, d: 0.04 }, [0, 1.28, 0.1]);
addBilateral("muscular", "biceps_braquial", "Bíceps Braquial", "Biceps Brachii", [...MX, "Membro Superior", "Braço"], { type: "cylinder", rt: 0.03, rb: 0.025, h: 0.24 }, [0.23, 1.15, 0.02]);
addBilateral("muscular", "triceps_braquial", "Tríceps Braquial", "Triceps Brachii", [...MX, "Membro Superior", "Braço"], { type: "cylinder", rt: 0.028, rb: 0.024, h: 0.24 }, [0.23, 1.15, -0.02]);
add("muscular", "reto_abdominal", "Reto Abdominal", "Rectus Abdominis", [...MX, "Abdome"], { type: "box", w: 0.14, h: 0.22, d: 0.03 }, [0, 1.02, 0.1]);
add("muscular", "obliquo_externo", "Oblíquo Externo do Abdome", "External Oblique", [...MX, "Abdome"], { type: "box", w: 0.28, h: 0.2, d: 0.03 }, [0, 1.02, 0.06]);
add("muscular", "latissimo_do_dorso", "Latíssimo do Dorso", "Latissimus Dorsi", [...MX, "Dorso"], { type: "box", w: 0.3, h: 0.24, d: 0.03 }, [0, 1.15, -0.09]);
add("muscular", "gluteo_maximo", "Glúteo Máximo", "Gluteus Maximus", [...MX, "Pelve"], { type: "box", w: 0.28, h: 0.16, d: 0.08 }, [0, 0.75, -0.08]);
addBilateral("muscular", "quadriceps_femoral", "Quadríceps Femoral", "Quadriceps Femoris", [...MX, "Membro Inferior", "Coxa"], { type: "cylinder", rt: 0.055, rb: 0.04, h: 0.38 }, [0.09, 0.5, 0.03]);
addBilateral("muscular", "isquiotibiais", "Isquiotibiais", "Hamstrings", [...MX, "Membro Inferior", "Coxa"], { type: "cylinder", rt: 0.05, rb: 0.035, h: 0.38 }, [0.09, 0.5, -0.04]);
addBilateral("muscular", "gastrocnemio", "Gastrocnêmio", "Gastrocnemius", [...MX, "Membro Inferior", "Perna"], { type: "cylinder", rt: 0.04, rb: 0.02, h: 0.22 }, [0.09, 0.18, -0.02]);
addBilateral("muscular", "tibial_anterior", "Tibial Anterior", "Tibialis Anterior", [...MX, "Membro Inferior", "Perna"], { type: "cylinder", rt: 0.02, rb: 0.012, h: 0.22 }, [0.1, 0.18, 0.02]);
// ---------------------------------------------------------------------------
// SISTEMA NERVOSO
// ---------------------------------------------------------------------------
const NX = ["Sistema Nervoso"];
add("nervoso", "encefalo", "Encéfalo (Cérebro)", "Brain (Cerebrum)", [...NX, "Sistema Nervoso Central", "Cabeça"], { type: "sphere", r: 0.09 }, [0, 1.74, -0.01], { description: "Inclui os hemisférios cerebrais e o córtex." });
add("nervoso", "cerebelo", "Cerebelo", "Cerebellum", [...NX, "Sistema Nervoso Central", "Cabeça"], { type: "sphere", r: 0.04 }, [0, 1.68, -0.06]);
add("nervoso", "tronco_encefalico", "Tronco Encefálico", "Brainstem", [...NX, "Sistema Nervoso Central", "Cabeça"], { type: "cylinder", rt: 0.02, rb: 0.02, h: 0.06 }, [0, 1.62, -0.03]);
add("nervoso", "medula_espinhal", "Medula Espinhal", "Spinal Cord", [...NX, "Sistema Nervoso Central", "Tronco"], { type: "cylinder", rt: 0.009, rb: 0.009, h: 0.6 }, [0, 1.2, -0.035]);
addBilateral("nervoso", "plexo_braquial", "Plexo Braquial", "Brachial Plexus", [...NX, "Sistema Nervoso Periférico", "Ombro"], { type: "sphere", r: 0.02 }, [0.16, 1.38, 0]);
addBilateral("nervoso", "nervo_frenico", "Nervo Frênico", "Phrenic Nerve", [...NX, "Sistema Nervoso Periférico", "Pescoço"], { type: "cylinder", rt: 0.004, rb: 0.004, h: 0.25 }, [0.05, 1.35, 0.01]);
addBilateral("nervoso", "nervo_mediano", "Nervo Mediano", "Median Nerve", [...NX, "Sistema Nervoso Periférico", "Membro Superior"], { type: "cylinder", rt: 0.004, rb: 0.004, h: 0.4 }, [0.24, 0.95, 0.01]);
addBilateral("nervoso", "nervo_radial", "Nervo Radial", "Radial Nerve", [...NX, "Sistema Nervoso Periférico", "Membro Superior"], { type: "cylinder", rt: 0.004, rb: 0.004, h: 0.4 }, [0.25, 0.95, -0.01]);
addBilateral("nervoso", "nervo_ulnar", "Nervo Ulnar", "Ulnar Nerve", [...NX, "Sistema Nervoso Periférico", "Membro Superior"], { type: "cylinder", rt: 0.004, rb: 0.004, h: 0.4 }, [0.23, 0.95, -0.02]);
addBilateral("nervoso", "nervo_isquiatico", "Nervo Isquiático (Ciático)", "Sciatic Nerve", [...NX, "Sistema Nervoso Periférico", "Membro Inferior"], { type: "cylinder", rt: 0.007, rb: 0.006, h: 0.4 }, [0.09, 0.45, -0.03], { synonyms: ["nervo ciático"] });
addBilateral("nervoso", "nervo_femoral", "Nervo Femoral", "Femoral Nerve", [...NX, "Sistema Nervoso Periférico", "Membro Inferior"], { type: "cylinder", rt: 0.005, rb: 0.005, h: 0.3 }, [0.11, 0.55, 0.02]);
// ---------------------------------------------------------------------------
// SISTEMA CARDIOVASCULAR
// ---------------------------------------------------------------------------
const CX = ["Sistema Cardiovascular"];
add("cardiovascular", "coracao", "Coração", "Heart", [...CX, "Tórax"], { type: "sphere", r: 0.07 }, [0.03, 1.27, 0.04]);
add("cardiovascular", "aorta", "Aorta", "Aorta", [...CX, "Tórax", "Abdome"], { type: "cylinder", rt: 0.014, rb: 0.01, h: 0.45 }, [0, 1.1, -0.02]);
add("cardiovascular", "veia_cava_superior", "Veia Cava Superior", "Superior Vena Cava", [...CX, "Tórax"], { type: "cylinder", rt: 0.012, rb: 0.012, h: 0.1 }, [0.03, 1.36, 0]);
add("cardiovascular", "veia_cava_inferior", "Veia Cava Inferior", "Inferior Vena Cava", [...CX, "Tórax", "Abdome"], { type: "cylinder", rt: 0.013, rb: 0.013, h: 0.35 }, [0.03, 1.1, -0.03]);
addBilateral("cardiovascular", "arteria_carotida", "Artéria Carótida", "Carotid Artery", [...CX, "Pescoço"], { type: "cylinder", rt: 0.006, rb: 0.006, h: 0.14 }, [0.03, 1.53, 0.01]);
addBilateral("cardiovascular", "arteria_subclavia", "Artéria Subclávia", "Subclavian Artery", [...CX, "Ombro"], { type: "cylinder", rt: 0.006, rb: 0.006, h: 0.12 }, [0.14, 1.4, 0]);
addBilateral("cardiovascular", "veia_jugular", "Veia Jugular", "Jugular Vein", [...CX, "Pescoço"], { type: "cylinder", rt: 0.006, rb: 0.006, h: 0.14 }, [0.045, 1.53, -0.01]);
addBilateral("cardiovascular", "arteria_braquial", "Artéria Braquial", "Brachial Artery", [...CX, "Membro Superior", "Braço"], { type: "cylinder", rt: 0.005, rb: 0.004, h: 0.24 }, [0.23, 1.15, 0.01]);
addBilateral("cardiovascular", "arteria_femoral", "Artéria Femoral", "Femoral Artery", [...CX, "Membro Inferior", "Coxa"], { type: "cylinder", rt: 0.006, rb: 0.005, h: 0.4 }, [0.09, 0.5, 0.05]);
// ---------------------------------------------------------------------------
// SISTEMA RESPIRATÓRIO
// ---------------------------------------------------------------------------
const RX = ["Sistema Respiratório"];
add("respiratorio", "laringe", "Laringe", "Larynx", [...RX, "Pescoço"], { type: "cylinder", rt: 0.02, rb: 0.02, h: 0.05 }, [0, 1.48, 0.02]);
add("respiratorio", "traqueia", "Traqueia", "Trachea", [...RX, "Pescoço", "Tórax"], { type: "cylinder", rt: 0.012, rb: 0.012, h: 0.13 }, [0, 1.4, 0.02]);
addBilateral("respiratorio", "bronquio_principal", "Brônquio Principal", "Main Bronchus", [...RX, "Tórax"], { type: "cylinder", rt: 0.008, rb: 0.006, h: 0.06 }, [0.03, 1.32, 0], { rotation: [0, 0, -0.5] });
addBilateral("respiratorio", "pulmao", "Pulmão", "Lung", [...RX, "Tórax"], { type: "box", w: 0.14, h: 0.28, d: 0.16 }, [0.13, 1.22, -0.02]);
add("respiratorio", "diafragma", "Diafragma", "Diaphragm", [...RX, "Tórax", "Abdome"], { type: "box", w: 0.32, h: 0.02, d: 0.2 }, [0, 1.08, 0]);
// ---------------------------------------------------------------------------
// SISTEMA TEGUMENTAR
// ---------------------------------------------------------------------------
const TX = ["Sistema Tegumentar"];
add("tegumentar", "epiderme", "Epiderme", "Epidermis", [...TX, "Membro Superior", "Antebraço"], { type: "box", w: 0.09, h: 0.2, d: 0.005 }, [0.24, 0.85, 0.035], { description: "Camada mais externa da pele, avascular." });
add("tegumentar", "derme", "Derme", "Dermis", [...TX, "Membro Superior", "Antebraço"], { type: "box", w: 0.09, h: 0.2, d: 0.01 }, [0.24, 0.85, 0.028], { description: "Camada intermediária, rica em vasos, nervos e anexos cutâneos." });
add("tegumentar", "hipoderme", "Hipoderme (Tecido Subcutâneo)", "Hypodermis (Subcutaneous Tissue)", [...TX, "Membro Superior", "Antebraço"], { type: "box", w: 0.09, h: 0.2, d: 0.015 }, [0.24, 0.85, 0.018], { description: "Camada mais profunda, rica em tecido adiposo." });
writeFileSync(
path.resolve(__dirname, "manifest.json"),
JSON.stringify({ systems, structures }, null, 2),
path.resolve(__dirname, "manifest-tegumentar.json"),
JSON.stringify({ structures }, null, 2),
);
console.log(`manifest.json gerado com ${structures.length} estruturas em ${systems.length} sistemas.`);
console.log(`manifest-tegumentar.json gerado com ${structures.length} estruturas.`);
+14
View File
@@ -0,0 +1,14 @@
// Fonte única de verdade para os sistemas anatômicos — usada tanto pela
// extração dos modelos reais (Z-Anatomy) quanto pelo gerador esquemático
// do sistema tegumentar (sem fonte de malha real disponível).
export const SYSTEMS = [
{ slug: "esqueletico", namePt: "Sistema Esquelético", nameEn: "Skeletal System", colorHex: "#e8e2d0", order: 1 },
{ slug: "muscular", namePt: "Sistema Muscular", nameEn: "Muscular System", colorHex: "#b5433a", order: 2 },
{ slug: "nervoso", namePt: "Sistema Nervoso", nameEn: "Nervous System", colorHex: "#f2c14e", order: 3 },
{ slug: "cardiovascular", namePt: "Sistema Cardiovascular", nameEn: "Cardiovascular System", colorHex: "#c1121f", order: 4 },
{ slug: "respiratorio", namePt: "Sistema Respiratório", nameEn: "Respiratory System", colorHex: "#4f7cff", order: 5 },
{ slug: "visceral", namePt: "Órgãos Viscerais (Digestório, Urinário, Endócrino, Reprodutor)", nameEn: "Visceral Organs", colorHex: "#8b5cf6", order: 6 },
{ slug: "articular", namePt: "Articulações", nameEn: "Joints", colorHex: "#38bdf8", order: 7 },
{ slug: "linfatico", namePt: "Sistema Linfático", nameEn: "Lymphatic System", colorHex: "#34d399", order: 8 },
{ slug: "tegumentar", namePt: "Sistema Tegumentar", nameEn: "Integumentary System", colorHex: "#e0a96d", order: 9 },
];
+5 -2
View File
@@ -36,8 +36,11 @@ server {
server_name atlas.martinsevieira.adv.br;
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
# Sem "reuseport" aqui: já foi declarado uma única vez pelo primeiro
# vhost do host (12-sites.conf) para 0.0.0.0:443/[::]:443 — repetir
# causa "duplicate listen options" no nginx -t.
listen 443 quic;
listen [::]:443 quic;
http2 on;
http3 on;
http3_hq on;
+8
View File
@@ -19,6 +19,14 @@ export function Layout() {
Quiz
</NavLink>
</nav>
<a
href="https://github.com/LluisV/Z-Anatomy-Sample"
target="_blank"
rel="noreferrer"
className="ml-auto text-[11px] text-slate-500 hover:text-slate-300"
>
Modelos 3D: projeto Z-Anatomy (CC BY-SA 4.0)
</a>
</header>
<main className="flex-1 min-h-0">
<Outlet />
+21 -2
View File
@@ -75,7 +75,25 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
<details key={system} open={false} className="text-sm">
<summary className="cursor-pointer text-slate-300 hover:text-white">{system}</summary>
<div className="pl-3 mt-1 space-y-2">
{[...regions.entries()].map(([region, items]) => (
{[...regions.entries()].map(([region, items]) =>
// Estruturas reais (Z-Anatomy) não têm sub-região própria
// — regionPath tem só o nome do sistema, então a região
// aqui seria idêntica ao título acima. Nesse caso lista
// direto, sem um nível de agrupamento redundante.
region === system ? (
<ul key={region} className="space-y-0.5">
{items.map((s) => (
<li key={s.id}>
<button
onClick={() => goTo(s)}
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
>
{s.namePt}
</button>
</li>
))}
</ul>
) : (
<details key={region}>
<summary className="cursor-pointer text-slate-400 hover:text-white text-xs uppercase tracking-wide">
{region}
@@ -93,7 +111,8 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
))}
</ul>
</details>
))}
),
)}
</div>
</details>
))}
+22 -2
View File
@@ -63,9 +63,27 @@ export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
? [...bySlug.values()].find((s) => s.id === selectedStructureId)?.slug ?? null
: null;
// Um arquivo .glb pode conter estruturas de mais de um sistema (ex: o
// "visceral-organs.glb" tem estruturas de "respiratorio" e "visceral"),
// então carregamos qualquer arquivo que tenha ao menos uma estrutura de
// um sistema visível — a visibilidade por malha é resolvida dentro de
// cada SystemModel.
const structuresByFile = useMemo(() => {
const structuresList = [...bySlug.values()];
const map = new Map<string, Structure[]>();
for (const s of structuresList) {
if (!map.has(s.modelFile)) map.set(s.modelFile, []);
map.get(s.modelFile)!.push(s);
}
return map;
}, [bySlug]);
const modelFiles = useMemo(
() => [...visibleSystems].map((slug) => `${slug}.glb`),
[visibleSystems],
() =>
[...structuresByFile.entries()]
.filter(([, items]) => items.some((s) => visibleSystems.has(s.system.slug)))
.map(([file]) => file),
[structuresByFile, visibleSystems],
);
return (
@@ -83,6 +101,8 @@ export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
<SystemModel
key={modelFile}
modelFile={modelFile}
structuresInFile={structuresByFile.get(modelFile) ?? []}
visibleSystems={visibleSystems}
selectedSlug={selectedSlug}
clippingPlanes={clippingPlanes}
onSelect={(slug) => {
+44 -7
View File
@@ -1,46 +1,83 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import type { ThreeEvent } from "@react-three/fiber";
import { modelUrl } from "../../lib/api.js";
import { modelUrl, type Structure } from "../../lib/api.js";
interface Props {
modelFile: string;
// Estruturas do banco cujo modelFile é este arquivo — pode conter mais de
// um sistema (ex: o glb "visceral-organs" tem estruturas de "respiratorio"
// e de "visceral" ao mesmo tempo).
structuresInFile: Structure[];
visibleSystems: Set<string>;
selectedSlug: string | null;
onSelect: (slug: string) => void;
clippingPlanes: THREE.Plane[];
}
const HIGHLIGHT = new THREE.Color("#22d3ee");
const BLACK = new THREE.Color(0x000000);
export function SystemModel({ modelFile, selectedSlug, onSelect, clippingPlanes }: Props) {
export function SystemModel({
modelFile,
structuresInFile,
visibleSystems,
selectedSlug,
onSelect,
clippingPlanes,
}: Props) {
const { scene } = useGLTF(modelUrl(modelFile));
const clonedRef = useRef<THREE.Group>();
// Nome original do nó (como veio do .glb), capturado uma única vez no
// clone — necessário porque o efeito abaixo reescreve mesh.name para o
// slug da estrutura, e precisa continuar encontrando a estrutura certa
// em execuções seguintes (toggle de camadas, seleção, corte etc.).
const originalNames = useRef<WeakMap<THREE.Object3D, string>>(new WeakMap());
if (!clonedRef.current) {
const clone = scene.clone(true);
clone.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (mesh.isMesh && mesh.material) {
if (mesh.isMesh) {
originalNames.current.set(mesh, mesh.name);
if (mesh.material) {
mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone();
}
}
});
clonedRef.current = clone;
}
const root = clonedRef.current!;
const byMeshName = useMemo(
() => new Map(structuresInFile.map((s) => [s.meshName, s])),
[structuresInFile],
);
useEffect(() => {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
const originalName = originalNames.current.get(mesh) ?? mesh.name;
const structure = byMeshName.get(originalName);
if (!structure) {
// Malha sem correspondência no manifesto (não catalogada) — oculta.
mesh.visible = false;
return;
}
mesh.name = structure.slug;
mesh.visible = visibleSystems.has(structure.system.slug);
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.clippingPlanes = clippingPlanes;
mat.clipShadows = true;
const isSelected = mesh.name === selectedSlug;
mat.emissive = isSelected ? HIGHLIGHT : new THREE.Color(0x000000);
const isSelected = structure.slug === selectedSlug;
mat.emissive = isSelected ? HIGHLIGHT : BLACK;
mat.emissiveIntensity = isSelected ? 0.6 : 0;
});
}, [root, selectedSlug, clippingPlanes]);
}, [root, byMeshName, visibleSystems, selectedSlug, clippingPlanes]);
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();