diff --git a/README.md b/README.md index 129a973..8e78834 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/data-pipeline/Dockerfile b/data-pipeline/Dockerfile index 06020e9..b3b2175 100644 --- a/data-pipeline/Dockerfile +++ b/data-pipeline/Dockerfile @@ -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"] diff --git a/data-pipeline/package.json b/data-pipeline/package.json index d2358b5..682c90e 100644 --- a/data-pipeline/package.json +++ b/data-pipeline/package.json @@ -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" } diff --git a/data-pipeline/scripts/build-seed-models.mjs b/data-pipeline/scripts/build-seed-models.mjs index 793aa55..a4fbd20 100644 --- a/data-pipeline/scripts/build-seed-models.mjs +++ b/data-pipeline/scripts/build-seed-models.mjs @@ -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])); diff --git a/data-pipeline/scripts/convert-fbx.mjs b/data-pipeline/scripts/convert-fbx.mjs new file mode 100644 index 0000000..8420eb1 --- /dev/null +++ b/data-pipeline/scripts/convert-fbx.mjs @@ -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."); diff --git a/data-pipeline/scripts/extract-manifest.mjs b/data-pipeline/scripts/extract-manifest.mjs new file mode 100644 index 0000000..d6a411e --- /dev/null +++ b/data-pipeline/scripts/extract-manifest.mjs @@ -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/.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.`); diff --git a/data-pipeline/scripts/fetch-real-models.mjs b/data-pipeline/scripts/fetch-real-models.mjs new file mode 100644 index 0000000..dc2def8 --- /dev/null +++ b/data-pipeline/scripts/fetch-real-models.mjs @@ -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."); diff --git a/data-pipeline/scripts/merge-manifests.mjs b/data-pipeline/scripts/merge-manifests.mjs new file mode 100644 index 0000000..1ecaf17 --- /dev/null +++ b/data-pipeline/scripts/merge-manifests.mjs @@ -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).`); diff --git a/data-pipeline/scripts/seed-db.mjs b/data-pipeline/scripts/seed-db.mjs index 768b276..bcc696c 100644 --- a/data-pipeline/scripts/seed-db.mjs +++ b/data-pipeline/scripts/seed-db.mjs @@ -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, ], ); diff --git a/data-pipeline/scripts/sources.mjs b/data-pipeline/scripts/sources.mjs new file mode 100644 index 0000000..7f7ad79 --- /dev/null +++ b/data-pipeline/scripts/sources.mjs @@ -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" }, +}; diff --git a/data-pipeline/scripts/translate-pt.mjs b/data-pipeline/scripts/translate-pt.mjs new file mode 100644 index 0000000..0b69218 --- /dev/null +++ b/data-pipeline/scripts/translate-pt.mjs @@ -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; +} diff --git a/data-pipeline/seed/build-manifest.mjs b/data-pipeline/seed/build-manifest.mjs index 45f9085..00fcec5 100644 --- a/data-pipeline/seed/build-manifest.mjs +++ b/data-pipeline/seed/build-manifest.mjs @@ -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 ` (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.`); diff --git a/data-pipeline/seed/systems.mjs b/data-pipeline/seed/systems.mjs new file mode 100644 index 0000000..0a18ef2 --- /dev/null +++ b/data-pipeline/seed/systems.mjs @@ -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 }, +]; diff --git a/deploy/nginx-vhost-atlas.conf b/deploy/nginx-vhost-atlas.conf index 3ed10e3..733c69c 100644 --- a/deploy/nginx-vhost-atlas.conf +++ b/deploy/nginx-vhost-atlas.conf @@ -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; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 52c442d..590ce21 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -19,6 +19,14 @@ export function Layout() { Quiz + + Modelos 3D: projeto Z-Anatomy (CC BY-SA 4.0) +
diff --git a/frontend/src/components/SearchNav.tsx b/frontend/src/components/SearchNav.tsx index 16561c8..6bc5722 100644 --- a/frontend/src/components/SearchNav.tsx +++ b/frontend/src/components/SearchNav.tsx @@ -75,12 +75,13 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
{system}
- {[...regions.entries()].map(([region, items]) => ( -
- - {region} - -
    + {[...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 ? ( +
      {items.map((s) => (
    -
- ))} + ) : ( +
+ + {region} + +
    + {items.map((s) => ( +
  • + +
  • + ))} +
+
+ ), + )}
))} diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx index c901de8..72753c3 100644 --- a/frontend/src/components/viewer/Scene.tsx +++ b/frontend/src/components/viewer/Scene.tsx @@ -63,9 +63,27 @@ export function Scene({ bySlug }: { bySlug: Map }) { ? [...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(); + 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 }) { { diff --git a/frontend/src/components/viewer/SystemModel.tsx b/frontend/src/components/viewer/SystemModel.tsx index dd4f8ad..58a922e 100644 --- a/frontend/src/components/viewer/SystemModel.tsx +++ b/frontend/src/components/viewer/SystemModel.tsx @@ -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; 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(); + // 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>(new WeakMap()); if (!clonedRef.current) { const clone = scene.clone(true); clone.traverse((obj) => { const mesh = obj as THREE.Mesh; - if (mesh.isMesh && mesh.material) { - mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone(); + 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) => { e.stopPropagation();