diff --git a/frontend/src/components/viewer/Mannequin.tsx b/frontend/src/components/viewer/Mannequin.tsx index 3a42fc1..fdcd65b 100644 --- a/frontend/src/components/viewer/Mannequin.tsx +++ b/frontend/src/components/viewer/Mannequin.tsx @@ -68,6 +68,12 @@ export function Mannequin() { box.getCenter(center); const group = new THREE.Group(); + // Nome fixo pra PlanesPage.tsx conseguir achar exatamente este objeto + // via `scene.getObjectByName(...)` e calcular a caixa delimitadora só + // dele — sem isso, o bounding box da cena inteira incluiria os planos + // de corte e as setas decorativas, estragando o enquadramento por + // região (Cabeça, Tórax...). + group.name = "mannequin-body"; group.add(clone); // Centraliza em X/Z e põe o pé no chão (y=0) ANTES de escalar, depois // aplica a escala no grupo pai — assim o pivô da escala é a origem já diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx index 0ab2d73..55617e5 100644 --- a/frontend/src/components/viewer/Scene.tsx +++ b/frontend/src/components/viewer/Scene.tsx @@ -2,9 +2,10 @@ import { Suspense, useEffect, useMemo, useRef, type ReactNode } from "react"; import { Canvas, useThree } from "@react-three/fiber"; import { CameraControls, Grid } from "@react-three/drei"; import * as THREE from "three"; -import { useAtlasStore, type BodyRegion, type ClipAxis } from "../../store/useAtlasStore.js"; +import { useAtlasStore, type ClipAxis } from "../../store/useAtlasStore.js"; import { SystemModel } from "./SystemModel.js"; import type { Structure } from "../../lib/api.js"; +import { computeVisibleBoundingBox, regionSubBox } from "../../lib/bodyRegionBox.js"; // Sistemas sem .glb pra carregar — tegumentar/histologia são // fotomicrografias 2D reais com hotspots (ver SkinDiagram.tsx e @@ -56,53 +57,6 @@ function useClippingPlanes(): THREE.Plane[] { }, [enabled, axis, position, tiltX, tiltZ, contentBox]); } -function isFiniteVec3(v: THREE.Vector3): boolean { - return Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); -} - -// Une o bounding box só das malhas VISÍVEIS e com geometria válida — -// alguns objetos do dataset real (anotações/curvas do Z-Anatomy) podem ter -// vértices degenerados/NaN, e Box3.setFromObject(scene) ingenuamente -// incluiria isso, distorcendo o enquadramento (ou escondendo tudo o mais -// fora do "zoom" resultante). -function computeVisibleBoundingBox(root: THREE.Object3D): THREE.Box3 { - const union = new THREE.Box3(); - const tmp = new THREE.Box3(); - root.traverse((obj) => { - const mesh = obj as THREE.Mesh; - if (!mesh.isMesh || !mesh.visible) return; - tmp.setFromObject(mesh); - if (tmp.isEmpty()) return; - if (!isFiniteVec3(tmp.min) || !isFiniteVec3(tmp.max)) return; - union.union(tmp); - }); - return union; -} - -// Faixas aproximadas de altura (0 = pés, 1 = topo da cabeça) usadas pela -// navegação rápida por região — proporções corporais padrão de um adulto -// em pé. Regiões dos membros usam a largura toda (os braços ficam nas -// bordas laterais dessa faixa de altura). -const BODY_REGION_Y_RANGES: Record, [number, number]> = { - cabeca: [0.85, 1.0], - pescoco: [0.8, 0.87], - torax: [0.62, 0.83], - abdome: [0.52, 0.65], - pelve: [0.42, 0.55], - membros_superiores: [0.55, 0.88], - membros_inferiores: [0.0, 0.45], -}; - -function regionSubBox(total: THREE.Box3, region: BodyRegion): THREE.Box3 { - if (region === "corpo_inteiro") return total; - const [t0, t1] = BODY_REGION_Y_RANGES[region]; - const yMin = THREE.MathUtils.lerp(total.min.y, total.max.y, t0); - const yMax = THREE.MathUtils.lerp(total.min.y, total.max.y, t1); - return new THREE.Box3( - new THREE.Vector3(total.min.x, yMin, total.min.z), - new THREE.Vector3(total.max.x, yMax, total.max.z), - ); -} function CameraController({ modelFiles }: { modelFiles: string[] }) { const controlsRef = useRef(null); diff --git a/frontend/src/lib/bodyRegionBox.ts b/frontend/src/lib/bodyRegionBox.ts new file mode 100644 index 0000000..5b1158e --- /dev/null +++ b/frontend/src/lib/bodyRegionBox.ts @@ -0,0 +1,55 @@ +import * as THREE from "three"; +import type { BodyRegion } from "../store/useAtlasStore.js"; + +// Extraído de Scene.tsx pra ser reaproveitado também em PlanesPage.tsx — +// mesma lógica de enquadrar a câmera numa região do corpo (Cabeça, +// Pescoço, Tórax...), agora usada tanto no Atlas/Exame Físico (malha real +// do Z-Anatomy) quanto em Planos e Eixos (o boneco de Regiões Anatômicas). + +function isFiniteVec3(v: THREE.Vector3): boolean { + return Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); +} + +// Une o bounding box só das malhas VISÍVEIS e com geometria válida — +// alguns objetos do dataset real (anotações/curvas do Z-Anatomy) podem ter +// vértices degenerados/NaN, e Box3.setFromObject(scene) ingenuamente +// incluiria isso, distorcendo o enquadramento (ou escondendo tudo o mais +// fora do "zoom" resultante). +export function computeVisibleBoundingBox(root: THREE.Object3D): THREE.Box3 { + const union = new THREE.Box3(); + const tmp = new THREE.Box3(); + root.traverse((obj) => { + const mesh = obj as THREE.Mesh; + if (!mesh.isMesh || !mesh.visible) return; + tmp.setFromObject(mesh); + if (tmp.isEmpty()) return; + if (!isFiniteVec3(tmp.min) || !isFiniteVec3(tmp.max)) return; + union.union(tmp); + }); + return union; +} + +// Faixas aproximadas de altura (0 = pés, 1 = topo da cabeça) usadas pela +// navegação rápida por região — proporções corporais padrão de um adulto +// em pé. Regiões dos membros usam a largura toda (os braços ficam nas +// bordas laterais dessa faixa de altura). +export const BODY_REGION_Y_RANGES: Record, [number, number]> = { + cabeca: [0.85, 1.0], + pescoco: [0.8, 0.87], + torax: [0.62, 0.83], + abdome: [0.52, 0.65], + pelve: [0.42, 0.55], + membros_superiores: [0.55, 0.88], + membros_inferiores: [0.0, 0.45], +}; + +export function regionSubBox(total: THREE.Box3, region: BodyRegion): THREE.Box3 { + if (region === "corpo_inteiro") return total; + const [t0, t1] = BODY_REGION_Y_RANGES[region]; + const yMin = THREE.MathUtils.lerp(total.min.y, total.max.y, t0); + const yMax = THREE.MathUtils.lerp(total.min.y, total.max.y, t1); + return new THREE.Box3( + new THREE.Vector3(total.min.x, yMin, total.min.z), + new THREE.Vector3(total.max.x, yMax, total.max.z), + ); +} diff --git a/frontend/src/routes/PlanesPage.tsx b/frontend/src/routes/PlanesPage.tsx index e706437..68b9e6d 100644 --- a/frontend/src/routes/PlanesPage.tsx +++ b/frontend/src/routes/PlanesPage.tsx @@ -1,8 +1,11 @@ -import { useState } from "react"; -import { Canvas } from "@react-three/fiber"; +import { useEffect, useRef, useState } from "react"; +import { Canvas, useThree } from "@react-three/fiber"; import { CameraControls, Html, Line } from "@react-three/drei"; import * as THREE from "three"; import { Mannequin } from "../components/viewer/Mannequin.js"; +import { QuickRegionNav } from "../components/QuickRegionNav.js"; +import { useAtlasStore } from "../store/useAtlasStore.js"; +import { computeVisibleBoundingBox, regionSubBox } from "../lib/bodyRegionBox.js"; interface PlaneDef { key: "sagittal" | "coronal" | "transverse"; @@ -135,6 +138,38 @@ const GLOSSARY_GROUPS: GlossaryGroup[] = [ }, ]; +// Mesma navegação rápida por região do Atlas/Exame Físico (QuickRegionNav +// já existente, botões "Cabeça", "Tórax" etc.) — aqui aplicada só ao +// boneco (procura o objeto pelo nome fixo `mannequin-body`, ver +// Mannequin.tsx, pra não misturar no bounding box os planos de corte e as +// setas decorativas da cena). +function RegionCameraController() { + const controlsRef = useRef(null); + const { scene } = useThree(); + const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest); + const seenNonce = useRef(regionFocusRequest?.nonce); + + useEffect(() => { + if (!regionFocusRequest || !controlsRef.current) return; + if (regionFocusRequest.nonce === seenNonce.current) return; + seenNonce.current = regionFocusRequest.nonce; + const body = scene.getObjectByName("mannequin-body"); + if (!body) return; + const total = computeVisibleBoundingBox(body); + if (total.isEmpty()) return; + const box = regionSubBox(total, regionFocusRequest.region); + if (box.isEmpty()) return; + controlsRef.current.fitToBox(box, true, { + paddingLeft: 0.3, + paddingRight: 0.3, + paddingTop: 0.3, + paddingBottom: 0.3, + }); + }, [regionFocusRequest, scene]); + + return ; +} + export default function PlanesPage() { const [visible, setVisible] = useState>({ sagittal: true, @@ -259,8 +294,9 @@ export default function PlanesPage() { )), )} - + +