diff --git a/backend/src/server.ts b/backend/src/server.ts
index e2607ac..52937ea 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -20,12 +20,17 @@ const app = Fastify({ logger: true, trustProxy: true });
await app.register(cors, { origin: true });
+// Cache-Control é decidido no nginx interno (ver nginx/nginx.conf,
+// location /models/), não aqui — "immutable"/maxAge nesse nível conflitava
+// com o "no-cache" que o nginx adiciona por cima (dois headers
+// Cache-Control na mesma resposta, comportamento indefinido entre
+// navegadores), e ia contra o motivo do "no-cache": os .glb têm nome fixo
+// mas o CONTEÚDO muda toda vez que o pipeline roda de novo.
await app.register(fastifyStatic, {
root: MODELS_DIR,
prefix: "/models/",
decorateReply: false,
- immutable: true,
- maxAge: "7d",
+ etag: true,
});
app.get("/api/health", async () => ({ status: "ok" }));
diff --git a/data-pipeline/scripts/extract-manifest.mjs b/data-pipeline/scripts/extract-manifest.mjs
index 9f739f2..4f42fba 100644
--- a/data-pipeline/scripts/extract-manifest.mjs
+++ b/data-pipeline/scripts/extract-manifest.mjs
@@ -6,8 +6,8 @@
// - data-pipeline/seed/manifest-real.json (entradas para o seed do banco)
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
-import { prune, dedup, weld, simplify } from "@gltf-transform/functions";
-import { MeshoptSimplifier } from "meshoptimizer";
+import { prune, dedup, weld, simplify, meshopt } from "@gltf-transform/functions";
+import { MeshoptSimplifier, MeshoptEncoder } from "meshoptimizer";
import { writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -21,6 +21,7 @@ import { MUSCLE_DESCRIPTIONS } from "./muscle-descriptions.mjs";
import { NERVE_DESCRIPTIONS } from "./nerve-descriptions.mjs";
await MeshoptSimplifier.ready;
+await MeshoptEncoder.ready;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONVERTED_DIR = path.resolve(__dirname, "../converted");
@@ -183,7 +184,12 @@ function classifySystem(key, rawName) {
return RESPIRATORY_KEYWORDS.some((kw) => lower.includes(kw)) ? "respiratorio" : "visceral";
}
-const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
+// registerDependencies com a chave "meshopt.encoder" é o que permite ao
+// io.write() de fato aplicar a compressão EXT_meshopt_compression pedida
+// no transform() abaixo — sem isso, ela seria ignorada silenciosamente.
+const io = new NodeIO()
+ .registerExtensions(ALL_EXTENSIONS)
+ .registerDependencies({ "meshopt.encoder": MeshoptEncoder });
const manifest = [];
let totalStructures = 0;
@@ -349,6 +355,12 @@ for (const { fbx, key } of SOURCES) {
simplify({ simplifier: MeshoptSimplifier, ratio: 0.5, error: 0.01 }),
prune(),
dedup(),
+ // Comprime os atributos de vértice (posição/normal/UV) com o mesmo
+ // codec Meshopt já usado pra simplificação — reduz bastante o tamanho
+ // do .glb final (os floats crus de posição/normal dominam o arquivo),
+ // e o carregamento no navegador não precisa de nada extra: o useGLTF
+ // do drei já vem com o decoder Meshopt ligado por padrão.
+ meshopt({ encoder: MeshoptEncoder, level: "medium" }),
);
const outPath = path.join(MODELS_OUT_DIR, target.modelFile);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 14cd830..e25ec82 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,22 +1,30 @@
+import { lazy, Suspense } from "react";
import { Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout.js";
+
+// A página inicial (Atlas) já carrega o peso pesado de verdade (three.js +
+// r3f + drei) — separar as OUTRAS rotas em chunks à parte só evita que
+// quem abre o atlas baixe/parseie de graça o código do Quiz, de Planos e
+// do painel interno de estatísticas antes mesmo de ver o modelo 3D.
import AtlasPage from "./routes/AtlasPage.js";
-import PlanesPage from "./routes/PlanesPage.js";
-import QuizPage from "./routes/QuizPage.js";
-import PhysicalExamPage from "./routes/PhysicalExamPage.js";
-import AdminStatsPage from "./routes/AdminStatsPage.js";
+const PlanesPage = lazy(() => import("./routes/PlanesPage.js"));
+const PhysicalExamPage = lazy(() => import("./routes/PhysicalExamPage.js"));
+const QuizPage = lazy(() => import("./routes/QuizPage.js"));
+const AdminStatsPage = lazy(() => import("./routes/AdminStatsPage.js"));
export default function App() {
return (
-
- }>
- } />
- } />
- } />
- } />
-
- {/* Fora do Layout: painel interno, sem o menu/nav do atlas. */}
- } />
-
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+
+ {/* Fora do Layout: painel interno, sem o menu/nav do atlas. */}
+ } />
+
+
);
}
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 2a7bcc2..0a66875 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -11,6 +11,13 @@ http {
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/javascript application/json application/wasm model/gltf-binary;
+ # Sem isso, o default do nginx (gzip_proxied off) desliga a compressão
+ # justamente nas respostas que passam por proxy_pass (/api/ e /models/,
+ # que são as maiores — JSON de estruturas e os .glb) — só os arquivos
+ # estáticos servidos direto por este nginx (JS/CSS do build) estavam
+ # sendo comprimidos até agora.
+ gzip_proxied any;
+ gzip_vary on;
# Respect a proxy in front of this container (the user's own reverse proxy).
set_real_ip_from 0.0.0.0/0;
@@ -72,6 +79,14 @@ http {
add_header Cache-Control "no-cache";
}
+ # Bundle do Vite (JS/CSS/fonts) tem hash de conteúdo no nome do arquivo
+ # — ao contrário dos .glb, um arquivo com esse nome NUNCA muda de
+ # conteúdo (um build novo gera nomes novos), então pode cachear "pra
+ # sempre" sem risco de servir algo desatualizado.
+ location /assets/ {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
# SPA fallback.
location / {
try_files $uri $uri/ /index.html;