From 76ac1df11628ef3a7e9c4ca6fe434bd01898dd02 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Fri, 23 Oct 2026 12:44:33 -0300 Subject: [PATCH] fix camera --- frontend/src/components/QuickRegionNav.tsx | 18 ++++++++ frontend/src/components/viewer/Scene.tsx | 49 +++++++++++++++++++++- frontend/src/store/useAtlasStore.ts | 11 +++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/QuickRegionNav.tsx b/frontend/src/components/QuickRegionNav.tsx index 1f4a934..ad8ca7a 100644 --- a/frontend/src/components/QuickRegionNav.tsx +++ b/frontend/src/components/QuickRegionNav.tsx @@ -30,6 +30,7 @@ function loadStoredPos(): { x: number; y: number } | null { // queira ver — a posição escolhida fica salva entre visitas. export function QuickRegionNav() { const focusOnRegion = useAtlasStore((s) => s.focusOnRegion); + const focusOnViewAngle = useAtlasStore((s) => s.focusOnViewAngle); const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadStoredPos()); const elRef = useRef(null); const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); @@ -127,6 +128,23 @@ export function QuickRegionNav() { ))} + ); } diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx index 38cfc9b..5c8ab40 100644 --- a/frontend/src/components/viewer/Scene.tsx +++ b/frontend/src/components/viewer/Scene.tsx @@ -59,11 +59,20 @@ function useClippingPlanes(): THREE.Plane[] { } +// Ângulo polar "de pé, olhando na horizontal" (não de cima nem de baixo) — +// usado tanto pro enquadramento inicial quanto pelos botões Anterior/ +// Posterior, pra sempre ancorar numa vista de frente/costas limpa em vez de +// um ângulo 3/4 arbitrário. +const EYE_LEVEL_POLAR = Math.PI / 2; +const ANTERIOR_AZIMUTH = 0; +const POSTERIOR_AZIMUTH = Math.PI; + function CameraController({ modelFiles }: { modelFiles: string[] }) { const controlsRef = useRef(null); const { scene, camera } = useThree(); const focusRequest = useAtlasStore((s) => s.focusRequest); const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest); + const viewAngleRequest = useAtlasStore((s) => s.viewAngleRequest); const setContentBox = useAtlasStore((s) => s.setContentBox); // focusRequest/regionFocusRequest moram no store GLOBAL (compartilhado @@ -75,6 +84,13 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { // da câmera já existe. const seenFocusNonce = useRef(focusRequest?.nonce); const seenRegionNonce = useRef(regionFocusRequest?.nonce); + const seenViewAngleNonce = useRef(viewAngleRequest?.nonce); + + // Último box enquadrado (corpo inteiro, região ou estrutura) — os botões + // Anterior/Posterior giram a câmera em torno DESSE enquadramento, não + // sempre do corpo inteiro, senão trocar de lado enquanto o usuário está + // olhando uma região específica (ex: "Pelve") chutaria o zoom pra fora. + const lastFitBoxRef = useRef(null); // Malhas reais (Z-Anatomy) não seguem a mesma escala/origem que os // modelos esquemáticos antigos (podem vir em cm, ou centralizadas em @@ -122,7 +138,17 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { camera.near = Math.max(size / 1000, 0.001); camera.far = size * 100; if (camera instanceof THREE.PerspectiveCamera) camera.updateProjectionMatrix(); - controlsRef.current.fitToBox(box, true, { + lastFitBoxRef.current = box.clone(); + // fitToBox arredonda o ângulo ATUAL da câmera pro eixo mais próximo + // (frente/trás/lado/cima/baixo) em vez de manter um 3/4 arbitrário — + // na carga inicial isso dependia de quão perto a posição default + // [1.2, 1.4, 1.6] ficava de um múltiplo de 90°, o que podia (ex: após + // qualquer ajuste futuro nesse valor) arredondar pra uma vista de + // cima/baixo e enquadrar só os pés ou só a cabeça. Fixamos o ângulo + // explicitamente numa vista frontal, na altura dos olhos, ANTES do + // primeiro fitToBox, pra carga inicial ser sempre determinística. + controlsRef.current.rotateTo(ANTERIOR_AZIMUTH, EYE_LEVEL_POLAR, false); + controlsRef.current.fitToBox(box, false, { paddingLeft: 0.2, paddingRight: 0.2, paddingTop: 0.2, @@ -146,6 +172,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { if (!obj) return; const box = computeVisibleBoundingBox(obj); if (box.isEmpty()) return; + lastFitBoxRef.current = box.clone(); controlsRef.current.fitToBox(box, true, { paddingLeft: 0.4, paddingRight: 0.4, @@ -162,6 +189,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { if (total.isEmpty()) return; const box = regionSubBox(total, regionFocusRequest.region); if (box.isEmpty()) return; + lastFitBoxRef.current = box.clone(); controlsRef.current.fitToBox(box, true, { paddingLeft: 0.3, paddingRight: 0.3, @@ -170,6 +198,25 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { }); }, [regionFocusRequest, scene]); + // Vista Anterior/Posterior — gira a câmera 180° em torno do último + // enquadramento (corpo inteiro, região ou estrutura), sem perder o zoom + // atual, pra comparar a face anterior e posterior de uma mesma parte. + useEffect(() => { + if (!viewAngleRequest || !controlsRef.current) return; + if (viewAngleRequest.nonce === seenViewAngleNonce.current) return; + seenViewAngleNonce.current = viewAngleRequest.nonce; + const box = lastFitBoxRef.current ?? computeVisibleBoundingBox(scene); + if (box.isEmpty()) return; + const azimuth = viewAngleRequest.angle === "anterior" ? ANTERIOR_AZIMUTH : POSTERIOR_AZIMUTH; + controlsRef.current.rotateTo(azimuth, EYE_LEVEL_POLAR, true); + controlsRef.current.fitToBox(box, true, { + paddingLeft: 0.25, + paddingRight: 0.25, + paddingTop: 0.25, + paddingBottom: 0.25, + }); + }, [viewAngleRequest, scene]); + return ( void; + // Gira a câmera pra ver o corpo de frente (anterior) ou de costas + // (posterior), mantendo o enquadramento atual (mesma região focada). + viewAngleRequest: { angle: "anterior" | "posterior"; nonce: number } | null; + focusOnViewAngle: (angle: "anterior" | "posterior") => void; + // tiltX/tiltZ (graus) inclinam o plano de corte a partir do eixo base — // é assim que se obtém um corte oblíquo em vez de só sagital/coronal/ // transversal puros. @@ -66,6 +71,12 @@ export const useAtlasStore = create((set) => ({ regionFocusRequest: { region, nonce: (state.regionFocusRequest?.nonce ?? 0) + 1 }, })), + viewAngleRequest: null, + focusOnViewAngle: (angle) => + set((state) => ({ + viewAngleRequest: { angle, nonce: (state.viewAngleRequest?.nonce ?? 0) + 1 }, + })), + clipping: { enabled: false, axis: "sagittal", position: 0, tiltX: 0, tiltZ: 0 }, setClippingEnabled: (enabled) => set((state) => ({ clipping: { ...state.clipping, enabled } })), setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })),