first commit

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-07 20:46:08 -03:00
commit 0e854110d3
49 files changed
+2292

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
POSTGRES_USER=anatomia
POSTGRES_PASSWORD=change-me
POSTGRES_DB=anatomia
# Domínio público do atlas — usado pelo acme-companion (via LETSENCRYPT_HOST)
# para emitir o certificado, e deve bater com o server_name do vhost em
# docker/nginx/sites/*.conf no host cloud-lua-net.
ANATOMIA_DOMAIN=atlas.martinsevieira.adv.br
# IP fixo do container anatomia-web dentro da rede externa "dockernet".
# Confira que está livre (docker network inspect dockernet) antes de subir.
ANATOMIA_WEB_IP=10.15.0.93
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
build/
.env
*.log
.DS_Store
data-pipeline/seed/manifest.json
data-pipeline/seed/models/
+103
View File
@@ -0,0 +1,103 @@
# Atlas 3D de Anatomia
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
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.
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.
## Arquitetura
Este compose foi adaptado para rodar como um stack **separado** dentro da infraestrutura existente do host `cloud-lua-net`, que já tem uma rede Docker externa `dockernet` e um container `nginx` central (com HTTP/3) que lê vhosts manuais em `docker/nginx/sites/*.conf`, mais um par `nginx-gen` + `acme-companion` que emite certificados Let's Encrypt via `LETSENCRYPT_HOST`.
```
container "nginx" (proxy principal do host, HTTP/3, já existente)
│ vhost manual: docker/nginx/sites/30-atlas.conf
│ proxy_pass → IP fixo do container anatomia-web em dockernet
▼
container "anatomia-web" (nginx interno deste projeto)
├─ serve o build estático do frontend (SPA)
├─ /api/* → proxy → container "anatomia-api"
└─ /models/* → proxy → container "anatomia-api"
container "anatomia-api" (Fastify + Prisma) ──▶ container "anatomia-db" (Postgres)
```
Não é publicada nenhuma porta no host — `anatomia-web` só é alcançável dentro da rede `dockernet`, no mesmo padrão já usado para outros apps desse compose (ex: `frontend`/`backend` do ticketz, `nextcloud`). Isso significa que **este projeto roda como um `docker compose` próprio, independente do compose gigante do host** — ele só precisa se conectar à mesma rede externa `dockernet`, o que já é suportado nativamente pelo Docker (containers de projetos Compose diferentes na mesma rede se enxergam normalmente).
## Como rodar
```bash
# no host cloud-lua-net, dentro da pasta deste projeto (ex: /home/docker/anatomia)
cp .env.example .env
# Confirme que o IP escolhido está livre antes de subir:
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
```
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.
### Rodando fora desse host (dev local, sem "dockernet")
O `docker-compose.yml` deste projeto depende da rede externa `dockernet` e de um IP fixo, então para testar localmente em outra máquina crie a rede primeiro e ajuste/remova o `ipv4_address`, ou publique uma porta temporária no serviço `anatomia-web`, por exemplo:
```bash
docker network create dockernet # numa máquina onde ela ainda não existe
```
E adicione temporariamente ao serviço `anatomia-web` no `docker-compose.yml`:
```yaml
ports:
- "8080:80"
```
Depois `docker compose up -d --build anatomia-db anatomia-api anatomia-web && docker compose run --rm anatomia-pipeline` e acesse `http://localhost:8080`.
## Estrutura do projeto
- `frontend/` — React + TypeScript + Vite + react-three-fiber (visualizador 3D), Zustand, Tailwind.
- `backend/` — Fastify + TypeScript + Prisma (API REST + arquivos `.glb` estáticos).
- `data-pipeline/` — scripts que geram os modelos 3D semente e populam o banco (roda como serviço Docker *one-off*, não fica sempre ligado).
- `nginx/nginx.conf` — nginx interno que serve o frontend e faz proxy para a API.
## Funcionalidades
- **Camadas por sistema**: esquelético, muscular, nervoso, cardiovascular, respiratório, 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).
- **Módulo de planos e eixos anatômicos** (`/planos-anatomicos`): manequim simples com os três planos de secção, eixos de direção rotulados e glossário de termos (anterior/posterior, medial/lateral, proximal/distal etc.).
- **Quiz com repetição espaçada** (`/quiz`): pede para localizar a estrutura no modelo 3D; usa o algoritmo SM-2 para agendar revisões.
## 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. **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.
## Desenvolvimento local (sem Docker)
```bash
# Backend
cd backend && npm install
export DATABASE_URL=postgresql://anatomia:anatomia@localhost:5432/anatomia
npx prisma migrate deploy
npm run dev
# Frontend (em outro terminal)
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)
cd data-pipeline && npm install
npm run build:seed-models
npm run seed:db
```
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY prisma ./prisma
RUN npx prisma generate
COPY src ./src
COPY tsconfig.json ./
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json ./
RUN npm install --omit=dev
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=build /app/dist ./dist
COPY prisma ./prisma
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
EXPOSE 3333
ENTRYPOINT ["./docker-entrypoint.sh"]
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Aplicando migrations do Prisma..."
npx prisma migrate deploy
echo "Iniciando API..."
exec node dist/server.js
+28
View File
@@ -0,0 +1,28 @@
{
"name": "anatomia-backend",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
"seed": "tsx src/lib/seed.ts"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/static": "^7.0.4",
"@prisma/client": "^5.20.0",
"fastify": "^4.28.1",
"prisma": "^5.20.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.14.15",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}
@@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "anatomical_systems" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"namePt" TEXT NOT NULL,
"nameEn" TEXT NOT NULL,
"colorHex" TEXT NOT NULL DEFAULT '#4f7cff',
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "anatomical_systems_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "structures" (
"id" TEXT NOT NULL,
"fmaId" TEXT,
"slug" TEXT NOT NULL,
"namePt" TEXT NOT NULL,
"nameEn" TEXT NOT NULL,
"synonyms" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"description" TEXT,
"regionPath" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"modelFile" TEXT NOT NULL,
"meshName" TEXT NOT NULL,
"systemId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "structures_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quiz_cards" (
"id" TEXT NOT NULL,
"structureId" TEXT NOT NULL,
"easeFactor" DOUBLE PRECISION NOT NULL DEFAULT 2.5,
"intervalDays" DOUBLE PRECISION NOT NULL DEFAULT 0,
"repetitions" INTEGER NOT NULL DEFAULT 0,
"dueAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastReviewedAt" TIMESTAMP(3),
CONSTRAINT "quiz_cards_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "anatomical_systems_slug_key" ON "anatomical_systems"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "structures_fmaId_key" ON "structures"("fmaId");
-- CreateIndex
CREATE UNIQUE INDEX "structures_slug_key" ON "structures"("slug");
-- CreateIndex
CREATE INDEX "structures_systemId_idx" ON "structures"("systemId");
-- CreateIndex
CREATE UNIQUE INDEX "quiz_cards_structureId_key" ON "quiz_cards"("structureId");
-- AddForeignKey
ALTER TABLE "structures" ADD CONSTRAINT "structures_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "anatomical_systems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quiz_cards" ADD CONSTRAINT "quiz_cards_structureId_fkey" FOREIGN KEY ("structureId") REFERENCES "structures"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1 @@
provider = "postgresql"
+54
View File
@@ -0,0 +1,54 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model AnatomicalSystem {
id String @id @default(cuid())
slug String @unique
namePt String
nameEn String
colorHex String @default("#4f7cff")
order Int @default(0)
structures Structure[]
@@map("anatomical_systems")
}
model Structure {
id String @id @default(cuid())
fmaId String? @unique
slug String @unique
namePt String
nameEn String
synonyms String[] @default([])
description String?
regionPath String[] @default([])
modelFile String
meshName String
systemId String
system AnatomicalSystem @relation(fields: [systemId], references: [id])
quizCard QuizCard?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([systemId])
@@map("structures")
}
model QuizCard {
id String @id @default(cuid())
structureId String @unique
structure Structure @relation(fields: [structureId], references: [id])
easeFactor Float @default(2.5)
intervalDays Float @default(0)
repetitions Int @default(0)
dueAt DateTime @default(now())
lastReviewedAt DateTime?
@@map("quiz_cards")
}
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
+28
View File
@@ -0,0 +1,28 @@
// SM-2 spaced repetition algorithm (SuperMemo 2).
// `quality` is 0-5: 0-2 = failed recall (reset), 3-5 = successful recall (grows interval).
export interface SM2State {
easeFactor: number;
intervalDays: number;
repetitions: number;
}
export function sm2(state: SM2State, quality: number): SM2State {
const q = Math.max(0, Math.min(5, quality));
let { easeFactor, intervalDays, repetitions } = state;
if (q < 3) {
return { easeFactor, intervalDays: 1, repetitions: 0 };
}
repetitions += 1;
if (repetitions === 1) intervalDays = 1;
else if (repetitions === 2) intervalDays = 6;
else intervalDays = Math.round(intervalDays * easeFactor);
easeFactor = Math.max(
1.3,
easeFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)),
);
return { easeFactor, intervalDays, repetitions };
}
+74
View File
@@ -0,0 +1,74 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { sm2 } from "../lib/sm2.js";
const reviewBodySchema = z.object({
quality: z.number().int().min(0).max(5),
});
export async function quizRoutes(app: FastifyInstance) {
// Returns a batch of structures due for review (or new ones if nothing is due yet).
app.get<{ Querystring: { system?: string; limit?: string } }>(
"/api/quiz/next",
async (req) => {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
const systemFilter = req.query.system
? { system: { slug: req.query.system } }
: {};
const due = await prisma.structure.findMany({
where: {
...systemFilter,
OR: [
{ quizCard: null },
{ quizCard: { dueAt: { lte: new Date() } } },
],
},
include: { system: true, quizCard: true },
take: limit,
});
return due;
},
);
app.post<{ Params: { structureId: string }; Body: unknown }>(
"/api/quiz/:structureId/review",
async (req, reply) => {
const { quality } = reviewBodySchema.parse(req.body);
const structureId = req.params.structureId;
const existing = await prisma.quizCard.findUnique({ where: { structureId } });
const current = existing ?? {
easeFactor: 2.5,
intervalDays: 0,
repetitions: 0,
};
const next = sm2(current, quality);
const dueAt = new Date(Date.now() + next.intervalDays * 24 * 60 * 60 * 1000);
const card = await prisma.quizCard.upsert({
where: { structureId },
create: {
structureId,
easeFactor: next.easeFactor,
intervalDays: next.intervalDays,
repetitions: next.repetitions,
dueAt,
lastReviewedAt: new Date(),
},
update: {
easeFactor: next.easeFactor,
intervalDays: next.intervalDays,
repetitions: next.repetitions,
dueAt,
lastReviewedAt: new Date(),
},
});
return reply.send(card);
},
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
const listQuerySchema = z.object({
system: z.string().optional(),
q: z.string().optional(),
});
export async function structuresRoutes(app: FastifyInstance) {
app.get("/api/structures", async (req) => {
const { system, q } = listQuerySchema.parse(req.query);
return prisma.structure.findMany({
where: {
...(system ? { system: { slug: system } } : {}),
...(q
? {
OR: [
{ namePt: { contains: q, mode: "insensitive" } },
{ nameEn: { contains: q, mode: "insensitive" } },
{ synonyms: { has: q } },
],
}
: {}),
},
include: { system: true },
orderBy: { namePt: "asc" },
});
});
app.get<{ Params: { id: string } }>("/api/structures/:id", async (req, reply) => {
const structure = await prisma.structure.findUnique({
where: { id: req.params.id },
include: { system: true },
});
if (!structure) return reply.code(404).send({ error: "not_found" });
return structure;
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export async function systemsRoutes(app: FastifyInstance) {
app.get("/api/systems", async () => {
return prisma.anatomicalSystem.findMany({
orderBy: { order: "asc" },
include: { _count: { select: { structures: true } } },
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { systemsRoutes } from "./routes/systems.js";
import { structuresRoutes } from "./routes/structures.js";
import { quizRoutes } from "./routes/quiz.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MODELS_DIR = process.env.MODELS_DIR ?? path.resolve(__dirname, "../models");
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(fastifyStatic, {
root: MODELS_DIR,
prefix: "/models/",
decorateReply: false,
immutable: true,
maxAge: "7d",
});
app.get("/api/health", async () => ({ status: "ok" }));
await app.register(systemsRoutes);
await app.register(structuresRoutes);
await app.register(quizRoutes);
const port = Number(process.env.PORT ?? 3333);
app
.listen({ port, host: "0.0.0.0" })
.catch((err) => {
app.log.error(err);
process.exit(1);
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"]
}
+6
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
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"]
+15
View File
@@ -0,0 +1,15 @@
{
"name": "anatomia-data-pipeline",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"build:seed-models": "node scripts/build-seed-models.mjs",
"seed:db": "node scripts/seed-db.mjs"
},
"dependencies": {
"@gltf-transform/core": "^4.0.4",
"@gltf-transform/functions": "^4.0.4",
"pg": "^8.12.0"
}
}
@@ -0,0 +1,97 @@
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, "../seed/manifest.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);
const material = doc
.createMaterial(systemSlug)
.setBaseColorFactor(hexToRgb(systemColors[systemSlug] ?? "#cccccc"))
.setRoughnessFactor(0.6)
.setMetallicFactor(0.05);
for (const item of items) {
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.slug).addPrimitive(prim);
const node = doc
.createNode(item.slug)
.setMesh(mesh)
.setTranslation(item.position)
.setRotation(eulerToQuat(item.rotation ?? [0, 0, 0]));
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)`);
}
+130
View File
@@ -0,0 +1,130 @@
// Minimal parametric mesh generators (box/sphere/cylinder), dependency-free.
// Returns { positions: Float32Array, normals: Float32Array, indices: Uint32Array }.
export function box(w, h, d) {
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]]],
];
for (const [n, corners] of faces) {
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),
};
}
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 "sphere": return sphere(shape.r);
case "cylinder": return cylinder(shape.rt, shape.rb, shape.h);
default: throw new Error(`Unknown shape type: ${shape.type}`);
}
}
+98
View File
@@ -0,0 +1,98 @@
// Populates the database from data-pipeline/seed/manifest.json.
// Run after `npm run build:seed-models` and after backend Prisma migrations
// have been applied (this script uses plain SQL, not Prisma, so the
// data-pipeline stays independent of the backend's toolchain).
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
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 connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.error("DATABASE_URL não definida.");
process.exit(1);
}
const client = new pg.Client({ connectionString });
await client.connect();
// The "api" service applies Prisma migrations on startup; if this pipeline
// runs first (e.g. right after `docker compose up`), wait for the table.
async function waitForTable(name, maxAttempts = 30) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const { rows } = await client.query(
`SELECT to_regclass($1) AS exists`,
[name],
);
if (rows[0]?.exists) return;
console.log(`Aguardando migrations do backend criarem "${name}" (${attempt}/${maxAttempts})...`);
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`Tabela "${name}" não apareceu — rode "docker compose up -d api" antes do pipeline.`);
}
await waitForTable("structures");
function cuid(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
try {
await client.query("BEGIN");
const systemIds = new Map();
for (const s of systems) {
const id = cuid("sys");
systemIds.set(s.slug, id);
await client.query(
`INSERT INTO anatomical_systems (id, slug, "namePt", "nameEn", "colorHex", "order")
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (slug) DO UPDATE SET "namePt"=$3, "nameEn"=$4, "colorHex"=$5, "order"=$6
RETURNING id`,
[id, s.slug, s.namePt, s.nameEn, s.colorHex, s.order],
);
}
// Re-read ids (in case of ON CONFLICT they may differ from freshly generated ones).
const { rows: existingSystems } = await client.query(
`SELECT id, slug FROM anatomical_systems`,
);
for (const row of existingSystems) systemIds.set(row.slug, row.id);
for (const st of structures) {
const systemId = systemIds.get(st.system);
if (!systemId) throw new Error(`Sistema desconhecido: ${st.system}`);
await client.query(
`INSERT INTO structures (id, "fmaId", slug, "namePt", "nameEn", synonyms, description, "regionPath", "modelFile", "meshName", "systemId", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11, now())
ON CONFLICT (slug) DO UPDATE SET
"namePt"=$4, "nameEn"=$5, synonyms=$6, description=$7, "regionPath"=$8,
"modelFile"=$9, "meshName"=$10, "systemId"=$11, "updatedAt"=now()`,
[
cuid("str"),
null,
st.slug,
st.namePt,
st.nameEn,
st.synonyms ?? [],
st.description ?? null,
st.regionPath ?? [],
`${st.system}.glb`,
st.slug,
systemId,
],
);
}
await client.query("COMMIT");
console.log(`Seed concluído: ${systems.length} sistemas, ${structures.length} estruturas.`);
} catch (err) {
await client.query("ROLLBACK");
console.error(err);
process.exit(1);
} finally {
await client.end();
}
+165
View File
@@ -0,0 +1,165 @@
// Generates manifest.json: the curated seed dataset of anatomical structures.
//
// IMPORTANT — read this before treating the geometry as "real anatomy":
// The BodyParts3D dataset (lifesciencedb.jp/bp3d) does not expose a scriptable
// download endpoint (it's served through a legacy Ext.js viewer app with no
// stable direct-download URLs per structure). Rather than block the whole
// platform on manually clicking through thousands of downloads, this seed
// dataset uses simple parametric primitives (box/sphere/cylinder) placed at
// anatomically correct positions/proportions on a standing human figure
// (1.8 m tall, origin at the feet, y-up, +z = anterior).
//
// The names, systems, regions and relationships ARE real anatomy — only the
// mesh shapes are schematic stand-ins. To upgrade to real segmented meshes:
// obtain BodyParts3D OBJ files (manually exported from the viewer, or via
// Z-Anatomy's Blender project which re-packages the same CC BY-SA dataset
// with saner file names), then replace the shape generator's output for a
// given `slug` with `scripts/convert-obj.mjs <file.obj> <slug>` (see
// data-pipeline/README.md). No frontend/backend code needs to change.
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 = {}) {
structures.push({
system,
slug,
namePt,
nameEn,
synonyms: opts.synonyms ?? [],
description: opts.description ?? "",
regionPath: region,
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),
);
console.log(`manifest.json gerado com ${structures.length} estruturas em ${systems.length} sistemas.`);
+81
View File
@@ -0,0 +1,81 @@
# Copie este arquivo para /home/docker/docker/nginx/sites/ no host
# cloud-lua-net (ex: 30-atlas.conf) — é lido pelo container "nginx"
# (o proxy reverso principal, com HTTP/3), que monta
# ./docker/nginx/sites:/etc/nginx/conf.d.
#
# Pré-requisitos:
# 1. DNS de atlas.martinsevieira.adv.br apontando para este servidor.
# 2. O stack do Atlas (docker-compose.yml deste projeto) rodando e
# conectado à rede externa "dockernet" com IP fixo 10.15.0.93
# (ajuste os dois lugares abaixo se você mudou ANATOMIA_WEB_IP).
# 3. O container "anatomia-web" tem LETSENCRYPT_HOST=atlas.martinsevieira.adv.br,
# então o acme-companion já existente vai emitir o certificado
# automaticamente assim que este vhost estiver no ar respondendo o
# desafio HTTP-01 em /.well-known/acme-challenge/.
server {
server_name atlas.martinsevieira.adv.br;
listen 80;
listen [::]:80;
location ^~ /.well-known/acme-challenge/ {
auth_basic off;
auth_request off;
allow all;
root /usr/share/nginx/html;
try_files $uri =404;
break;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
server_name atlas.martinsevieira.adv.br;
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
http2 on;
http3 on;
http3_hq on;
quic_retry on;
quic_gso on;
add_header Alt-Svc 'h3=":443"; ma=86400';
ssl_certificate /etc/nginx/certs/atlas.martinsevieira.adv.br/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/atlas.martinsevieira.adv.br/key.pem;
ssl_dhparam /etc/nginx/certs/dhparam.pem;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_timeout 10m;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
# Modelos 3D (.glb) podem crescer bastante quando o dataset for
# ampliado com malhas reais do BodyParts3D/Z-Anatomy.
client_max_body_size 200m;
# anatomia-web (o nginx interno do app) já cuida do SPA fallback e do
# proxy para /api e /models até o backend — aqui é só um passthrough.
location / {
proxy_pass http://10.15.0.93:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+98
View File
@@ -0,0 +1,98 @@
networks:
dockernet:
external: true
name: dockernet
services:
anatomia-db:
container_name: anatomia-db
image: postgres:16-alpine
restart: unless-stopped
environment:
TZ: 'America/Araguaina'
POSTGRES_USER: ${POSTGRES_USER:-anatomia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-anatomia}
POSTGRES_DB: ${POSTGRES_DB:-anatomia}
volumes:
- anatomia_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-anatomia}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- dockernet
deploy:
resources:
limits:
memory: 512M
anatomia-api:
container_name: anatomia-api
build:
context: ./backend
restart: unless-stopped
depends_on:
anatomia-db:
condition: service_healthy
environment:
TZ: 'America/Araguaina'
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}
PORT: 3333
MODELS_DIR: /app/models
volumes:
- anatomia_models_data:/app/models:ro
networks:
- dockernet
deploy:
resources:
limits:
memory: 512M
anatomia-web:
container_name: anatomia-web
build:
context: .
dockerfile: frontend/Dockerfile
restart: unless-stopped
depends_on:
- anatomia-api
environment:
TZ: 'America/Araguaina'
# Lido pelo acme-companion (pareado com nginx-gen) para emitir/renovar
# o certificado Let's Encrypt deste domínio.
LETSENCRYPT_HOST: ${ANATOMIA_DOMAIN:-atlas.martinsevieira.adv.br}
networks:
dockernet:
# IP fixo para o vhost manual em docker/nginx/sites/*.conf poder
# apontar (proxy_pass) direto para este container, no mesmo padrão
# já usado para "frontend"/"backend" (ticketz) e "nextcloud".
# Confirme que este IP está livre em dockernet antes de subir!
ipv4_address: ${ANATOMIA_WEB_IP:-10.15.0.93}
deploy:
resources:
limits:
memory: 256M
# One-off data pipeline: generates the seed GLB models + populates the DB.
# Run explicitly with: docker compose run --rm anatomia-pipeline
anatomia-pipeline:
container_name: anatomia-pipeline
build:
context: ./data-pipeline
profiles: ["tools"]
depends_on:
anatomia-db:
condition: service_healthy
anatomia-api:
condition: service_started
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}
volumes:
- anatomia_models_data:/app/seed/models
networks:
- dockernet
volumes:
anatomia_db_data:
anatomia_models_data:
+13
View File
@@ -0,0 +1,13 @@
# Build context is the repository root (see docker-compose.yml) so this
# stage can reach both frontend/ and nginx/.
FROM node:20-alpine AS build
WORKDIR /app
COPY frontend/package.json ./
RUN npm install
COPY frontend/ .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas 3D de Anatomia</title>
</head>
<body class="bg-ink text-slate-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "anatomia-frontend",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^9.114.3",
"@react-three/fiber": "^8.17.10",
"fuse.js": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"three": "^0.169.0",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.10",
"typescript": "^5.6.2",
"vite": "^5.4.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+17
View File
@@ -0,0 +1,17 @@
import { Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout.js";
import AtlasPage from "./routes/AtlasPage.js";
import PlanesPage from "./routes/PlanesPage.js";
import QuizPage from "./routes/QuizPage.js";
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<AtlasPage />} />
<Route path="/planos-anatomicos" element={<PlanesPage />} />
<Route path="/quiz" element={<QuizPage />} />
</Route>
</Routes>
);
}
@@ -0,0 +1,53 @@
import { useAtlasStore, type ClipAxis } from "../store/useAtlasStore.js";
const AXES: { value: ClipAxis; label: string }[] = [
{ value: "sagittal", label: "Sagital (esq./dir.)" },
{ value: "coronal", label: "Coronal (ant./post.)" },
{ value: "transverse", label: "Transversal (sup./inf.)" },
];
export function ClippingControls() {
const clipping = useAtlasStore((s) => s.clipping);
const setClippingEnabled = useAtlasStore((s) => s.setClippingEnabled);
const setClipAxis = useAtlasStore((s) => s.setClipAxis);
const setClipPosition = useAtlasStore((s) => s.setClipPosition);
return (
<div className="space-y-2 border-t border-white/10 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={clipping.enabled}
onChange={(e) => setClippingEnabled(e.target.checked)}
className="accent-accent"
/>
Dissecção virtual (plano de corte)
</label>
{clipping.enabled && (
<div className="space-y-2 pl-1">
<select
value={clipping.axis}
onChange={(e) => setClipAxis(e.target.value as ClipAxis)}
className="w-full text-sm bg-white/5 border border-white/10 rounded px-2 py-1"
>
{AXES.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
<input
type="range"
min={-1}
max={1}
step={0.01}
value={clipping.position}
onChange={(e) => setClipPosition(Number(e.target.value))}
className="w-full accent-accent"
/>
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useSystems } from "../hooks/useStructures.js";
import { useAtlasStore } from "../store/useAtlasStore.js";
export function LayerToggle() {
const systems = useSystems();
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
const toggleSystem = useAtlasStore((s) => s.toggleSystem);
return (
<div className="space-y-1.5">
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-2">Camadas</h3>
{systems.map((sys) => (
<label
key={sys.slug}
className="flex items-center gap-2 text-sm cursor-pointer select-none rounded px-2 py-1 hover:bg-white/5"
>
<input
type="checkbox"
checked={visibleSystems.has(sys.slug)}
onChange={() => toggleSystem(sys.slug)}
className="accent-accent"
/>
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: sys.colorHex }}
/>
<span>{sys.namePt}</span>
{sys._count && (
<span className="ml-auto text-xs text-slate-500">{sys._count.structures}</span>
)}
</label>
))}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { NavLink, Outlet } from "react-router-dom";
const linkClass = ({ isActive }: { isActive: boolean }) =>
`px-3 py-1.5 rounded text-sm ${isActive ? "bg-accent text-white" : "text-slate-300 hover:bg-white/5"}`;
export function Layout() {
return (
<div className="h-screen flex flex-col">
<header className="flex items-center gap-4 px-4 py-2 border-b border-white/10 bg-panel">
<h1 className="text-sm font-semibold tracking-wide text-white">🫀 Atlas 3D de Anatomia</h1>
<nav className="flex gap-1">
<NavLink to="/" end className={linkClass}>
Atlas
</NavLink>
<NavLink to="/planos-anatomicos" className={linkClass}>
Planos e Eixos
</NavLink>
<NavLink to="/quiz" className={linkClass}>
Quiz
</NavLink>
</nav>
</header>
<main className="flex-1 min-h-0">
<Outlet />
</main>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useMemo, useState } from "react";
import Fuse from "fuse.js";
import type { Structure } from "../lib/api.js";
import { useAtlasStore } from "../store/useAtlasStore.js";
export function SearchNav({ structures }: { structures: Structure[] }) {
const [query, setQuery] = useState("");
const selectStructure = useAtlasStore((s) => s.selectStructure);
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
const fuse = useMemo(
() =>
new Fuse(structures, {
keys: ["namePt", "nameEn", "synonyms"],
threshold: 0.35,
}),
[structures],
);
const results = query.trim() ? fuse.search(query).slice(0, 20).map((r) => r.item) : [];
const tree = useMemo(() => {
const root = new Map<string, Map<string, Structure[]>>();
for (const s of structures) {
const system = s.system.namePt;
const region = s.regionPath[s.regionPath.length - 1] ?? "Geral";
if (!root.has(system)) root.set(system, new Map());
const regionMap = root.get(system)!;
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(s);
}
return root;
}, [structures]);
function goTo(structure: Structure) {
if (!visibleSystems.has(structure.system.slug)) {
setVisibleSystems([...visibleSystems, structure.system.slug]);
}
selectStructure(structure.id);
focusOnStructure(structure.slug);
}
return (
<div className="flex flex-col gap-3 h-full min-h-0">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar estrutura (ex: fêmur, nervo ciático)..."
className="w-full rounded bg-white/5 border border-white/10 px-3 py-2 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
/>
<div className="overflow-y-auto flex-1 min-h-0 pr-1">
{query.trim() ? (
<ul className="space-y-1">
{results.map((s) => (
<li key={s.id}>
<button
onClick={() => goTo(s)}
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
>
{s.namePt}
<span className="text-slate-500 text-xs"> — {s.system.namePt}</span>
</button>
</li>
))}
{results.length === 0 && (
<li className="text-sm text-slate-500 px-2">Nenhuma estrutura encontrada.</li>
)}
</ul>
) : (
<div className="space-y-3">
{[...tree.entries()].map(([system, regions]) => (
<details key={system} open={false} className="text-sm">
<summary className="cursor-pointer text-slate-300 hover:text-white">{system}</summary>
<div className="pl-3 mt-1 space-y-2">
{[...regions.entries()].map(([region, items]) => (
<details key={region}>
<summary className="cursor-pointer text-slate-400 hover:text-white text-xs uppercase tracking-wide">
{region}
</summary>
<ul className="pl-3 mt-1 space-y-0.5">
{items.map((s) => (
<li key={s.id}>
<button
onClick={() => goTo(s)}
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
>
{s.namePt}
</button>
</li>
))}
</ul>
</details>
))}
</div>
</details>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { useAtlasStore } from "../store/useAtlasStore.js";
import type { Structure } from "../lib/api.js";
export function StructureInfoPanel({ bySlug }: { bySlug: Map<string, Structure> }) {
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
const selectStructure = useAtlasStore((s) => s.selectStructure);
const structure = selectedStructureId
? [...bySlug.values()].find((s) => s.id === selectedStructureId)
: null;
if (!structure) {
return (
<div className="text-sm text-slate-400">
Clique em uma estrutura no modelo 3D para ver os detalhes aqui.
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold text-white">{structure.namePt}</h2>
<p className="text-sm text-slate-400 italic">{structure.nameEn}</p>
</div>
<button
onClick={() => selectStructure(null)}
className="text-slate-400 hover:text-white text-sm"
aria-label="Fechar"
>
✕
</button>
</div>
<span
className="inline-block text-xs px-2 py-1 rounded-full"
style={{ backgroundColor: structure.system.colorHex + "33", color: structure.system.colorHex }}
>
{structure.system.namePt}
</span>
{structure.description && <p className="text-sm text-slate-300">{structure.description}</p>}
{structure.regionPath.length > 0 && (
<p className="text-xs text-slate-500">{structure.regionPath.join(" › ")}</p>
)}
{structure.synonyms.length > 0 && (
<p className="text-xs text-slate-500">Sinônimos: {structure.synonyms.join(", ")}</p>
)}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { CameraControls, Grid } from "@react-three/drei";
import * as THREE from "three";
import { useAtlasStore, type ClipAxis } from "../../store/useAtlasStore.js";
import { SystemModel } from "./SystemModel.js";
import type { Structure } from "../../lib/api.js";
const AXIS_CONFIG: Record<ClipAxis, { normal: [number, number, number]; min: number; max: number }> = {
sagittal: { normal: [1, 0, 0], min: -0.4, max: 0.4 },
coronal: { normal: [0, 0, 1], min: -0.3, max: 0.3 },
transverse: { normal: [0, -1, 0], min: -1.8, max: 0 },
};
function useClippingPlanes(): THREE.Plane[] {
const { enabled, axis, position } = useAtlasStore((s) => s.clipping);
return useMemo(() => {
if (!enabled) return [];
const cfg = AXIS_CONFIG[axis];
const t = (position + 1) / 2; // normalize -1..1 -> 0..1
const constant = cfg.min + t * (cfg.max - cfg.min);
const normal = new THREE.Vector3(...cfg.normal);
return [new THREE.Plane(normal, -constant)];
}, [enabled, axis, position]);
}
function FocusController() {
const controlsRef = useRef<CameraControls>(null);
const { scene } = useThree();
const focusRequest = useAtlasStore((s) => s.focusRequest);
useEffect(() => {
if (!focusRequest || !controlsRef.current) return;
const obj = scene.getObjectByName(focusRequest.slug);
if (!obj) return;
const box = new THREE.Box3().setFromObject(obj);
if (box.isEmpty()) return;
controlsRef.current.fitToBox(box, true, {
paddingLeft: 0.4,
paddingRight: 0.4,
paddingTop: 0.4,
paddingBottom: 0.4,
});
}, [focusRequest, scene]);
return (
<CameraControls
ref={controlsRef}
makeDefault
minDistance={0.15}
maxDistance={4}
/>
);
}
export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
const selectStructure = useAtlasStore((s) => s.selectStructure);
const clippingPlanes = useClippingPlanes();
const selectedSlug = selectedStructureId
? [...bySlug.values()].find((s) => s.id === selectedStructureId)?.slug ?? null
: null;
const modelFiles = useMemo(
() => [...visibleSystems].map((slug) => `${slug}.glb`),
[visibleSystems],
);
return (
<Canvas
shadows
camera={{ position: [1.2, 1.4, 1.6], fov: 45, near: 0.01, far: 20 }}
gl={{ localClippingEnabled: true }}
>
<color attach="background" args={["#0b1220"]} />
<ambientLight intensity={0.55} />
<directionalLight position={[2, 3, 2]} intensity={1.1} castShadow />
<directionalLight position={[-2, 1, -2]} intensity={0.4} />
<Suspense fallback={null}>
{modelFiles.map((modelFile) => (
<SystemModel
key={modelFile}
modelFile={modelFile}
selectedSlug={selectedSlug}
clippingPlanes={clippingPlanes}
onSelect={(slug) => {
const structure = bySlug.get(slug);
if (structure) selectStructure(structure.id);
}}
/>
))}
</Suspense>
<Grid
position={[0, 0, 0]}
args={[4, 4]}
cellColor="#1e293b"
sectionColor="#334155"
fadeDistance={6}
infiniteGrid
/>
<FocusController />
</Canvas>
);
}
@@ -0,0 +1,52 @@
import { useEffect, 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";
interface Props {
modelFile: string;
selectedSlug: string | null;
onSelect: (slug: string) => void;
clippingPlanes: THREE.Plane[];
}
const HIGHLIGHT = new THREE.Color("#22d3ee");
export function SystemModel({ modelFile, selectedSlug, onSelect, clippingPlanes }: Props) {
const { scene } = useGLTF(modelUrl(modelFile));
const clonedRef = useRef<THREE.Group>();
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();
}
});
clonedRef.current = clone;
}
const root = clonedRef.current!;
useEffect(() => {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
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);
mat.emissiveIntensity = isSelected ? 0.6 : 0;
});
}, [root, selectedSlug, clippingPlanes]);
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
const name = e.object.name;
if (name) onSelect(name);
};
return <primitive object={root} onClick={handleClick} />;
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useMemo, useState } from "react";
import { api, type AnatomicalSystem, type Structure } from "../lib/api.js";
export function useSystems() {
const [systems, setSystems] = useState<AnatomicalSystem[]>([]);
useEffect(() => {
api.systems().then(setSystems).catch(console.error);
}, []);
return systems;
}
export function useAllStructures() {
const [structures, setStructures] = useState<Structure[]>([]);
useEffect(() => {
api.structures().then(setStructures).catch(console.error);
}, []);
const bySlug = useMemo(() => {
const map = new Map<string, Structure>();
for (const s of structures) map.set(s.slug, s);
return map;
}, [structures]);
return { structures, bySlug };
}
+12
View File
@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
height: 100%;
margin: 0;
}
canvas {
outline: none;
}
+79
View File
@@ -0,0 +1,79 @@
export interface AnatomicalSystem {
id: string;
slug: string;
namePt: string;
nameEn: string;
colorHex: string;
order: number;
_count?: { structures: number };
}
export interface Structure {
id: string;
fmaId: string | null;
slug: string;
namePt: string;
nameEn: string;
synonyms: string[];
description: string | null;
regionPath: string[];
modelFile: string;
meshName: string;
systemId: string;
system: AnatomicalSystem;
}
export interface QuizCard {
id: string;
structureId: string;
easeFactor: number;
intervalDays: number;
repetitions: number;
dueAt: string;
}
const BASE = "/api";
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
return res.json() as Promise<T>;
}
export const api = {
systems: () => getJson<AnatomicalSystem[]>(`${BASE}/systems`),
structures: (params: { system?: string; q?: string } = {}) => {
const usp = new URLSearchParams();
if (params.system) usp.set("system", params.system);
if (params.q) usp.set("q", params.q);
const qs = usp.toString();
return getJson<Structure[]>(`${BASE}/structures${qs ? `?${qs}` : ""}`);
},
structure: (id: string) => getJson<Structure>(`${BASE}/structures/${id}`),
quizNext: (params: { system?: string; limit?: number } = {}) => {
const usp = new URLSearchParams();
if (params.system) usp.set("system", params.system);
if (params.limit) usp.set("limit", String(params.limit));
const qs = usp.toString();
return getJson<(Structure & { quizCard: QuizCard | null })[]>(
`${BASE}/quiz/next${qs ? `?${qs}` : ""}`,
);
},
quizReview: async (structureId: string, quality: number): Promise<QuizCard> => {
const res = await fetch(`${BASE}/quiz/${structureId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quality }),
});
if (!res.ok) throw new Error(`quizReview failed: ${res.status}`);
return res.json();
},
};
export function modelUrl(modelFile: string): string {
return `/models/${modelFile}`;
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+30
View File
@@ -0,0 +1,30 @@
import { useAllStructures } from "../hooks/useStructures.js";
import { Scene } from "../components/viewer/Scene.js";
import { LayerToggle } from "../components/LayerToggle.js";
import { ClippingControls } from "../components/ClippingControls.js";
import { SearchNav } from "../components/SearchNav.js";
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
export default function AtlasPage() {
const { structures, bySlug } = useAllStructures();
return (
<div className="h-full grid grid-cols-[280px_1fr_320px]">
<aside className="border-r border-white/10 bg-panel p-3 flex flex-col min-h-0">
<LayerToggle />
<ClippingControls />
<div className="flex-1 min-h-0 mt-4">
<SearchNav structures={structures} />
</div>
</aside>
<div className="relative">
<Scene bySlug={bySlug} />
</div>
<aside className="border-l border-white/10 bg-panel p-4 overflow-y-auto">
<StructureInfoPanel bySlug={bySlug} />
</aside>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useState } from "react";
import { Canvas } from "@react-three/fiber";
import { CameraControls, Html, Line } from "@react-three/drei";
import * as THREE from "three";
interface PlaneDef {
key: "sagittal" | "coronal" | "transverse";
label: string;
color: string;
args: [number, number];
position: [number, number, number];
rotation: [number, number, number];
}
const PLANES: PlaneDef[] = [
{ key: "sagittal", label: "Plano Sagital", color: "#4f7cff", args: [1.2, 2.2], position: [0, 0.9, 0], rotation: [0, Math.PI / 2, 0] },
{ key: "coronal", label: "Plano Coronal (Frontal)", color: "#22d3ee", args: [1.2, 2.2], position: [0, 0.9, 0], rotation: [0, 0, 0] },
{ key: "transverse", label: "Plano Transversal (Axial)", color: "#f2c14e", args: [1.2, 1.2], position: [0, 0.9, 0], rotation: [Math.PI / 2, 0, 0] },
];
const AXES: { label: string; from: [number, number, number]; to: [number, number, number]; color: string }[] = [
{ label: "Superior", from: [0, 0.9, 0], to: [0, 1.9, 0], color: "#f87171" },
{ label: "Inferior", from: [0, 0.9, 0], to: [0, -0.1, 0], color: "#f87171" },
{ label: "Anterior", from: [0, 0.9, 0], to: [0, 0.9, 0.9], color: "#34d399" },
{ label: "Posterior", from: [0, 0.9, 0], to: [0, 0.9, -0.9], color: "#34d399" },
{ label: "Direita (do paciente)", from: [0, 0.9, 0], to: [-0.7, 0.9, 0], color: "#a78bfa" },
{ label: "Esquerda (do paciente)", from: [0, 0.9, 0], to: [0.7, 0.9, 0], color: "#a78bfa" },
];
const GLOSSARY: { term: string; def: string }[] = [
{ term: "Anterior / Posterior", def: "Em direção à frente (ventral) ou para trás (dorsal) do corpo." },
{ term: "Superior / Inferior", def: "Em direção à cabeça (cranial) ou aos pés (caudal)." },
{ term: "Medial / Lateral", def: "Mais próximo (medial) ou mais distante (lateral) do plano mediano/sagital." },
{ term: "Proximal / Distal", def: "Mais próximo (proximal) ou mais distante (distal) do tronco/raiz do membro." },
{ term: "Superficial / Profundo", def: "Mais próximo (superficial) ou mais distante (profundo) da superfície do corpo." },
{ term: "Plano Sagital", def: "Divide o corpo em partes esquerda e direita (é 'mediano' quando passa exatamente pelo centro)." },
{ term: "Plano Coronal (Frontal)", def: "Divide o corpo em partes anterior e posterior." },
{ term: "Plano Transversal (Axial)", def: "Divide o corpo em partes superior e inferior." },
];
function Mannequin() {
const skin = "#8fa3c8";
return (
<group>
<mesh position={[0, 1.65, 0]}>
<sphereGeometry args={[0.12]} />
<meshStandardMaterial color={skin} />
</mesh>
<mesh position={[0, 1.2, 0]}>
<boxGeometry args={[0.4, 0.55, 0.22]} />
<meshStandardMaterial color={skin} />
</mesh>
<mesh position={[0, 0.75, 0]}>
<boxGeometry args={[0.36, 0.3, 0.2]} />
<meshStandardMaterial color={skin} />
</mesh>
{[1, -1].map((side) => (
<group key={`arm-${side}`}>
<mesh position={[side * 0.28, 1.1, 0]}>
<cylinderGeometry args={[0.05, 0.045, 0.55, 10]} />
<meshStandardMaterial color={skin} />
</mesh>
</group>
))}
{[1, -1].map((side) => (
<group key={`leg-${side}`}>
<mesh position={[side * 0.11, 0.3, 0]}>
<cylinderGeometry args={[0.065, 0.05, 0.65, 10]} />
<meshStandardMaterial color={skin} />
</mesh>
</group>
))}
</group>
);
}
export default function PlanesPage() {
const [visible, setVisible] = useState<Record<PlaneDef["key"], boolean>>({
sagittal: true,
coronal: false,
transverse: false,
});
const [showAxes, setShowAxes] = useState(true);
return (
<div className="h-full grid grid-cols-[280px_1fr_300px]">
<aside className="border-r border-white/10 bg-panel p-4 space-y-3">
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-2">Planos Anatômicos</h3>
{PLANES.map((p) => (
<label key={p.key} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={visible[p.key]}
onChange={() => setVisible((v) => ({ ...v, [p.key]: !v[p.key] }))}
className="accent-accent"
/>
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: p.color }} />
{p.label}
</label>
))}
<label className="flex items-center gap-2 text-sm cursor-pointer pt-2 border-t border-white/10 mt-3">
<input type="checkbox" checked={showAxes} onChange={() => setShowAxes((v) => !v)} className="accent-accent" />
Eixos e termos de direção
</label>
</aside>
<div className="relative">
<Canvas camera={{ position: [1.8, 1.3, 2.2], fov: 45 }}>
<color attach="background" args={["#0b1220"]} />
<ambientLight intensity={0.6} />
<directionalLight position={[2, 3, 2]} intensity={1} />
<Mannequin />
{PLANES.filter((p) => visible[p.key]).map((p) => (
<mesh key={p.key} position={p.position} rotation={p.rotation}>
<planeGeometry args={p.args} />
<meshBasicMaterial color={p.color} transparent opacity={0.18} side={THREE.DoubleSide} />
</mesh>
))}
{showAxes &&
AXES.map((a) => (
<group key={a.label}>
<Line points={[a.from, a.to]} color={a.color} lineWidth={2} />
<Html position={a.to} center distanceFactor={4}>
<div
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap"
style={{ background: a.color, color: "#0b1220" }}
>
{a.label}
</div>
</Html>
</group>
))}
<CameraControls makeDefault />
</Canvas>
</div>
<aside className="border-l border-white/10 bg-panel p-4 overflow-y-auto space-y-3">
<h3 className="text-xs uppercase tracking-wide text-slate-400">Glossário de Direções</h3>
{GLOSSARY.map((g) => (
<div key={g.term}>
<p className="text-sm font-medium text-white">{g.term}</p>
<p className="text-xs text-slate-400">{g.def}</p>
</div>
))}
</aside>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from "react";
import { api, type Structure, type QuizCard } from "../lib/api.js";
import { useAllStructures } from "../hooks/useStructures.js";
import { Scene } from "../components/viewer/Scene.js";
import { LayerToggle } from "../components/LayerToggle.js";
import { useAtlasStore } from "../store/useAtlasStore.js";
type Deck = (Structure & { quizCard: QuizCard | null })[];
export default function QuizPage() {
const { bySlug } = useAllStructures();
const [deck, setDeck] = useState<Deck>([]);
const [index, setIndex] = useState(0);
const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
const selectStructure = useAtlasStore((s) => s.selectStructure);
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
async function loadDeck() {
const next = await api.quizNext({ limit: 10 });
setDeck(next);
setIndex(0);
setScore({ correct: 0, total: 0 });
selectStructure(null);
if (next.length > 0) {
setVisibleSystems([...new Set(next.map((s) => s.system.slug))]);
}
}
useEffect(() => {
loadDeck();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const current = deck[index];
const ignoreNextSelectRef = useRef(false);
useEffect(() => {
if (!current || !selectedStructureId || feedback) return;
if (ignoreNextSelectRef.current) {
ignoreNextSelectRef.current = false;
return;
}
const isCorrect = selectedStructureId === current.id;
setFeedback(isCorrect ? "correct" : "wrong");
setScore((s) => ({ correct: s.correct + (isCorrect ? 1 : 0), total: s.total + 1 }));
api.quizReview(current.id, isCorrect ? 5 : 2).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedStructureId]);
function next() {
selectStructure(null);
setFeedback(null);
if (index + 1 < deck.length) setIndex(index + 1);
else loadDeck();
}
function giveUp() {
if (!current) return;
setFeedback("wrong");
setScore((s) => ({ ...s, total: s.total + 1 }));
api.quizReview(current.id, 2).catch(console.error);
ignoreNextSelectRef.current = true;
focusOnStructure(current.slug);
selectStructure(current.id);
}
return (
<div className="h-full grid grid-cols-[280px_1fr]">
<aside className="border-r border-white/10 bg-panel p-3 flex flex-col gap-4 min-h-0">
<LayerToggle />
</aside>
<div className="relative">
<Scene bySlug={bySlug} />
<div className="absolute top-3 left-1/2 -translate-x-1/2 bg-panel/95 border border-white/10 rounded-lg px-5 py-3 text-center shadow-lg max-w-md">
{!current ? (
<p className="text-sm text-slate-300">
Nenhuma estrutura pendente de revisão agora. 🎉
</p>
) : (
<>
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">
Onde fica esta estrutura?
</p>
<p className="text-lg font-semibold text-white">{current.namePt}</p>
<p className="text-xs text-slate-500 mb-2">{current.system.namePt}</p>
{feedback === "correct" && (
<p className="text-sm text-emerald-400 mb-2">Correto! ✅</p>
)}
{feedback === "wrong" && (
<p className="text-sm text-rose-400 mb-2">Não foi essa estrutura.</p>
)}
<div className="flex gap-2 justify-center mt-2">
{feedback ? (
<button
onClick={next}
className="text-sm px-3 py-1 rounded bg-accent text-white"
>
Próxima
</button>
) : (
<button
onClick={giveUp}
className="text-sm px-3 py-1 rounded bg-white/10 text-slate-200"
>
Mostrar resposta
</button>
)}
</div>
<p className="text-xs text-slate-500 mt-2">
{index + 1} / {deck.length} · acertos: {score.correct}/{score.total}
</p>
</>
)}
</div>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { create } from "zustand";
export type ClipAxis = "sagittal" | "coronal" | "transverse";
interface AtlasState {
visibleSystems: Set<string>;
toggleSystem: (slug: string) => void;
setVisibleSystems: (slugs: string[]) => void;
selectedStructureId: string | null;
selectStructure: (id: string | null) => void;
focusRequest: { slug: string; nonce: number } | null;
focusOnStructure: (slug: string) => void;
clipping: { enabled: boolean; axis: ClipAxis; position: number };
setClippingEnabled: (enabled: boolean) => void;
setClipAxis: (axis: ClipAxis) => void;
setClipPosition: (position: number) => void;
}
export const useAtlasStore = create<AtlasState>((set) => ({
visibleSystems: new Set(["esqueletico"]),
toggleSystem: (slug) =>
set((state) => {
const next = new Set(state.visibleSystems);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
return { visibleSystems: next };
}),
setVisibleSystems: (slugs) => set({ visibleSystems: new Set(slugs) }),
selectedStructureId: null,
selectStructure: (id) => set({ selectedStructureId: id }),
focusRequest: null,
focusOnStructure: (slug) =>
set((state) => ({ focusRequest: { slug, nonce: (state.focusRequest?.nonce ?? 0) + 1 } })),
clipping: { enabled: false, axis: "sagittal", position: 0 },
setClippingEnabled: (enabled) => set((state) => ({ clipping: { ...state.clipping, enabled } })),
setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })),
setClipPosition: (position) => set((state) => ({ clipping: { ...state.clipping, position } })),
}));
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
ink: "#0b1220",
panel: "#121a2b",
accent: "#4f7cff",
},
},
},
plugins: [],
};
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
"/api": "http://localhost:3333",
"/models": "http://localhost:3333",
},
},
});
+50
View File
@@ -0,0 +1,50 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/javascript application/json application/wasm model/gltf-binary;
# Respect a proxy in front of this container (the user's own reverse proxy).
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
location /api/ {
proxy_pass http://anatomia-api:3333/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /models/ {
proxy_pass http://anatomia-api:3333/models/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "public, max-age=604800, immutable";
}
# SPA fallback.
location / {
try_files $uri $uri/ /index.html;
}
}
}