fix camera

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-10-23 12:44:33 -03:00
1 parent 204e9f8a39
commit 76ac1df116
3 files changed
+77 -1

No files matched your search

@@ -30,6 +30,7 @@ function loadStoredPos(): { x: number; y: number } | null {
// queira ver — a posição escolhida fica salva entre visitas. // queira ver — a posição escolhida fica salva entre visitas.
export function QuickRegionNav() { export function QuickRegionNav() {
const focusOnRegion = useAtlasStore((s) => s.focusOnRegion); const focusOnRegion = useAtlasStore((s) => s.focusOnRegion);
const focusOnViewAngle = useAtlasStore((s) => s.focusOnViewAngle);
const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadStoredPos()); const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadStoredPos());
const elRef = useRef<HTMLDivElement>(null); const elRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
@@ -127,6 +128,23 @@ export function QuickRegionNav() {
</button> </button>
))} ))}
</div> </div>
<div className="shrink-0 h-6 w-px bg-white/10" aria-hidden="true" />
<div className="flex gap-1.5 shrink-0">
<button
onClick={() => focusOnViewAngle("anterior")}
title="Vista anterior (de frente)"
className="shrink-0 text-xs px-3 py-1.5 rounded-full bg-white/5 hover:bg-accent hover:text-white text-slate-300 transition-colors"
>
Anterior
</button>
<button
onClick={() => focusOnViewAngle("posterior")}
title="Vista posterior (de costas)"
className="shrink-0 text-xs px-3 py-1.5 rounded-full bg-white/5 hover:bg-accent hover:text-white text-slate-300 transition-colors"
>
Posterior
</button>
</div>
</div> </div>
); );
} }
+48 -1
View File
@@ -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[] }) { function CameraController({ modelFiles }: { modelFiles: string[] }) {
const controlsRef = useRef<CameraControls>(null); const controlsRef = useRef<CameraControls>(null);
const { scene, camera } = useThree(); const { scene, camera } = useThree();
const focusRequest = useAtlasStore((s) => s.focusRequest); const focusRequest = useAtlasStore((s) => s.focusRequest);
const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest); const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest);
const viewAngleRequest = useAtlasStore((s) => s.viewAngleRequest);
const setContentBox = useAtlasStore((s) => s.setContentBox); const setContentBox = useAtlasStore((s) => s.setContentBox);
// focusRequest/regionFocusRequest moram no store GLOBAL (compartilhado // focusRequest/regionFocusRequest moram no store GLOBAL (compartilhado
@@ -75,6 +84,13 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
// da câmera já existe. // da câmera já existe.
const seenFocusNonce = useRef(focusRequest?.nonce); const seenFocusNonce = useRef(focusRequest?.nonce);
const seenRegionNonce = useRef(regionFocusRequest?.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<THREE.Box3 | null>(null);
// Malhas reais (Z-Anatomy) não seguem a mesma escala/origem que os // Malhas reais (Z-Anatomy) não seguem a mesma escala/origem que os
// modelos esquemáticos antigos (podem vir em cm, ou centralizadas em // 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.near = Math.max(size / 1000, 0.001);
camera.far = size * 100; camera.far = size * 100;
if (camera instanceof THREE.PerspectiveCamera) camera.updateProjectionMatrix(); 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, paddingLeft: 0.2,
paddingRight: 0.2, paddingRight: 0.2,
paddingTop: 0.2, paddingTop: 0.2,
@@ -146,6 +172,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
if (!obj) return; if (!obj) return;
const box = computeVisibleBoundingBox(obj); const box = computeVisibleBoundingBox(obj);
if (box.isEmpty()) return; if (box.isEmpty()) return;
lastFitBoxRef.current = box.clone();
controlsRef.current.fitToBox(box, true, { controlsRef.current.fitToBox(box, true, {
paddingLeft: 0.4, paddingLeft: 0.4,
paddingRight: 0.4, paddingRight: 0.4,
@@ -162,6 +189,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
if (total.isEmpty()) return; if (total.isEmpty()) return;
const box = regionSubBox(total, regionFocusRequest.region); const box = regionSubBox(total, regionFocusRequest.region);
if (box.isEmpty()) return; if (box.isEmpty()) return;
lastFitBoxRef.current = box.clone();
controlsRef.current.fitToBox(box, true, { controlsRef.current.fitToBox(box, true, {
paddingLeft: 0.3, paddingLeft: 0.3,
paddingRight: 0.3, paddingRight: 0.3,
@@ -170,6 +198,25 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
}); });
}, [regionFocusRequest, scene]); }, [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 ( return (
<CameraControls <CameraControls
ref={controlsRef} ref={controlsRef}
+11
View File
@@ -26,6 +26,11 @@ interface AtlasState {
regionFocusRequest: { region: BodyRegion; nonce: number } | null; regionFocusRequest: { region: BodyRegion; nonce: number } | null;
focusOnRegion: (region: BodyRegion) => void; focusOnRegion: (region: BodyRegion) => 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 — // 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/ // é assim que se obtém um corte oblíquo em vez de só sagital/coronal/
// transversal puros. // transversal puros.
@@ -66,6 +71,12 @@ export const useAtlasStore = create<AtlasState>((set) => ({
regionFocusRequest: { region, nonce: (state.regionFocusRequest?.nonce ?? 0) + 1 }, 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 }, clipping: { enabled: false, axis: "sagittal", position: 0, tiltX: 0, tiltZ: 0 },
setClippingEnabled: (enabled) => set((state) => ({ clipping: { ...state.clipping, enabled } })), setClippingEnabled: (enabled) => set((state) => ({ clipping: { ...state.clipping, enabled } })),
setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })), setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })),