diff --git a/README.md b/README.md index 8e78834..fe076a3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ O pipeline (`data-pipeline/`) baixa os `.fbx` reais, converte para `.glb` via `a **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`. +**O que não veio do Z-Anatomy-Sample**: a amostra pública não inclui camadas de pele (confirmado consultando a listagem completa do repositório). Duas tentativas de bloco 3D esquemático (geometria procedural) pro sistema **tegumentar** não ficaram boas o bastante pra estudo, então ele agora é um diagrama 2D clássico com pontos clicáveis (imagem de domínio público, NCI/SEER Training Modules) — ver `data-pipeline/seed/build-manifest.mjs` (dados das estruturas), `frontend/src/data/skinHotspots.ts` (posição dos marcadores sobre a imagem) e `frontend/src/components/SkinDiagram.tsx`. ## Arquitetura @@ -87,15 +87,17 @@ Depois `docker compose up -d --build anatomia-db anatomia-api anatomia-web && do ## Expandindo o dataset -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. +1. **Mais arquivos do Z-Anatomy-Sample**: 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`. +3. **Sistema tegumentar com malha 3D real**: se aparecer uma fonte aberta (sem exigir conta/token pra baixar) com camadas de pele, dá pra trocar o diagrama 2D atual (`SkinDiagram.tsx`) por um modelo real seguindo o 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. ## 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. +Diagrama do sistema tegumentar (corte de pele): **National Cancer Institute / SEER Training Modules** (training.seer.cancer.gov) — obra do governo dos EUA, domínio público. + ## Desenvolvimento local (sem Docker) ```bash diff --git a/data-pipeline/package.json b/data-pipeline/package.json index 42618a7..709f0c7 100644 --- a/data-pipeline/package.json +++ b/data-pipeline/package.json @@ -7,7 +7,7 @@ "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", + "build:tegumentar": "node seed/build-manifest.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" diff --git a/data-pipeline/scripts/build-seed-models.mjs b/data-pipeline/scripts/build-seed-models.mjs deleted file mode 100644 index 816293a..0000000 --- a/data-pipeline/scripts/build-seed-models.mjs +++ /dev/null @@ -1,118 +0,0 @@ -import { Document, NodeIO } from "@gltf-transform/core"; -import { readFileSync, mkdirSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { buildGeometry } from "./geometry.mjs"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const manifestPath = path.resolve( - __dirname, - process.argv[2] ?? "../seed/manifest-tegumentar.json", -); -const outDir = path.resolve(__dirname, "../seed/models"); -mkdirSync(outDir, { recursive: true }); - -const { structures } = JSON.parse(readFileSync(manifestPath, "utf-8")); - -function eulerToQuat([x, y, z]) { - const cx = Math.cos(x / 2), sx = Math.sin(x / 2); - const cy = Math.cos(y / 2), sy = Math.sin(y / 2); - const cz = Math.cos(z / 2), sz = Math.sin(z / 2); - return [ - sx * cy * cz - cx * sy * sz, - cx * sy * cz + sx * cy * sz, - cx * cy * sz - sx * sy * cz, - cx * cy * cz + sx * sy * sz, - ]; -} - -function hexToRgb(hex) { - const n = parseInt(hex.replace("#", ""), 16); - return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1]; -} - -const bySystem = new Map(); -for (const s of structures) { - if (!bySystem.has(s.system)) bySystem.set(s.system, []); - bySystem.get(s.system).push(s); -} - -const systemColors = { - esqueletico: "#e8e2d0", - muscular: "#b5433a", - nervoso: "#f2c14e", - cardiovascular: "#c1121f", - respiratorio: "#4f7cff", - tegumentar: "#e0a96d", -}; - -for (const [systemSlug, items] of bySystem) { - const doc = new Document(); - const buffer = doc.createBuffer(); - const scene = doc.createScene(systemSlug); - doc.getRoot().setDefaultScene(scene); - - // Cada item pode pedir uma cor própria (opts.color) pra se distinguir dos - // demais (ex: glândula amarela, vaso vermelho/azul, pelo castanho) em vez - // de todo o sistema sair de uma cor só — cache por hex evita recriar - // material repetido. - const materialCache = new Map(); - function materialFor(hex) { - if (materialCache.has(hex)) return materialCache.get(hex); - const mat = doc - .createMaterial(hex) - .setBaseColorFactor(hexToRgb(hex)) - .setRoughnessFactor(0.6) - .setMetallicFactor(0.05) - // O bloco de pele usa caixas com a face da frente removida (ver - // openFrontBox em geometry.mjs) pra dar pra ver o conteúdo por - // dentro — sem dupla face, as paredes de trás/lados ficariam - // invisíveis quando vistas "de dentro" da caixa aberta. - .setDoubleSided(true); - materialCache.set(hex, mat); - return mat; - } - const defaultHex = systemColors[systemSlug] ?? "#cccccc"; - - for (const item of items) { - const material = materialFor(item.color ?? defaultHex); - const geo = buildGeometry(item.shape); - const posAccessor = doc - .createAccessor() - .setType("VEC3") - .setArray(geo.positions) - .setBuffer(buffer); - const normAccessor = doc - .createAccessor() - .setType("VEC3") - .setArray(geo.normals) - .setBuffer(buffer); - const idxAccessor = doc - .createAccessor() - .setType("SCALAR") - .setArray(geo.indices) - .setBuffer(buffer); - - const prim = doc - .createPrimitive() - .setAttribute("POSITION", posAccessor) - .setAttribute("NORMAL", normAccessor) - .setIndices(idxAccessor) - .setMaterial(material); - - const mesh = doc.createMesh(item.meshName).addPrimitive(prim); - const node = doc - .createNode(item.meshName) - .setMesh(mesh) - .setTranslation(item.position) - .setRotation(eulerToQuat(item.rotation ?? [0, 0, 0])) - .setExtras({ slug: item.slug }); - - scene.addChild(node); - } - - const io = new NodeIO(); - const outFile = path.join(outDir, `${systemSlug}.glb`); - await io.write(outFile, doc); - console.log(`✓ ${outFile} (${items.length} estruturas)`); -} diff --git a/data-pipeline/scripts/geometry.mjs b/data-pipeline/scripts/geometry.mjs deleted file mode 100644 index dc63268..0000000 --- a/data-pipeline/scripts/geometry.mjs +++ /dev/null @@ -1,146 +0,0 @@ -// Minimal parametric mesh generators (box/sphere/cylinder), dependency-free. -// Returns { positions: Float32Array, normals: Float32Array, indices: Uint32Array }. - -// skipNormals: normais (como "0,-1,0") das faces a NÃO gerar — usado pelo -// bloco de pele em corte (openFrontBox abaixo) pra deixar a face da frente -// aberta, como uma caixa de diorama, revelando o conteúdo interno em vez de -// escondê-lo atrás de uma parede opaca. -export function box(w, h, d, skipNormals = []) { - const x = w / 2, y = h / 2, z = d / 2; - const positions = []; - const normals = []; - const indices = []; - - const faces = [ - // [normal, 4 corners CCW] - [[0, 0, 1], [[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]]], - [[0, 0, -1], [[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]]], - [[0, 1, 0], [[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]]], - [[0, -1, 0], [[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]]], - [[1, 0, 0], [[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]]], - [[-1, 0, 0], [[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]]], - ]; - - const skip = new Set(skipNormals.map((n) => n.join(","))); - - for (const [n, corners] of faces) { - if (skip.has(n.join(","))) continue; - const base = positions.length / 3; - for (const c of corners) { - positions.push(...c); - normals.push(...n); - } - indices.push(base, base + 1, base + 2, base, base + 2, base + 3); - } - - return { - positions: new Float32Array(positions), - normals: new Float32Array(normals), - indices: new Uint32Array(indices), - }; -} - -// Caixa "de diorama": todas as faces normais, exceto a de baixo em Y -// (nosso eixo "frente/trás" no bloco de pele) — deixa a frente aberta pra -// dar pra ver o conteúdo (folículos, glândulas, vasos) por dentro, em vez -// de uma parede opaca escondendo tudo. -export function openFrontBox(w, h, d) { - return box(w, h, d, [[0, -1, 0]]); -} - -export function sphere(radius, widthSegments = 16, heightSegments = 12) { - const positions = []; - const normals = []; - const indices = []; - - for (let y = 0; y <= heightSegments; y++) { - const v = y / heightSegments; - const theta = v * Math.PI; - for (let x = 0; x <= widthSegments; x++) { - const u = x / widthSegments; - const phi = u * Math.PI * 2; - const nx = -Math.cos(phi) * Math.sin(theta); - const ny = Math.cos(theta); - const nz = Math.sin(phi) * Math.sin(theta); - positions.push(radius * nx, radius * ny, radius * nz); - normals.push(nx, ny, nz); - } - } - - const rowSize = widthSegments + 1; - for (let y = 0; y < heightSegments; y++) { - for (let x = 0; x < widthSegments; x++) { - const a = y * rowSize + x; - const b = a + rowSize; - indices.push(a, b, a + 1, b, b + 1, a + 1); - } - } - - return { - positions: new Float32Array(positions), - normals: new Float32Array(normals), - indices: new Uint32Array(indices), - }; -} - -export function cylinder(radiusTop, radiusBottom, height, radialSegments = 12) { - const positions = []; - const normals = []; - const indices = []; - const halfH = height / 2; - - // Side wall. - for (let y = 0; y <= 1; y++) { - const r = y === 0 ? radiusTop : radiusBottom; - const py = y === 0 ? halfH : -halfH; - for (let x = 0; x <= radialSegments; x++) { - const u = (x / radialSegments) * Math.PI * 2; - const nx = Math.sin(u); - const nz = Math.cos(u); - positions.push(r * nx, py, r * nz); - const slope = (radiusBottom - radiusTop) / height; - const len = Math.sqrt(1 + slope * slope); - normals.push(nx / len, -slope / len, nz / len); - } - } - const rowSize = radialSegments + 1; - for (let x = 0; x < radialSegments; x++) { - const a = x, b = a + rowSize; - indices.push(a, b, a + 1, b, b + 1, a + 1); - } - - // Caps. - function cap(y, r, py, flip) { - const base = positions.length / 3; - const ny = flip ? 1 : -1; - positions.push(0, py, 0); - normals.push(0, ny, 0); - for (let x = 0; x <= radialSegments; x++) { - const u = (x / radialSegments) * Math.PI * 2; - positions.push(r * Math.sin(u), py, r * Math.cos(u)); - normals.push(0, ny, 0); - } - for (let x = 0; x < radialSegments; x++) { - if (flip) indices.push(base, base + x + 2, base + x + 1); - else indices.push(base, base + x + 1, base + x + 2); - } - } - if (radiusTop > 0) cap(0, radiusTop, halfH, true); - if (radiusBottom > 0) cap(1, radiusBottom, -halfH, false); - - return { - positions: new Float32Array(positions), - normals: new Float32Array(normals), - indices: new Uint32Array(indices), - }; -} - -export function buildGeometry(shape) { - switch (shape.type) { - case "box": return box(shape.w, shape.h, shape.d); - case "openFrontBox": return openFrontBox(shape.w, shape.h, shape.d); - case "sphere": return sphere(shape.r); - case "cylinder": return cylinder(shape.rt, shape.rb, shape.h); - default: throw new Error(`Unknown shape type: ${shape.type}`); - } -} diff --git a/data-pipeline/seed/build-manifest.mjs b/data-pipeline/seed/build-manifest.mjs index 26447e3..2b8c887 100644 --- a/data-pipeline/seed/build-manifest.mjs +++ b/data-pipeline/seed/build-manifest.mjs @@ -1,24 +1,28 @@ -// Gera manifest-tegumentar.json: o Z-Anatomy-Sample não inclui camadas de -// pele (epiderme/derme/hipoderme) nem seus anexos (folículo piloso, glândulas -// etc.) — confirmado consultando a listagem completa de -// Assets/Models/1.0 Models no repositório (só Skeletal, Muscular, Nervous, -// CardioVascular, Visceral, Joints, LymphoidOrgans, Regions e Reference -// lines) — são estruturas microscópicas, nenhum dataset de corpo inteiro as -// modela como malha. Por isso o sistema tegumentar (só ele — os demais -// sistemas continuam vindo de malhas reais via scripts/extract-manifest.mjs, -// sem nenhuma mudança) usa um "bloco" esquemático construído com geometria -// paramétrica simples (ver scripts/geometry.mjs e scripts/build-seed-models.mjs, -// também exclusivos do tegumentar — só o script "build:tegumentar" os chama). +// Gera manifest-tegumentar.json. // -// MODELO "DIORAMA": uma caixa sólida (as 3 camadas empilhadas, cada uma uma -// openFrontBox — ver geometry.mjs) com a face da frente removida, como uma -// caixa de vidro sem uma parede, no estilo do desenho clássico de corte de -// pele (Netter/Sobotta): de cima dá pra ver a superfície da epiderme com os -// pelos saindo; do lado dá pra ver a "banda" de cor de cada camada; e pela -// frente aberta dá pra ver os componentes (folículos, glândulas, vasos, -// corpúsculos) soltos dentro do volume da derme/hipoderme, sem nada opaco -// na frente escondendo-os. Gire o modelo com o mouse pra explorar de vários -// ângulos. +// HISTÓRICO: tentamos duas vezes um bloco 3D esquemático (geometria +// procedural — caixas/esferas/cilindros) pro sistema tegumentar, já que o +// Z-Anatomy-Sample não tem nenhum dado de pele (confirmado consultando a +// listagem completa de Assets/Models/1.0 Models: só Skeletal, Muscular, +// Nervous, CardioVascular, Visceral, Joints, LymphoidOrgans, Regions e +// Reference lines). Nenhuma das duas ficou boa o suficiente pra estudo. +// +// DECISÃO ATUAL: em vez de insistir num modelo 3D (que exigiria ou uma +// malha real licenciada — ex: Sketchfab, mas isso pede conta/token de API, +// uma dependência externa nova — ou continuar no procedural, já reprovado), +// o tegumentar agora é um DIAGRAMA 2D com pontos clicáveis (hotspots), como +// a ilustração clássica de corte de pele. A imagem usada é de domínio +// público (obra do governo dos EUA — National Cancer Institute / SEER +// Training Modules, https://training.seer.cancer.gov/melanoma/anatomy/), +// salva em frontend/public/tegumentar/skin-cross-section.jpg. Sem +// restrição de uso — não precisa nem de atribuição, mas mantemos por boa +// prática (ver frontend/src/components/SkinDiagram.tsx). +// +// As posições dos hotspots (x/y em % da imagem) ficam em +// frontend/src/data/skinHotspots.ts, não aqui — são um detalhe de +// apresentação (onde desenhar o marcador em cima da figura), enquanto este +// arquivo só gera os DADOS da estrutura (nome, descrição) que entram no +// banco, do mesmo jeito que qualquer outra estrutura do atlas. import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -27,288 +31,121 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const structures = []; -// Todo o desenho abaixo é escrito em "unidades de projeto" (a escala -// original, pensada peça por peça) e só na hora de gerar a estrutura final -// o add() aplica o fator SCALE — assim dá pra aumentar/diminuir o bloco -// inteiro mudando um único número, sem recalcular à mão a posição de cada -// uma das peças. -const SCALE = 3; - -function scaleShape(shape) { - const scaled = { ...shape }; - for (const key of ["w", "h", "d", "r", "rt", "rb"]) { - if (scaled[key] != null) scaled[key] *= SCALE; - } - return scaled; -} - -// Escala a posição em torno da âncora do bloco (CX, CY, Z_TOP) — assim o -// bloco cresce a partir do ponto onde "gruda" no antebraço, em vez de só se -// afastar dele. -function scalePosition([x, y, z]) { - return [CX + (x - CX) * SCALE, CY + (y - CY) * SCALE, Z_TOP - (Z_TOP - z) * SCALE]; -} - -function add(system, slug, namePt, nameEn, region, shape, position, opts = {}) { +// modelFile/meshName são NOT NULL no schema mas não fazem sentido pro +// tegumentar (não há malha 3D) — o frontend nunca tenta carregar um glb +// pra esse sistema (ver o filtro em Scene.tsx), então usamos um valor- +// sentinela só pra satisfazer o schema. +function add(slug, namePt, nameEn, description, region = []) { structures.push({ - system, + system: "tegumentar", slug, namePt, nameEn, - synonyms: opts.synonyms ?? [], - description: opts.description ?? "", - regionPath: region, - modelFile: `${system}.glb`, + synonyms: [], + description, + regionPath: ["Sistema Tegumentar", "Corte Transversal da Pele", ...region], + modelFile: "n/a", meshName: slug, - shape: scaleShape(shape), - position: scalePosition(position), - rotation: opts.rotation ?? [0, 0, 0], - color: opts.color, }); } -const TX = ["Sistema Tegumentar", "Corte Transversal da Pele (Diorama)"]; - -// Centro/âncora do bloco (coordenadas do mundo, mesma escala do corpo -// Z-Anatomy — colado na face do antebraço, como antes). -const CX = 0.24; -const CY = 0.85; - -const BLOCK_W = 0.14; // extensão em X (largura do bloco) -const Y_DEPTH = 0.12; // profundidade em Y (frente-trás) — a mesma pras 3 camadas -const Y_FRONT = CY - Y_DEPTH / 2; // onde fica a abertura (face removida) - -// Espessura (eixo Z) de cada camada e a profundidade onde cada uma começa/ -// termina — definidas uma vez aqui pra tudo (camadas E componentes) usar os -// mesmos números e não desalinhar. Proporções exageradas (derme/hipoderme -// bem mais grossas que na realidade) de propósito, pra caber e destacar os -// componentes — como em qualquer diagrama didático de pele. -const Z_TOP = 0.038; // superfície da epiderme -const D_EPI = 0.012; -const D_DERME = 0.035; -const D_HIPO = 0.053; -const Z_EPI_BOTTOM = Z_TOP - D_EPI; // limite epiderme/derme -const Z_DERME_BOTTOM = Z_EPI_BOTTOM - D_DERME; // limite derme/hipoderme -const Z_HIPO_BOTTOM = Z_DERME_BOTTOM - D_HIPO; // base do bloco - -const ROT_Z = [Math.PI / 2, 0, 0]; // deixa cilindros na vertical (eixo nasce em Y, isso gira pra Z) - -// ---- As 3 camadas da pele, como uma caixa de diorama (frente aberta) ---- add( - "tegumentar", "epiderme", "Epiderme", "Epidermis", [...TX], - { type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_EPI }, - [CX, CY, Z_TOP - D_EPI / 2], - { - color: "#e0a96d", - description: - "Camada mais externa e avascular da pele, formada por epitélio pavimentoso estratificado queratinizado; sua função é a principal barreira contra perda de água, atrito, micro-organismos e raios UV (com a ajuda da melanina). É a face de cima do bloco, de onde saem os pelos.", - }, + "epiderme", "Epiderme", "Epidermis", + "Camada mais externa e avascular da pele, formada por epitélio pavimentoso estratificado queratinizado; principal barreira contra perda de água, atrito, micro-organismos e raios UV.", ); add( - "tegumentar", "derme", "Derme", "Dermis", [...TX], - { type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_DERME }, - [CX, CY, Z_EPI_BOTTOM - D_DERME / 2], - { - color: "#c97b4a", - description: - "Camada de tecido conjuntivo rica em colágeno e elastina, vasos sanguíneos, terminações nervosas e os anexos cutâneos (folículos pilosos, glândulas); sustenta a epiderme e dá à pele resistência e elasticidade. Visível como a faixa do meio no lado do bloco.", - }, + "derme", "Derme", "Dermis", + "Camada de tecido conjuntivo abaixo da epiderme, rica em colágeno e elastina, vasos sanguíneos, nervos e os anexos cutâneos (folículos pilosos, glândulas); dá à pele resistência e elasticidade.", ); add( - "tegumentar", "hipoderme", "Hipoderme (Tecido Subcutâneo)", "Hypodermis (Subcutaneous Tissue)", [...TX], - { type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_HIPO }, - [CX, CY, Z_DERME_BOTTOM - D_HIPO / 2], - { - color: "#f2d4a3", - description: - "Camada mais profunda, rica em tecido adiposo; isola termicamente, absorve impactos e serve de reserva de energia — não é tecnicamente parte da pele, mas a conecta aos músculos e ossos subjacentes. É a faixa mais grossa, na base do bloco.", - }, -); - -// ---- 6 "colunas" de componentes, cada uma numa posição X própria, dentro -// do volume aberto do bloco — a altura (Z) de cada peça já indica em qual -// camada ela vive; um leve desvio em Y dá sensação de profundidade. ---- -const COL = (i) => CX - 0.06 + i * 0.024; // 6 colunas igualmente espaçadas - -// ---- Coluna 1: complexo pilossebáceo (pelo + folículo + músculo eretor + glândula sebácea) ---- -const HX = COL(0); -const HY = CY - 0.02; -const HAIR_TILT = [Math.PI / 2 + 0.3, 0.2, 0]; - -add( - "tegumentar", "pelo", "Pelo (Haste Capilar)", "Hair Shaft", [...TX, "Complexo Pilossebáceo"], - { type: "cylinder", rt: 0.0006, rb: 0.001, h: 0.02 }, [HX, HY, Z_TOP + 0.01], - { - color: "#4a3527", - rotation: HAIR_TILT, - description: - "Porção do pelo visível acima da superfície da pele, formada por células queratinizadas mortas; sua cor vem da melanina produzida no bulbo piloso.", - }, + "hipoderme", "Hipoderme (Subcútis)", "Subcutis (Hypodermis)", + "Camada mais profunda, rica em tecido adiposo; isola termicamente, absorve impactos e serve de reserva de energia — não é tecnicamente parte da pele, mas a conecta aos músculos e ossos subjacentes.", ); add( - "tegumentar", "foliculo-piloso", "Folículo Piloso", "Hair Follicle", [...TX, "Complexo Pilossebáceo"], - { type: "cylinder", rt: 0.0022, rb: 0.0022, h: 0.058 }, [HX, HY, 0.009], - { - color: "#8a6a52", - rotation: HAIR_TILT, - description: - "Invaginação da epiderme para dentro da derme/hipoderme que envolve e forma a raiz do pelo; suas células-tronco também participam da regeneração da epiderme após lesões.", - }, + "estrato-corneo", "Estrato Córneo", "Stratum Corneum", + "Camada mais superficial da epiderme, formada por células mortas cheias de queratina que se descamam continuamente; é a barreira física final contra o ambiente.", + ["Epiderme"], ); add( - "tegumentar", "bulbo-piloso", "Bulbo Piloso", "Hair Bulb", [...TX, "Complexo Pilossebáceo"], - { type: "sphere", r: 0.004 }, [HX - 0.005, HY, -0.02], - { - color: "#6b4c38", - description: - "Base dilatada do folículo, na hipoderme; contém a papila dérmica (com vasos que nutrem o pelo) e a matriz germinativa, onde o pelo é produzido por divisão celular.", - }, + "camada-pigmentar", "Camada Pigmentar", "Pigment Layer", + "Região da epiderme onde os melanócitos depositam melanina nas células vizinhas, dando à pele sua cor e protegendo o DNA celular da radiação UV.", + ["Epiderme"], ); add( - "tegumentar", "musculo-eretor-do-pelo", "Músculo Eretor do Pelo", "Arrector Pili Muscle", [...TX, "Complexo Pilossebáceo"], - { type: "box", w: 0.0012, h: 0.014, d: 0.003 }, [HX + 0.005, HY, 0.017], - { - color: "#b5433a", - rotation: [1.05, 0, 0], - description: - "Pequeno músculo liso ligado obliquamente ao folículo; ao se contrair (frio, medo), eleva o pelo e comprime a glândula sebácea — é o responsável pela pele arrepiada (piloereção).", - }, + "estrato-germinativo", "Estrato Germinativo (Basal + Espinhoso)", "Stratum Germinativum (Basale + Spinosum)", + "Camadas mais profundas da epiderme (estrato basal + estrato espinhoso), onde as células-tronco se dividem continuamente para repor as células que se perdem na superfície.", + ["Epiderme"], ); add( - "tegumentar", "glandula-sebacea", "Glândula Sebácea", "Sebaceous Gland", [...TX, "Complexo Pilossebáceo"], - { type: "sphere", r: 0.005 }, [HX + 0.008, HY, 0.022], - { - color: "#f2c14e", - description: - "Glândula anexa ao folículo piloso que secreta sebo — mistura oleosa que lubrifica o pelo e a pele e ajuda a impermeabilizá-la; sua obstrução está por trás da acne.", - }, -); - -// ---- Fios de pelo extras, só decorativos (mesma descrição do "pelo"), pra -// a superfície do bloco não ficar com um pelo solitário — no desenho de -// referência vários pelos saem da epiderme ao mesmo tempo. ---- -const EXTRA_HAIRS = [ - [COL(1) + 0.01, CY - 0.03], - [COL(2) - 0.008, CY - 0.015], - [COL(4) + 0.006, CY - 0.025], -]; -EXTRA_HAIRS.forEach(([x, y], i) => { - add( - "tegumentar", `pelo-extra-${i + 1}`, "Pelo (Haste Capilar)", "Hair Shaft", - [...TX, "Complexo Pilossebáceo"], - { type: "cylinder", rt: 0.0006, rb: 0.001, h: 0.02 }, [x, y, Z_TOP + 0.01], - { - color: "#4a3527", - rotation: [Math.PI / 2 + (i - 1) * 0.15, 0.15, 0], - description: - "Porção do pelo visível acima da superfície da pele, formada por células queratinizadas mortas; sua cor vem da melanina produzida no bulbo piloso.", - }, - ); -}); - -// ---- Coluna 2: glândula sudorípara (écrina) + ducto + poro ---- -const SX = COL(1); -const SY = CY; -add( - "tegumentar", "glandula-sudoripara", "Glândula Sudorípara (Écrina)", "Eccrine Sweat Gland", [...TX, "Glândula Sudorípara"], - { type: "sphere", r: 0.006 }, [SX, SY, -0.045], - { - color: "#4f7cff", - description: - "Porção secretora enovelada, na derme profunda/hipoderme; produz o suor (água + eletrólitos), principal mecanismo de termorregulação do corpo por evaporação.", - }, + "papila-dermica", "Papila Dérmica", "Dermal Papilla", + "Projeção da derme para dentro da epiderme, aumentando a área de contato entre as duas camadas e alojando as alças capilares que nutrem a epiderme (que não tem vasos próprios).", + ["Derme"], ); add( - "tegumentar", "ducto-sudoriparo", "Ducto Sudoríparo", "Sweat Duct", [...TX, "Glândula Sudorípara"], - { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.083 }, [SX, SY, -0.0035], - { - color: "#7ba6ff", - rotation: ROT_Z, - description: - "Canal que conduz o suor da glândula, atravessando a derme e a epiderme, até a superfície da pele.", - }, + "pelo", "Pelo (Haste Capilar)", "Hair Shaft", + "Porção do pelo visível acima da superfície da pele, formada por células queratinizadas mortas; sua cor vem da melanina produzida na papila do pelo.", + ["Complexo Pilossebáceo"], ); add( - "tegumentar", "poro-sudoriparo", "Poro Sudoríparo", "Sweat Pore", [...TX, "Glândula Sudorípara"], - { type: "sphere", r: 0.0012 }, [SX, SY, Z_TOP + 0.0005], - { - color: "#a9c6ff", - description: "Abertura do ducto sudoríparo na superfície da epiderme, por onde o suor é liberado.", - }, -); - -// ---- Coluna 3: plexo vascular dérmico ---- -const VX = COL(2); -const VY = CY - 0.01; -add( - "tegumentar", "arteriola-dermica", "Arteríola Dérmica", "Dermal Arteriole", [...TX, "Plexo Vascular Dérmico"], - { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.02 }, [VX - 0.005, VY, 0.008], - { - color: "#c1121f", - rotation: ROT_Z, - description: "Ramo arterial que leva sangue oxigenado ao plexo de capilares da derme, nutrindo a pele (a epiderme em si não tem vasos).", - }, + "foliculo-piloso", "Folículo Piloso", "Hair Follicle", + "Invaginação da epiderme para dentro da derme/hipoderme que envolve e forma a raiz do pelo; suas células-tronco também participam da regeneração da epiderme após lesões.", + ["Complexo Pilossebáceo"], ); add( - "tegumentar", "venula-dermica", "Vênula Dérmica", "Dermal Venule", [...TX, "Plexo Vascular Dérmico"], - { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.02 }, [VX + 0.005, VY, 0.008], - { - color: "#3457d5", - rotation: ROT_Z, - description: "Ramo venoso que drena o sangue do plexo capilar dérmico de volta à circulação.", - }, + "papila-do-pelo", "Papila do Pelo", "Papilla of Hair", + "Estrutura na base do folículo, rica em vasos sanguíneos, que nutre a matriz germinativa responsável por produzir o pelo.", + ["Complexo Pilossebáceo"], ); add( - "tegumentar", "alca-capilar-dermica", "Alça Capilar Dérmica", "Dermal Capillary Loop", [...TX, "Plexo Vascular Dérmico"], - { type: "cylinder", rt: 0.0006, rb: 0.0006, h: 0.012 }, [VX, VY, 0.024], - { - color: "#e0464f", - rotation: ROT_Z, - description: "Alça de capilares que sobe até as papilas dérmicas, logo abaixo da epiderme, levando nutrientes e oxigênio para a base da epiderme por difusão.", - }, + "musculo-eretor-do-pelo", "Músculo Eretor do Pelo", "Arrector Pili Muscle", + "Pequeno músculo liso ligado obliquamente ao folículo; ao se contrair (frio, medo), eleva o pelo e comprime a glândula sebácea — é o responsável pela pele arrepiada (piloereção).", + ["Complexo Pilossebáceo"], ); - -// ---- Coluna 4: corpúsculo de Meissner (toque leve) ---- add( - "tegumentar", "corpusculo-de-meissner", "Corpúsculo de Meissner", "Meissner's Corpuscle", [...TX, "Receptores Sensoriais"], - { type: "sphere", r: 0.003 }, [COL(3), CY - 0.015, 0.024], - { - color: "#34d399", - description: - "Receptor sensorial encapsulado logo abaixo da epiderme; detecta toque leve e vibrações de baixa frequência — muito numeroso na ponta dos dedos e nos lábios.", - }, + "glandula-sebacea", "Glândula Sebácea", "Sebaceous Gland", + "Glândula anexa ao folículo piloso que secreta sebo — mistura oleosa que lubrifica o pelo e a pele e ajuda a impermeabilizá-la; sua obstrução está por trás da acne.", + ["Complexo Pilossebáceo"], ); - -// ---- Coluna 5: corpúsculo de Pacini (pressão profunda) ---- add( - "tegumentar", "corpusculo-de-pacini", "Corpúsculo de Pacini", "Pacinian Corpuscle", [...TX, "Receptores Sensoriais"], - { type: "sphere", r: 0.008 }, [COL(4), CY + 0.02, -0.045], - { - color: "#22d3ee", - description: - "Grande receptor sensorial encapsulado em camadas (como uma cebola), na hipoderme; detecta pressão profunda e vibrações de alta frequência.", - }, + "glandula-sudoripara", "Glândula Sudorípara (Écrina)", "Eccrine Sweat Gland", + "Porção secretora enovelada, na derme profunda/hipoderme; produz o suor (água + eletrólitos), principal mecanismo de termorregulação do corpo por evaporação.", + ["Glândula Sudorípara"], +); +add( + "poro-sudoriparo", "Poro Sudoríparo", "Sweat Pore", + "Abertura do ducto da glândula sudorípara na superfície da epiderme, por onde o suor é liberado.", + ["Glândula Sudorípara"], +); +add( + "fibra-nervosa", "Fibra Nervosa", "Nerve Fiber", + "Prolongamento de neurônio que percorre a derme levando informação sensorial (toque, dor, temperatura) captada na pele até o sistema nervoso central.", + ["Inervação da Pele"], +); +add( + "terminacao-nervosa-tato", "Terminação Nervosa Sensorial (Tato)", "Sensory Nerve Ending for Touch", + "Receptor sensorial na junção derme-epiderme que detecta toque leve — muito numeroso na ponta dos dedos e nos lábios (tipo Meissner).", + ["Inervação da Pele"], +); +add( + "corpusculo-de-pacini", "Corpúsculo de Pacini", "Pacinian Corpuscle", + "Grande receptor sensorial encapsulado em camadas (como uma cebola), na hipoderme; detecta pressão profunda e vibrações de alta frequência.", + ["Inervação da Pele"], +); +add( + "vasos-sanguineos-linfaticos", "Vasos Sanguíneos e Linfáticos", "Blood and Lymph Vessels", + "Rede de vasos na derme que nutre a pele (a epiderme em si não tem vasos), participa da termorregulação e drena o excesso de líquido tecidual.", + ["Vascularização da Pele"], +); +add( + "arteria", "Artéria (Ramo Cutâneo)", "Artery (Cutaneous Branch)", + "Ramo arterial que entra na derme profunda e alimenta o plexo de vasos que nutre a pele.", + ["Vascularização da Pele"], +); +add( + "veia", "Veia (Ramo Cutâneo)", "Vein (Cutaneous Branch)", + "Ramo venoso que drena o sangue do plexo vascular da pele de volta à circulação.", + ["Vascularização da Pele"], ); - -// ---- Coluna 6: lóbulos de tecido adiposo, espalhados pela hipoderme ---- -const FX = COL(5); -const FY = CY + 0.01; -const FAT_OFFSETS = [ - [0, -0.02], [0.012, -0.03], [-0.01, -0.025], - [0.005, -0.04], [-0.008, -0.045], [0.01, -0.052], -]; -FAT_OFFSETS.forEach(([dx, dz], i) => { - add( - "tegumentar", `adipocito-${i + 1}`, `Lóbulo de Tecido Adiposo ${i + 1}`, `Adipose Tissue Lobule ${i + 1}`, - [...TX, "Tecido Adiposo"], - { type: "sphere", r: 0.007 }, [FX + dx, FY, dz], - { - color: "#f7e08c", - description: - "Grupo de adipócitos (células de gordura) da hipoderme; armazena energia, isola termicamente e amortece impactos mecânicos.", - }, - ); -}); writeFileSync( path.resolve(__dirname, "manifest-tegumentar.json"), diff --git a/frontend/public/tegumentar/skin-cross-section.jpg b/frontend/public/tegumentar/skin-cross-section.jpg new file mode 100644 index 0000000..487c902 Binary files /dev/null and b/frontend/public/tegumentar/skin-cross-section.jpg differ diff --git a/frontend/src/components/SkinDiagram.tsx b/frontend/src/components/SkinDiagram.tsx new file mode 100644 index 0000000..ac9831f --- /dev/null +++ b/frontend/src/components/SkinDiagram.tsx @@ -0,0 +1,69 @@ +import { useAtlasStore } from "../store/useAtlasStore.js"; +import { SKIN_HOTSPOTS } from "../data/skinHotspots.js"; +import type { Structure } from "../lib/api.js"; + +// O sistema tegumentar não tem malha 3D (Z-Anatomy não cobre pele — ver +// nota em data-pipeline/seed/build-manifest.mjs) e duas tentativas de bloco +// 3D esquemático não ficaram boas o bastante pra estudo. Em vez disso, é um +// diagrama 2D clássico (domínio público, NCI/SEER) com pontos clicáveis — +// cada um abre o mesmo StructureInfoPanel usado pelos outros sistemas. +export function SkinDiagram({ bySlug }: { bySlug: Map }) { + const selectedStructureId = useAtlasStore((s) => s.selectedStructureId); + const selectStructure = useAtlasStore((s) => s.selectStructure); + const toggleSystem = useAtlasStore((s) => s.toggleSystem); + + const tegumentar = [...bySlug.values()].filter((s) => s.system.slug === "tegumentar"); + if (tegumentar.length === 0) return null; + + return ( +
+
+
+

