diff --git a/data-pipeline/scripts/translate-pt.mjs b/data-pipeline/scripts/translate-pt.mjs index 372745d..3ee8fa8 100644 --- a/data-pipeline/scripts/translate-pt.mjs +++ b/data-pipeline/scripts/translate-pt.mjs @@ -267,7 +267,7 @@ const EXACT_PT = { "flexor pollicis longus": "Flexor Longo do Polegar", "flexor retinaculum of ankle": "Retináculo dos Flexores do Tornozelo", "flexor retinaculum of wrist": "Retináculo dos Flexores do Punho", - "frontalis muscle": "Frontal", + "frontalis muscle": "Frontal (músculo)", "genioglossus muscle": "Genioglosso", "geniohyoid muscle": "Geniohióideo", "gluteus maximus muscle": "Glúteo Máximo", diff --git a/frontend/src/components/ClippingControls.tsx b/frontend/src/components/ClippingControls.tsx index 2e65166..573c3a1 100644 --- a/frontend/src/components/ClippingControls.tsx +++ b/frontend/src/components/ClippingControls.tsx @@ -2,7 +2,7 @@ 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: "coronal", label: "Coronal / Frontal (ant./post.)" }, { value: "transverse", label: "Transversal (sup./inf.)" }, ]; @@ -11,6 +11,7 @@ export function ClippingControls() { const setClippingEnabled = useAtlasStore((s) => s.setClippingEnabled); const setClipAxis = useAtlasStore((s) => s.setClipAxis); const setClipPosition = useAtlasStore((s) => s.setClipPosition); + const setClipTilt = useAtlasStore((s) => s.setClipTilt); return (
@@ -25,27 +26,81 @@ export function ClippingControls() { {clipping.enabled && ( -
- - setClipPosition(Number(e.target.value))} - className="w-full accent-accent" - /> +
+
+ + +
+ +
+ + setClipPosition(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ +
+ + setClipTilt(Number(e.target.value), clipping.tiltZ)} + className="w-full accent-accent" + /> +
+ +
+ + setClipTilt(clipping.tiltX, Number(e.target.value))} + className="w-full accent-accent" + /> +
+ + {(clipping.tiltX !== 0 || clipping.tiltZ !== 0) && ( + + )} + +

+ A posição desliza o corte por todo o corpo carregado (não só pelo + centro) e a inclinação permite cortes oblíquos a partir de + qualquer um dos 3 planos anatômicos de base. +

)}
diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx index fbc0d63..8fbba46 100644 --- a/frontend/src/components/viewer/Scene.tsx +++ b/frontend/src/components/viewer/Scene.tsx @@ -6,22 +6,47 @@ import { useAtlasStore, type BodyRegion, type ClipAxis } from "../../store/useAt 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 }, +// Normais de referência em anatomia: sagital separa esq./dir., coronal +// (= frontal) separa ant./post., transversal separa sup./inf. +const AXIS_NORMAL: Record = { + sagittal: [1, 0, 0], + coronal: [0, 0, 1], + transverse: [0, -1, 0], }; +// Corte oblíquo = pega a normal de um dos 3 planos-base e inclina em torno +// dos outros dois eixos (em graus) — cobre qualquer ângulo, não só os 3 +// puros. A posição do corte é sempre relativa ao bounding box REAL do +// conteúdo carregado (não a um intervalo fixo chutado), então o corte +// atravessa a parte do corpo certa não importa a escala/origem dos dados. function useClippingPlanes(): THREE.Plane[] { - const { enabled, axis, position } = useAtlasStore((s) => s.clipping); + const { enabled, axis, position, tiltX, tiltZ } = useAtlasStore((s) => s.clipping); + const contentBox = useAtlasStore((s) => s.contentBox); + 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]); + if (!enabled || !contentBox) return []; + + const normal = new THREE.Vector3(...AXIS_NORMAL[axis]); + const euler = new THREE.Euler( + THREE.MathUtils.degToRad(tiltX), + 0, + THREE.MathUtils.degToRad(tiltZ), + ); + normal.applyEuler(euler).normalize(); + + const box = new THREE.Box3( + new THREE.Vector3(...contentBox.min), + new THREE.Vector3(...contentBox.max), + ); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const halfExtent = + (Math.abs(normal.x) * size.x + Math.abs(normal.y) * size.y + Math.abs(normal.z) * size.z) / 2; + + const pointOnPlane = center.clone().addScaledVector(normal, position * halfExtent); + const constant = -normal.dot(pointOnPlane); + return [new THREE.Plane(normal, constant)]; + }, [enabled, axis, position, tiltX, tiltZ, contentBox]); } function isFiniteVec3(v: THREE.Vector3): boolean { @@ -77,6 +102,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { const { scene, camera } = useThree(); const focusRequest = useAtlasStore((s) => s.focusRequest); const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest); + const setContentBox = useAtlasStore((s) => s.setContentBox); // Malhas reais (Z-Anatomy) não seguem a mesma escala/origem que os // modelos esquemáticos antigos (podem vir em cm, ou centralizadas em @@ -88,6 +114,10 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { const id = requestAnimationFrame(() => { const box = computeVisibleBoundingBox(scene); if (box.isEmpty()) return; + setContentBox({ + min: [box.min.x, box.min.y, box.min.z], + max: [box.max.x, box.max.y, box.max.z], + }); const size = box.getSize(new THREE.Vector3()).length(); // Ajusta os planos de corte da câmera ao tamanho real do conteúdo — // um "far" fixo pequeno cortaria (tornaria invisível) um modelo bem @@ -103,7 +133,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { }); }); return () => cancelAnimationFrame(id); - }, [modelFiles, scene, camera]); + }, [modelFiles, scene, camera, setContentBox]); useEffect(() => { if (!focusRequest || !controlsRef.current) return; diff --git a/frontend/src/store/useAtlasStore.ts b/frontend/src/store/useAtlasStore.ts index b559d73..7205fa7 100644 --- a/frontend/src/store/useAtlasStore.ts +++ b/frontend/src/store/useAtlasStore.ts @@ -26,10 +26,20 @@ interface AtlasState { regionFocusRequest: { region: BodyRegion; nonce: number } | null; focusOnRegion: (region: BodyRegion) => void; - clipping: { enabled: boolean; axis: ClipAxis; position: number }; + // 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. + clipping: { enabled: boolean; axis: ClipAxis; position: number; tiltX: number; tiltZ: number }; setClippingEnabled: (enabled: boolean) => void; setClipAxis: (axis: ClipAxis) => void; setClipPosition: (position: number) => void; + setClipTilt: (tiltX: number, tiltZ: number) => void; + + // Bounding box real do conteúdo carregado (calculado a partir das malhas + // visíveis) — o plano de corte usa isso pra saber onde fica "o corpo", + // já que os dados reais do Z-Anatomy não têm escala/origem previsível. + contentBox: { min: [number, number, number]; max: [number, number, number] } | null; + setContentBox: (box: { min: [number, number, number]; max: [number, number, number] } | null) => void; } export const useAtlasStore = create((set) => ({ @@ -56,8 +66,12 @@ export const useAtlasStore = create((set) => ({ regionFocusRequest: { region, nonce: (state.regionFocusRequest?.nonce ?? 0) + 1 }, })), - clipping: { enabled: false, axis: "sagittal", position: 0 }, + 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 } })), setClipPosition: (position) => set((state) => ({ clipping: { ...state.clipping, position } })), + setClipTilt: (tiltX, tiltZ) => set((state) => ({ clipping: { ...state.clipping, tiltX, tiltZ } })), + + contentBox: null, + setContentBox: (contentBox) => set({ contentBox }), }));