From 0e854110d38bb83ec75985c0fca1333a0da42082 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Mon, 7 Sep 2026 20:46:08 -0300 Subject: [PATCH] first commit --- .env.example | 12 ++ .gitignore | 8 + README.md | 103 +++++++++++ backend/Dockerfile | 22 +++ backend/docker-entrypoint.sh | 8 + backend/package.json | 28 +++ .../20250101000000_init/migration.sql | 64 +++++++ backend/prisma/migrations/migration_lock.toml | 1 + backend/prisma/schema.prisma | 54 ++++++ backend/src/lib/prisma.ts | 3 + backend/src/lib/sm2.ts | 28 +++ backend/src/routes/quiz.ts | 74 ++++++++ backend/src/routes/structures.ts | 39 +++++ backend/src/routes/systems.ts | 11 ++ backend/src/server.ts | 37 ++++ backend/tsconfig.json | 18 ++ data-pipeline/Dockerfile | 6 + data-pipeline/package.json | 15 ++ data-pipeline/scripts/build-seed-models.mjs | 97 ++++++++++ data-pipeline/scripts/geometry.mjs | 130 ++++++++++++++ data-pipeline/scripts/seed-db.mjs | 98 +++++++++++ data-pipeline/seed/build-manifest.mjs | 165 ++++++++++++++++++ deploy/nginx-vhost-atlas.conf | 81 +++++++++ docker-compose.yml | 98 +++++++++++ frontend/Dockerfile | 13 ++ frontend/index.html | 12 ++ frontend/package.json | 32 ++++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 17 ++ frontend/src/components/ClippingControls.tsx | 53 ++++++ frontend/src/components/LayerToggle.tsx | 35 ++++ frontend/src/components/Layout.tsx | 28 +++ frontend/src/components/SearchNav.tsx | 105 +++++++++++ .../src/components/StructureInfoPanel.tsx | 54 ++++++ frontend/src/components/viewer/Scene.tsx | 106 +++++++++++ .../src/components/viewer/SystemModel.tsx | 52 ++++++ frontend/src/hooks/useStructures.ts | 25 +++ frontend/src/index.css | 12 ++ frontend/src/lib/api.ts | 79 +++++++++ frontend/src/main.tsx | 13 ++ frontend/src/routes/AtlasPage.tsx | 30 ++++ frontend/src/routes/PlanesPage.tsx | 153 ++++++++++++++++ frontend/src/routes/QuizPage.tsx | 127 ++++++++++++++ frontend/src/store/useAtlasStore.ts | 44 +++++ frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 15 ++ frontend/tsconfig.json | 16 ++ frontend/vite.config.ts | 14 ++ nginx/nginx.conf | 50 ++++++ 49 files changed, 2292 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/docker-entrypoint.sh create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20250101000000_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/lib/prisma.ts create mode 100644 backend/src/lib/sm2.ts create mode 100644 backend/src/routes/quiz.ts create mode 100644 backend/src/routes/structures.ts create mode 100644 backend/src/routes/systems.ts create mode 100644 backend/src/server.ts create mode 100644 backend/tsconfig.json create mode 100644 data-pipeline/Dockerfile create mode 100644 data-pipeline/package.json create mode 100644 data-pipeline/scripts/build-seed-models.mjs create mode 100644 data-pipeline/scripts/geometry.mjs create mode 100644 data-pipeline/scripts/seed-db.mjs create mode 100644 data-pipeline/seed/build-manifest.mjs create mode 100644 deploy/nginx-vhost-atlas.conf create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ClippingControls.tsx create mode 100644 frontend/src/components/LayerToggle.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/SearchNav.tsx create mode 100644 frontend/src/components/StructureInfoPanel.tsx create mode 100644 frontend/src/components/viewer/Scene.tsx create mode 100644 frontend/src/components/viewer/SystemModel.tsx create mode 100644 frontend/src/hooks/useStructures.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/routes/AtlasPage.tsx create mode 100644 frontend/src/routes/PlanesPage.tsx create mode 100644 frontend/src/routes/QuizPage.tsx create mode 100644 frontend/src/store/useAtlasStore.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d888b7f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f56ac5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +build/ +.env +*.log +.DS_Store +data-pipeline/seed/manifest.json +data-pipeline/seed/models/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..129a973 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b77ea83 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..15a615d --- /dev/null +++ b/backend/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 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c81a34a --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/migrations/20250101000000_init/migration.sql b/backend/prisma/migrations/20250101000000_init/migration.sql new file mode 100644 index 0000000..67f0d46 --- /dev/null +++ b/backend/prisma/migrations/20250101000000_init/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..fae86d9 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/backend/src/lib/sm2.ts b/backend/src/lib/sm2.ts new file mode 100644 index 0000000..ffa5997 --- /dev/null +++ b/backend/src/lib/sm2.ts @@ -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 }; +} diff --git a/backend/src/routes/quiz.ts b/backend/src/routes/quiz.ts new file mode 100644 index 0000000..4b26a7b --- /dev/null +++ b/backend/src/routes/quiz.ts @@ -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); + }, + ); +} diff --git a/backend/src/routes/structures.ts b/backend/src/routes/structures.ts new file mode 100644 index 0000000..975921d --- /dev/null +++ b/backend/src/routes/structures.ts @@ -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; + }); +} diff --git a/backend/src/routes/systems.ts b/backend/src/routes/systems.ts new file mode 100644 index 0000000..f1e72ec --- /dev/null +++ b/backend/src/routes/systems.ts @@ -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 } } }, + }); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..466ca51 --- /dev/null +++ b/backend/src/server.ts @@ -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); + }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..cb9f9c7 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/data-pipeline/Dockerfile b/data-pipeline/Dockerfile new file mode 100644 index 0000000..06020e9 --- /dev/null +++ b/data-pipeline/Dockerfile @@ -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"] diff --git a/data-pipeline/package.json b/data-pipeline/package.json new file mode 100644 index 0000000..d2358b5 --- /dev/null +++ b/data-pipeline/package.json @@ -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" + } +} diff --git a/data-pipeline/scripts/build-seed-models.mjs b/data-pipeline/scripts/build-seed-models.mjs new file mode 100644 index 0000000..793aa55 --- /dev/null +++ b/data-pipeline/scripts/build-seed-models.mjs @@ -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)`); +} diff --git a/data-pipeline/scripts/geometry.mjs b/data-pipeline/scripts/geometry.mjs new file mode 100644 index 0000000..c6a46e5 --- /dev/null +++ b/data-pipeline/scripts/geometry.mjs @@ -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}`); + } +} diff --git a/data-pipeline/scripts/seed-db.mjs b/data-pipeline/scripts/seed-db.mjs new file mode 100644 index 0000000..768b276 --- /dev/null +++ b/data-pipeline/scripts/seed-db.mjs @@ -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(); +} diff --git a/data-pipeline/seed/build-manifest.mjs b/data-pipeline/seed/build-manifest.mjs new file mode 100644 index 0000000..45f9085 --- /dev/null +++ b/data-pipeline/seed/build-manifest.mjs @@ -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 ` (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.`); diff --git a/deploy/nginx-vhost-atlas.conf b/deploy/nginx-vhost-atlas.conf new file mode 100644 index 0000000..3ed10e3 --- /dev/null +++ b/deploy/nginx-vhost-atlas.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3ec953 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..34e2200 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9fe90ff --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Atlas 3D de Anatomia + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..394a634 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..4f1ad2b --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + }> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/components/ClippingControls.tsx b/frontend/src/components/ClippingControls.tsx new file mode 100644 index 0000000..2e65166 --- /dev/null +++ b/frontend/src/components/ClippingControls.tsx @@ -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 ( +
+ + + {clipping.enabled && ( +
+ + setClipPosition(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ )} +
+ ); +} diff --git a/frontend/src/components/LayerToggle.tsx b/frontend/src/components/LayerToggle.tsx new file mode 100644 index 0000000..5da9cbe --- /dev/null +++ b/frontend/src/components/LayerToggle.tsx @@ -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 ( +
+

Camadas

+ {systems.map((sys) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..52c442d --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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 ( +
+
+

🫀 Atlas 3D de Anatomia

+ +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/SearchNav.tsx b/frontend/src/components/SearchNav.tsx new file mode 100644 index 0000000..16561c8 --- /dev/null +++ b/frontend/src/components/SearchNav.tsx @@ -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>(); + 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 ( +
+ 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" + /> + +
+ {query.trim() ? ( +
    + {results.map((s) => ( +
  • + +
  • + ))} + {results.length === 0 && ( +
  • Nenhuma estrutura encontrada.
  • + )} +
+ ) : ( +
+ {[...tree.entries()].map(([system, regions]) => ( +
+ {system} +
+ {[...regions.entries()].map(([region, items]) => ( +
+ + {region} + +
    + {items.map((s) => ( +
  • + +
  • + ))} +
+
+ ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/StructureInfoPanel.tsx b/frontend/src/components/StructureInfoPanel.tsx new file mode 100644 index 0000000..a8bf760 --- /dev/null +++ b/frontend/src/components/StructureInfoPanel.tsx @@ -0,0 +1,54 @@ +import { useAtlasStore } from "../store/useAtlasStore.js"; +import type { Structure } from "../lib/api.js"; + +export function StructureInfoPanel({ bySlug }: { bySlug: Map }) { + 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 ( +
+ Clique em uma estrutura no modelo 3D para ver os detalhes aqui. +
+ ); + } + + return ( +
+
+
+

{structure.namePt}

+

{structure.nameEn}

+
+ +
+ + + {structure.system.namePt} + + + {structure.description &&

{structure.description}

} + + {structure.regionPath.length > 0 && ( +

{structure.regionPath.join(" › ")}

+ )} + + {structure.synonyms.length > 0 && ( +

Sinônimos: {structure.synonyms.join(", ")}

+ )} +
+ ); +} diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx new file mode 100644 index 0000000..c901de8 --- /dev/null +++ b/frontend/src/components/viewer/Scene.tsx @@ -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 = { + 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(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 ( + + ); +} + +export function Scene({ bySlug }: { bySlug: Map }) { + 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 ( + + + + + + + {modelFiles.map((modelFile) => ( + { + const structure = bySlug.get(slug); + if (structure) selectStructure(structure.id); + }} + /> + ))} + + + + + ); +} diff --git a/frontend/src/components/viewer/SystemModel.tsx b/frontend/src/components/viewer/SystemModel.tsx new file mode 100644 index 0000000..dd4f8ad --- /dev/null +++ b/frontend/src/components/viewer/SystemModel.tsx @@ -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(); + + 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) => { + e.stopPropagation(); + const name = e.object.name; + if (name) onSelect(name); + }; + + return ; +} diff --git a/frontend/src/hooks/useStructures.ts b/frontend/src/hooks/useStructures.ts new file mode 100644 index 0000000..8ea2a2b --- /dev/null +++ b/frontend/src/hooks/useStructures.ts @@ -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([]); + useEffect(() => { + api.systems().then(setSystems).catch(console.error); + }, []); + return systems; +} + +export function useAllStructures() { + const [structures, setStructures] = useState([]); + useEffect(() => { + api.structures().then(setStructures).catch(console.error); + }, []); + + const bySlug = useMemo(() => { + const map = new Map(); + for (const s of structures) map.set(s.slug, s); + return map; + }, [structures]); + + return { structures, bySlug }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..957a971 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body, #root { + height: 100%; + margin: 0; +} + +canvas { + outline: none; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..2bf3285 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`); + return res.json() as Promise; +} + +export const api = { + systems: () => getJson(`${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(`${BASE}/structures${qs ? `?${qs}` : ""}`); + }, + + structure: (id: string) => getJson(`${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 => { + 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}`; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2d5e640 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + , +); diff --git a/frontend/src/routes/AtlasPage.tsx b/frontend/src/routes/AtlasPage.tsx new file mode 100644 index 0000000..e067255 --- /dev/null +++ b/frontend/src/routes/AtlasPage.tsx @@ -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 ( +
+ + +
+ +
+ + +
+ ); +} diff --git a/frontend/src/routes/PlanesPage.tsx b/frontend/src/routes/PlanesPage.tsx new file mode 100644 index 0000000..dfb2c43 --- /dev/null +++ b/frontend/src/routes/PlanesPage.tsx @@ -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 ( + + + + + + + + + + + + + + {[1, -1].map((side) => ( + + + + + + + ))} + {[1, -1].map((side) => ( + + + + + + + ))} + + ); +} + +export default function PlanesPage() { + const [visible, setVisible] = useState>({ + sagittal: true, + coronal: false, + transverse: false, + }); + const [showAxes, setShowAxes] = useState(true); + + return ( +
+ + +
+ + + + + + + + {PLANES.filter((p) => visible[p.key]).map((p) => ( + + + + + ))} + + {showAxes && + AXES.map((a) => ( + + + +
+ {a.label} +
+ +
+ ))} + + +
+
+ + +
+ ); +} diff --git a/frontend/src/routes/QuizPage.tsx b/frontend/src/routes/QuizPage.tsx new file mode 100644 index 0000000..b5845cd --- /dev/null +++ b/frontend/src/routes/QuizPage.tsx @@ -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([]); + 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 ( +
+ + +
+ + +
+ {!current ? ( +

+ Nenhuma estrutura pendente de revisão agora. 🎉 +

+ ) : ( + <> +

+ Onde fica esta estrutura? +

+

{current.namePt}

+

{current.system.namePt}

+ + {feedback === "correct" && ( +

Correto! ✅

+ )} + {feedback === "wrong" && ( +

Não foi essa estrutura.

+ )} + +
+ {feedback ? ( + + ) : ( + + )} +
+ +

+ {index + 1} / {deck.length} · acertos: {score.correct}/{score.total} +

+ + )} +
+
+
+ ); +} diff --git a/frontend/src/store/useAtlasStore.ts b/frontend/src/store/useAtlasStore.ts new file mode 100644 index 0000000..2e65a3b --- /dev/null +++ b/frontend/src/store/useAtlasStore.ts @@ -0,0 +1,44 @@ +import { create } from "zustand"; + +export type ClipAxis = "sagittal" | "coronal" | "transverse"; + +interface AtlasState { + visibleSystems: Set; + 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((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 } })), +})); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..85ae971 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..75411a3 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6c32f8a --- /dev/null +++ b/frontend/vite.config.ts @@ -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", + }, + }, +}); diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c99afba --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +}