tegumentar3
This commit is contained in:
1 parent
063bd629c2
commit
9f2fe75ff6
11 files changed
+221
-537
No files matched your search
@@ -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.
|
**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
|
## Arquitetura
|
||||||
|
|
||||||
@@ -87,15 +87,17 @@ Depois `docker compose up -d --build anatomia-db anatomia-api anatomia-web && do
|
|||||||
|
|
||||||
## Expandindo o dataset
|
## 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.
|
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.
|
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
|
## 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.
|
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)
|
## Desenvolvimento local (sem Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"fetch:models": "node scripts/fetch-real-models.mjs",
|
"fetch:models": "node scripts/fetch-real-models.mjs",
|
||||||
"convert:models": "node scripts/convert-fbx.mjs",
|
"convert:models": "node scripts/convert-fbx.mjs",
|
||||||
"extract:manifest": "node scripts/extract-manifest.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",
|
"merge:manifest": "node scripts/merge-manifests.mjs",
|
||||||
"seed:db": "node scripts/seed-db.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"
|
"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"
|
||||||
|
|||||||
@@ -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)`);
|
|
||||||
}
|
|
||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
// Gera manifest-tegumentar.json: o Z-Anatomy-Sample não inclui camadas de
|
// Gera manifest-tegumentar.json.
|
||||||
// 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).
|
|
||||||
//
|
//
|
||||||
// MODELO "DIORAMA": uma caixa sólida (as 3 camadas empilhadas, cada uma uma
|
// HISTÓRICO: tentamos duas vezes um bloco 3D esquemático (geometria
|
||||||
// openFrontBox — ver geometry.mjs) com a face da frente removida, como uma
|
// procedural — caixas/esferas/cilindros) pro sistema tegumentar, já que o
|
||||||
// caixa de vidro sem uma parede, no estilo do desenho clássico de corte de
|
// Z-Anatomy-Sample não tem nenhum dado de pele (confirmado consultando a
|
||||||
// pele (Netter/Sobotta): de cima dá pra ver a superfície da epiderme com os
|
// listagem completa de Assets/Models/1.0 Models: só Skeletal, Muscular,
|
||||||
// pelos saindo; do lado dá pra ver a "banda" de cor de cada camada; e pela
|
// Nervous, CardioVascular, Visceral, Joints, LymphoidOrgans, Regions e
|
||||||
// frente aberta dá pra ver os componentes (folículos, glândulas, vasos,
|
// Reference lines). Nenhuma das duas ficou boa o suficiente pra estudo.
|
||||||
// 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
|
// DECISÃO ATUAL: em vez de insistir num modelo 3D (que exigiria ou uma
|
||||||
// ângulos.
|
// 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 { writeFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -27,288 +31,121 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
|
|
||||||
const structures = [];
|
const structures = [];
|
||||||
|
|
||||||
// Todo o desenho abaixo é escrito em "unidades de projeto" (a escala
|
// modelFile/meshName são NOT NULL no schema mas não fazem sentido pro
|
||||||
// original, pensada peça por peça) e só na hora de gerar a estrutura final
|
// tegumentar (não há malha 3D) — o frontend nunca tenta carregar um glb
|
||||||
// o add() aplica o fator SCALE — assim dá pra aumentar/diminuir o bloco
|
// pra esse sistema (ver o filtro em Scene.tsx), então usamos um valor-
|
||||||
// inteiro mudando um único número, sem recalcular à mão a posição de cada
|
// sentinela só pra satisfazer o schema.
|
||||||
// uma das peças.
|
function add(slug, namePt, nameEn, description, region = []) {
|
||||||
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 = {}) {
|
|
||||||
structures.push({
|
structures.push({
|
||||||
system,
|
system: "tegumentar",
|
||||||
slug,
|
slug,
|
||||||
namePt,
|
namePt,
|
||||||
nameEn,
|
nameEn,
|
||||||
synonyms: opts.synonyms ?? [],
|
synonyms: [],
|
||||||
description: opts.description ?? "",
|
description,
|
||||||
regionPath: region,
|
regionPath: ["Sistema Tegumentar", "Corte Transversal da Pele", ...region],
|
||||||
modelFile: `${system}.glb`,
|
modelFile: "n/a",
|
||||||
meshName: slug,
|
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(
|
add(
|
||||||
"tegumentar", "epiderme", "Epiderme", "Epidermis", [...TX],
|
"epiderme", "Epiderme", "Epidermis",
|
||||||
{ type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_EPI },
|
"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.",
|
||||||
[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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "derme", "Derme", "Dermis", [...TX],
|
"derme", "Derme", "Dermis",
|
||||||
{ type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_DERME },
|
"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.",
|
||||||
[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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "hipoderme", "Hipoderme (Tecido Subcutâneo)", "Hypodermis (Subcutaneous Tissue)", [...TX],
|
"hipoderme", "Hipoderme (Subcútis)", "Subcutis (Hypodermis)",
|
||||||
{ type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_HIPO },
|
"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.",
|
||||||
[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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "foliculo-piloso", "Folículo Piloso", "Hair Follicle", [...TX, "Complexo Pilossebáceo"],
|
"estrato-corneo", "Estrato Córneo", "Stratum Corneum",
|
||||||
{ type: "cylinder", rt: 0.0022, rb: 0.0022, h: 0.058 }, [HX, HY, 0.009],
|
"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"],
|
||||||
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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "bulbo-piloso", "Bulbo Piloso", "Hair Bulb", [...TX, "Complexo Pilossebáceo"],
|
"camada-pigmentar", "Camada Pigmentar", "Pigment Layer",
|
||||||
{ type: "sphere", r: 0.004 }, [HX - 0.005, HY, -0.02],
|
"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"],
|
||||||
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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "musculo-eretor-do-pelo", "Músculo Eretor do Pelo", "Arrector Pili Muscle", [...TX, "Complexo Pilossebáceo"],
|
"estrato-germinativo", "Estrato Germinativo (Basal + Espinhoso)", "Stratum Germinativum (Basale + Spinosum)",
|
||||||
{ type: "box", w: 0.0012, h: 0.014, d: 0.003 }, [HX + 0.005, HY, 0.017],
|
"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"],
|
||||||
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).",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "glandula-sebacea", "Glândula Sebácea", "Sebaceous Gland", [...TX, "Complexo Pilossebáceo"],
|
"papila-dermica", "Papila Dérmica", "Dermal Papilla",
|
||||||
{ type: "sphere", r: 0.005 }, [HX + 0.008, HY, 0.022],
|
"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"],
|
||||||
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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "ducto-sudoriparo", "Ducto Sudoríparo", "Sweat Duct", [...TX, "Glândula Sudorípara"],
|
"pelo", "Pelo (Haste Capilar)", "Hair Shaft",
|
||||||
{ type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.083 }, [SX, SY, -0.0035],
|
"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"],
|
||||||
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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "poro-sudoriparo", "Poro Sudoríparo", "Sweat Pore", [...TX, "Glândula Sudorípara"],
|
"foliculo-piloso", "Folículo Piloso", "Hair Follicle",
|
||||||
{ type: "sphere", r: 0.0012 }, [SX, SY, Z_TOP + 0.0005],
|
"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"],
|
||||||
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).",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "venula-dermica", "Vênula Dérmica", "Dermal Venule", [...TX, "Plexo Vascular Dérmico"],
|
"papila-do-pelo", "Papila do Pelo", "Papilla of Hair",
|
||||||
{ type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.02 }, [VX + 0.005, VY, 0.008],
|
"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"],
|
||||||
color: "#3457d5",
|
|
||||||
rotation: ROT_Z,
|
|
||||||
description: "Ramo venoso que drena o sangue do plexo capilar dérmico de volta à circulação.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
add(
|
add(
|
||||||
"tegumentar", "alca-capilar-dermica", "Alça Capilar Dérmica", "Dermal Capillary Loop", [...TX, "Plexo Vascular Dérmico"],
|
"musculo-eretor-do-pelo", "Músculo Eretor do Pelo", "Arrector Pili Muscle",
|
||||||
{ type: "cylinder", rt: 0.0006, rb: 0.0006, h: 0.012 }, [VX, VY, 0.024],
|
"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"],
|
||||||
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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Coluna 4: corpúsculo de Meissner (toque leve) ----
|
|
||||||
add(
|
add(
|
||||||
"tegumentar", "corpusculo-de-meissner", "Corpúsculo de Meissner", "Meissner's Corpuscle", [...TX, "Receptores Sensoriais"],
|
"glandula-sebacea", "Glândula Sebácea", "Sebaceous Gland",
|
||||||
{ type: "sphere", r: 0.003 }, [COL(3), CY - 0.015, 0.024],
|
"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"],
|
||||||
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.",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Coluna 5: corpúsculo de Pacini (pressão profunda) ----
|
|
||||||
add(
|
add(
|
||||||
"tegumentar", "corpusculo-de-pacini", "Corpúsculo de Pacini", "Pacinian Corpuscle", [...TX, "Receptores Sensoriais"],
|
"glandula-sudoripara", "Glândula Sudorípara (Écrina)", "Eccrine Sweat Gland",
|
||||||
{ type: "sphere", r: 0.008 }, [COL(4), CY + 0.02, -0.045],
|
"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"],
|
||||||
color: "#22d3ee",
|
);
|
||||||
description:
|
add(
|
||||||
"Grande receptor sensorial encapsulado em camadas (como uma cebola), na hipoderme; detecta pressão profunda e vibrações de alta frequência.",
|
"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(
|
writeFileSync(
|
||||||
path.resolve(__dirname, "manifest-tegumentar.json"),
|
path.resolve(__dirname, "manifest-tegumentar.json"),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
@@ -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<string, Structure> }) {
|
||||||
|
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 (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center p-4 bg-ink/70">
|
||||||
|
<div className="relative bg-panel border border-white/10 rounded-xl shadow-2xl p-3 sm:p-4 max-w-2xl w-full">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-sm font-semibold text-white">
|
||||||
|
Corte Transversal da Pele — clique nos pontos pra explorar
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSystem("tegumentar")}
|
||||||
|
className="text-slate-400 hover:text-white text-sm px-2 py-1"
|
||||||
|
aria-label="Fechar diagrama"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full select-none">
|
||||||
|
<img
|
||||||
|
src="/tegumentar/skin-cross-section.jpg"
|
||||||
|
alt="Corte transversal da pele mostrando epiderme, derme, hipoderme e seus anexos"
|
||||||
|
className="w-full h-auto rounded-lg border border-white/10"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{tegumentar.map((structure) => {
|
||||||
|
const hotspot = SKIN_HOTSPOTS[structure.slug];
|
||||||
|
if (!hotspot) return null;
|
||||||
|
const isSelected = structure.id === selectedStructureId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={structure.id}
|
||||||
|
onClick={() => selectStructure(structure.id)}
|
||||||
|
title={structure.namePt}
|
||||||
|
aria-label={structure.namePt}
|
||||||
|
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%` }}
|
||||||
|
className={`absolute -translate-x-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 transition-all
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? "bg-accent border-white scale-125 shadow-[0_0_0_4px_rgba(79,124,255,0.35)]"
|
||||||
|
: "bg-accent/70 border-white/70 hover:scale-125 hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-slate-500 mt-2">
|
||||||
|
Imagem de domínio público — National Cancer Institute / SEER Training Modules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -189,7 +189,10 @@ export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
|
|||||||
// um sistema visível — a visibilidade por malha é resolvida dentro de
|
// um sistema visível — a visibilidade por malha é resolvida dentro de
|
||||||
// cada SystemModel.
|
// cada SystemModel.
|
||||||
const structuresByFile = useMemo(() => {
|
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<string, Structure[]>();
|
const map = new Map<string, Structure[]>();
|
||||||
for (const s of structuresList) {
|
for (const s of structuresList) {
|
||||||
if (!map.has(s.modelFile)) map.set(s.modelFile, []);
|
if (!map.has(s.modelFile)) map.set(s.modelFile, []);
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export function SystemModel({
|
|||||||
const mesh = obj as THREE.Mesh;
|
const mesh = obj as THREE.Mesh;
|
||||||
if (mesh.isMesh && mesh.material) {
|
if (mesh.isMesh && mesh.material) {
|
||||||
mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone();
|
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;
|
clonedRef.current = clone;
|
||||||
|
|||||||
@@ -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<string, { x: number; y: number }> = {
|
||||||
|
"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 },
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { ClippingControls } from "../components/ClippingControls.js";
|
|||||||
import { SearchNav } from "../components/SearchNav.js";
|
import { SearchNav } from "../components/SearchNav.js";
|
||||||
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
|
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
|
||||||
import { QuickRegionNav } from "../components/QuickRegionNav.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
|
// 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
|
// laterais viram gavetas (drawers) que deslizam por cima do visualizador
|
||||||
@@ -61,6 +62,7 @@ export default function AtlasPage() {
|
|||||||
<div className="relative h-full min-h-0">
|
<div className="relative h-full min-h-0">
|
||||||
<Scene bySlug={bySlug} />
|
<Scene bySlug={bySlug} />
|
||||||
<QuickRegionNav />
|
<QuickRegionNav />
|
||||||
|
<SkinDiagram bySlug={bySlug} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftOpen(true)}
|
onClick={() => setLeftOpen(true)}
|
||||||
|
|||||||
Reference in new issue
Block a user