cortes anatomicos

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-09 19:41:49 -03:00
1 parent ff1b344ad1
commit baf749caa8
4 files changed
+117 -18

No files matched your search

+1 -1
View File
@@ -267,7 +267,7 @@ const EXACT_PT = {
"flexor pollicis longus": "Flexor Longo do Polegar", "flexor pollicis longus": "Flexor Longo do Polegar",
"flexor retinaculum of ankle": "Retináculo dos Flexores do Tornozelo", "flexor retinaculum of ankle": "Retináculo dos Flexores do Tornozelo",
"flexor retinaculum of wrist": "Retináculo dos Flexores do Punho", "flexor retinaculum of wrist": "Retináculo dos Flexores do Punho",
"frontalis muscle": "Frontal", "frontalis muscle": "Frontal (músculo)",
"genioglossus muscle": "Genioglosso", "genioglossus muscle": "Genioglosso",
"geniohyoid muscle": "Geniohióideo", "geniohyoid muscle": "Geniohióideo",
"gluteus maximus muscle": "Glúteo Máximo", "gluteus maximus muscle": "Glúteo Máximo",
+57 -2
View File
@@ -2,7 +2,7 @@ import { useAtlasStore, type ClipAxis } from "../store/useAtlasStore.js";
const AXES: { value: ClipAxis; label: string }[] = [ const AXES: { value: ClipAxis; label: string }[] = [
{ value: "sagittal", label: "Sagital (esq./dir.)" }, { 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.)" }, { value: "transverse", label: "Transversal (sup./inf.)" },
]; ];
@@ -11,6 +11,7 @@ export function ClippingControls() {
const setClippingEnabled = useAtlasStore((s) => s.setClippingEnabled); const setClippingEnabled = useAtlasStore((s) => s.setClippingEnabled);
const setClipAxis = useAtlasStore((s) => s.setClipAxis); const setClipAxis = useAtlasStore((s) => s.setClipAxis);
const setClipPosition = useAtlasStore((s) => s.setClipPosition); const setClipPosition = useAtlasStore((s) => s.setClipPosition);
const setClipTilt = useAtlasStore((s) => s.setClipTilt);
return ( return (
<div className="space-y-2 border-t border-white/10 pt-3"> <div className="space-y-2 border-t border-white/10 pt-3">
@@ -25,7 +26,9 @@ export function ClippingControls() {
</label> </label>
{clipping.enabled && ( {clipping.enabled && (
<div className="space-y-2 pl-1"> <div className="space-y-3 pl-1">
<div className="space-y-1">
<label className="text-xs text-slate-400">Orientação de base</label>
<select <select
value={clipping.axis} value={clipping.axis}
onChange={(e) => setClipAxis(e.target.value as ClipAxis)} onChange={(e) => setClipAxis(e.target.value as ClipAxis)}
@@ -37,6 +40,12 @@ export function ClippingControls() {
</option> </option>
))} ))}
</select> </select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">
Posição do corte no corpo ({clipping.position.toFixed(2)})
</label>
<input <input
type="range" type="range"
min={-1} min={-1}
@@ -47,6 +56,52 @@ export function ClippingControls() {
className="w-full accent-accent" className="w-full accent-accent"
/> />
</div> </div>
<div className="space-y-1">
<label className="text-xs text-slate-400">
Inclinação oblíqua — eixo 1 ({clipping.tiltX.toFixed(0)}°)
</label>
<input
type="range"
min={-89}
max={89}
step={1}
value={clipping.tiltX}
onChange={(e) => setClipTilt(Number(e.target.value), clipping.tiltZ)}
className="w-full accent-accent"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">
Inclinação oblíqua — eixo 2 ({clipping.tiltZ.toFixed(0)}°)
</label>
<input
type="range"
min={-89}
max={89}
step={1}
value={clipping.tiltZ}
onChange={(e) => setClipTilt(clipping.tiltX, Number(e.target.value))}
className="w-full accent-accent"
/>
</div>
{(clipping.tiltX !== 0 || clipping.tiltZ !== 0) && (
<button
onClick={() => setClipTilt(0, 0)}
className="text-xs text-accent hover:underline"
>
Zerar inclinação (voltar ao corte puro)
</button>
)}
<p className="text-[11px] text-slate-500 leading-snug">
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.
</p>
</div>
)} )}
</div> </div>
); );
+43 -13
View File
@@ -6,22 +6,47 @@ import { useAtlasStore, type BodyRegion, type ClipAxis } from "../../store/useAt
import { SystemModel } from "./SystemModel.js"; import { SystemModel } from "./SystemModel.js";
import type { Structure } from "../../lib/api.js"; import type { Structure } from "../../lib/api.js";
const AXIS_CONFIG: Record<ClipAxis, { normal: [number, number, number]; min: number; max: number }> = { // Normais de referência em anatomia: sagital separa esq./dir., coronal
sagittal: { normal: [1, 0, 0], min: -0.4, max: 0.4 }, // (= frontal) separa ant./post., transversal separa sup./inf.
coronal: { normal: [0, 0, 1], min: -0.3, max: 0.3 }, const AXIS_NORMAL: Record<ClipAxis, [number, number, number]> = {
transverse: { normal: [0, -1, 0], min: -1.8, max: 0 }, 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[] { 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(() => { return useMemo(() => {
if (!enabled) return []; if (!enabled || !contentBox) return [];
const cfg = AXIS_CONFIG[axis];
const t = (position + 1) / 2; // normalize -1..1 -> 0..1 const normal = new THREE.Vector3(...AXIS_NORMAL[axis]);
const constant = cfg.min + t * (cfg.max - cfg.min); const euler = new THREE.Euler(
const normal = new THREE.Vector3(...cfg.normal); THREE.MathUtils.degToRad(tiltX),
return [new THREE.Plane(normal, -constant)]; 0,
}, [enabled, axis, position]); 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 { function isFiniteVec3(v: THREE.Vector3): boolean {
@@ -77,6 +102,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
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 setContentBox = useAtlasStore((s) => s.setContentBox);
// 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
@@ -88,6 +114,10 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
const id = requestAnimationFrame(() => { const id = requestAnimationFrame(() => {
const box = computeVisibleBoundingBox(scene); const box = computeVisibleBoundingBox(scene);
if (box.isEmpty()) return; 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(); const size = box.getSize(new THREE.Vector3()).length();
// Ajusta os planos de corte da câmera ao tamanho real do conteúdo — // 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 // um "far" fixo pequeno cortaria (tornaria invisível) um modelo bem
@@ -103,7 +133,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) {
}); });
}); });
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [modelFiles, scene, camera]); }, [modelFiles, scene, camera, setContentBox]);
useEffect(() => { useEffect(() => {
if (!focusRequest || !controlsRef.current) return; if (!focusRequest || !controlsRef.current) return;
+16 -2
View File
@@ -26,10 +26,20 @@ interface AtlasState {
regionFocusRequest: { region: BodyRegion; nonce: number } | null; regionFocusRequest: { region: BodyRegion; nonce: number } | null;
focusOnRegion: (region: BodyRegion) => void; 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; setClippingEnabled: (enabled: boolean) => void;
setClipAxis: (axis: ClipAxis) => void; setClipAxis: (axis: ClipAxis) => void;
setClipPosition: (position: number) => 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<AtlasState>((set) => ({ export const useAtlasStore = create<AtlasState>((set) => ({
@@ -56,8 +66,12 @@ export const useAtlasStore = create<AtlasState>((set) => ({
regionFocusRequest: { region, nonce: (state.regionFocusRequest?.nonce ?? 0) + 1 }, 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 } })), setClippingEnabled: (enabled) => set((state) => ({ clipping: { ...state.clipping, enabled } })),
setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })), setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })),
setClipPosition: (position) => set((state) => ({ clipping: { ...state.clipping, position } })), setClipPosition: (position) => set((state) => ({ clipping: { ...state.clipping, position } })),
setClipTilt: (tiltX, tiltZ) => set((state) => ({ clipping: { ...state.clipping, tiltX, tiltZ } })),
contentBox: null,
setContentBox: (contentBox) => set({ contentBox }),
})); }));