first commit
This commit is contained in:
commit
0e854110d3
49 files changed
+2292
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data-pipeline/seed/manifest.json
|
||||||
|
data-pipeline/seed/models/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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)`);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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.`);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 } })),
|
||||||
|
}));
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user