+ Corte Transversal da Pele — clique nos pontos pra explorar +

+ +
+ +
+ Corte transversal da pele mostrando epiderme, derme, hipoderme e seus anexos + {tegumentar.map((structure) => { + const hotspot = SKIN_HOTSPOTS[structure.slug]; + if (!hotspot) return null; + const isSelected = structure.id === selectedStructureId; + return ( +
+ +

+ Imagem de domínio público — National Cancer Institute / SEER Training Modules. +

+
+
+ ); +} diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx index 7e2c265..4af416f 100644 --- a/frontend/src/components/viewer/Scene.tsx +++ b/frontend/src/components/viewer/Scene.tsx @@ -189,7 +189,10 @@ export function Scene({ bySlug }: { bySlug: Map }) { // um sistema visível — a visibilidade por malha é resolvida dentro de // cada SystemModel. const structuresByFile = useMemo(() => { - const structuresList = [...bySlug.values()]; + // Tegumentar não tem malha 3D (é um diagrama 2D — ver SkinDiagram.tsx), + // então nunca entra na busca de .glb, senão o GLTFLoader tentaria + // carregar o modelFile placeholder ("n/a") dessas estruturas. + const structuresList = [...bySlug.values()].filter((s) => s.system.slug !== "tegumentar"); const map = new Map(); for (const s of structuresList) { if (!map.has(s.modelFile)) map.set(s.modelFile, []); diff --git a/frontend/src/components/viewer/SystemModel.tsx b/frontend/src/components/viewer/SystemModel.tsx index a22b0cc..f774736 100644 --- a/frontend/src/components/viewer/SystemModel.tsx +++ b/frontend/src/components/viewer/SystemModel.tsx @@ -41,6 +41,13 @@ export function SystemModel({ const mesh = obj as THREE.Mesh; if (mesh.isMesh && mesh.material) { mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone(); + // Malhas finas tipo lâmina (fáscias, aponeuroses) saem da simplificação + // do pipeline (weld + meshopt) com normais corrompidas — a frente e o + // verso dessas "folhas" quase se tocam, então o weld mistura normais + // opostas num vértice só, gerando aquele efeito de iluminação + // "manchado"/ruidoso. Recalcular a partir da geometria final (já + // soldada) sempre dá um resultado consistente com a malha real. + mesh.geometry.computeVertexNormals(); } }); clonedRef.current = clone; diff --git a/frontend/src/data/skinHotspots.ts b/frontend/src/data/skinHotspots.ts new file mode 100644 index 0000000..ee821b2 --- /dev/null +++ b/frontend/src/data/skinHotspots.ts @@ -0,0 +1,28 @@ +// Posição (em % da largura/altura da imagem) de cada marcador clicável +// sobre frontend/public/tegumentar/skin-cross-section.jpg — medidas +// diretamente sobre a imagem (domínio público, NCI/SEER Training Modules). +// Isso é só apresentação (onde desenhar o ponto em cima da figura); os +// dados da estrutura em si (nome, descrição) vêm do banco, como qualquer +// outra estrutura do atlas — ver data-pipeline/seed/build-manifest.mjs. +export const SKIN_HOTSPOTS: Record = { + "estrato-corneo": { x: 37.9, y: 15.0 }, + "camada-pigmentar": { x: 37.5, y: 18.6 }, + "papila-dermica": { x: 48.3, y: 27.2 }, + epiderme: { x: 75.0, y: 32.2 }, + "terminacao-nervosa-tato": { x: 74.6, y: 38.7 }, + derme: { x: 75.4, y: 45.8 }, + "glandula-sebacea": { x: 69.2, y: 47.3 }, + "foliculo-piloso": { x: 65.8, y: 53.7 }, + "papila-do-pelo": { x: 65.8, y: 58.7 }, + hipoderme: { x: 75.4, y: 65.9 }, + "estrato-germinativo": { x: 37.9, y: 33.7 }, + "musculo-eretor-do-pelo": { x: 42.1, y: 43.0 }, + "fibra-nervosa": { x: 38.8, y: 49.4 }, + "vasos-sanguineos-linfaticos": { x: 35.0, y: 78.1 }, + "glandula-sudoripara": { x: 47.1, y: 80.2 }, + "corpusculo-de-pacini": { x: 51.3, y: 71.6 }, + arteria: { x: 70.4, y: 78.1 }, + veia: { x: 75.0, y: 80.9 }, + pelo: { x: 65.8, y: 9.3 }, + "poro-sudoriparo": { x: 69.2, y: 27.9 }, +}; diff --git a/frontend/src/routes/AtlasPage.tsx b/frontend/src/routes/AtlasPage.tsx index 28c9627..3b0012a 100644 --- a/frontend/src/routes/AtlasPage.tsx +++ b/frontend/src/routes/AtlasPage.tsx @@ -7,6 +7,7 @@ import { ClippingControls } from "../components/ClippingControls.js"; import { SearchNav } from "../components/SearchNav.js"; import { StructureInfoPanel } from "../components/StructureInfoPanel.js"; import { QuickRegionNav } from "../components/QuickRegionNav.js"; +import { SkinDiagram } from "../components/SkinDiagram.js"; // Em telas pequenas não há espaço para os 3 painéis lado a lado — os // laterais viram gavetas (drawers) que deslizam por cima do visualizador @@ -61,6 +62,7 @@ export default function AtlasPage() {
+