fix exame fisico

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-10 20:19:37 -03:00
1 parent 7e8963135b
commit 9a848416ef
4 files changed
+246 -66

No files matched your search

+130 -16
View File
@@ -1,16 +1,95 @@
import { useEffect, useState } from "react";
import { useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import * as THREE from "three";
import { useAtlasStore } from "../../store/useAtlasStore.js";
import type { ExamPoint, ExamTechnique } from "../../data/physicalExam.js";
import type { ExamPoint, ExamTechnique, ExamAnchor } from "../../data/physicalExam.js";
import { TECHNIQUES } from "../../data/physicalExam.js";
// Roda DENTRO do mesmo <Canvas> do Scene (recebido via prop "children" —
// ver Scene.tsx) pra poder usar <Html> do drei e o bounding box real do
// corpo carregado (contentBox no store, o mesmo usado pelo plano de corte e
// pela navegação rápida por região). Cada ponto de exame é dado como
// FRAÇÃO do corpo (ver data/physicalExam.ts), convertida aqui pra
// coordenada absoluta — assim os pontos acompanham o corpo real não
// importa a escala/origem carregada.
// Antes os pontos de exame usavam frações do bounding box do CORPO INTEIRO
// (cabeça a pé) — mas isso supõe proporções corporais genéricas que não
// batem necessariamente com a escala/proporção real deste modelo específico,
// e por isso os pontos apareciam longe de onde deveriam. A correção: ancorar
// cada ponto no bounding box de OSSOS REAIS específicos (esterno, costelas,
// bacia, crânio, rádio, tíbia) — como esses ossos já são renderizados
// corretamente pelo pipeline, herdamos a escala/posição certa de graça, sem
// precisar adivinhar proporção nenhuma.
//
// Isso exige que o sistema esquelético esteja CARREGADO na cena (não
// necessariamente visível — ver o prop alwaysLoadSystems em Scene.tsx/
// PhysicalExamPage.tsx, que força o glb do esqueleto a entrar mesmo com a
// camada desligada).
const LANDMARK_SLUGS: Record<string, string[]> = {
sternum: ["esqueletico-esterno"],
ribsTop: ["esqueletico-1-costela-direito", "esqueletico-1-costela-esquerdo"],
ribsBottom: ["esqueletico-12-costela-direito", "esqueletico-12-costela-esquerdo"],
pelvis: ["esqueletico-osso-do-quadril-direito", "esqueletico-osso-do-quadril-esquerdo"],
skull: ["esqueletico-osso-frontal", "esqueletico-mandibula"],
radiusR: ["esqueletico-radio-direito"],
radiusL: ["esqueletico-radio-esquerdo"],
tibiaR: ["esqueletico-tibia-direito"],
tibiaL: ["esqueletico-tibia-esquerdo"],
};
type AnchorBoxes = Record<ExamAnchor, THREE.Box3>;
function boxFromSlugs(scene: THREE.Object3D, slugs: string[]): THREE.Box3 | null {
const box = new THREE.Box3();
let found = false;
for (const slug of slugs) {
const obj = scene.getObjectByName(slug);
if (!obj) continue;
const objBox = new THREE.Box3().setFromObject(obj);
if (objBox.isEmpty()) continue;
box.union(objBox);
found = true;
}
return found ? box : null;
}
function computeAnchorBoxes(scene: THREE.Object3D): AnchorBoxes | null {
const raw: Record<string, THREE.Box3 | null> = {};
for (const [key, slugs] of Object.entries(LANDMARK_SLUGS)) {
raw[key] = boxFromSlugs(scene, slugs);
}
// Precisamos pelo menos do tórax (esterno+costelas), da bacia e do crânio
// pra derivar todas as regiões — se algum não foi encontrado ainda
// (glb ainda carregando), tenta de novo no próximo frame.
if (!raw.sternum || !raw.ribsTop || !raw.ribsBottom || !raw.pelvis || !raw.skull) return null;
const thorax = raw.sternum.clone().union(raw.ribsTop).union(raw.ribsBottom);
const pelvis = raw.pelvis.clone();
const skull = raw.skull.clone();
// Abdome = vão entre a base do tórax e o topo da bacia; pescoço = vão
// entre a base do crânio e o topo do tórax. Usamos a largura/profundidade
// do tórax como aproximação (o abdome real é um pouco mais estreito, mas
// é só uma referência de onde clicar, não uma malha visível).
const abdomen = new THREE.Box3(
new THREE.Vector3(
Math.min(thorax.min.x, pelvis.min.x),
pelvis.max.y,
Math.min(thorax.min.z, pelvis.min.z),
),
new THREE.Vector3(
Math.max(thorax.max.x, pelvis.max.x),
thorax.min.y,
Math.max(thorax.max.z, pelvis.max.z),
),
);
const neck = new THREE.Box3(
new THREE.Vector3(thorax.min.x, thorax.max.y, thorax.min.z),
new THREE.Vector3(thorax.max.x, skull.min.y, thorax.max.z),
);
const boxes: Partial<AnchorBoxes> = { thorax, abdomen, pelvis, head: skull, neck };
if (raw.radiusR) boxes.radiusR = raw.radiusR;
if (raw.radiusL) boxes.radiusL = raw.radiusL;
if (raw.tibiaR) boxes.tibiaR = raw.tibiaR;
if (raw.tibiaL) boxes.tibiaL = raw.tibiaL;
return boxes as AnchorBoxes;
}
export function ExamMarkers({
points,
selectedId,
@@ -20,21 +99,49 @@ export function ExamMarkers({
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const contentBox = useAtlasStore((s) => s.contentBox);
if (!contentBox) return null;
const { scene } = useThree();
const [anchors, setAnchors] = useState<AnchorBoxes | null>(null);
const min = new THREE.Vector3(...contentBox.min);
const max = new THREE.Vector3(...contentBox.max);
useEffect(() => {
let cancelled = false;
let attempts = 0;
let timer: ReturnType<typeof setTimeout>;
function tryCompute() {
if (cancelled) return;
const boxes = computeAnchorBoxes(scene);
if (boxes) {
setAnchors(boxes);
return;
}
attempts += 1;
if (attempts >= 50) {
console.warn(
"ExamMarkers: não encontrou os ossos de referência (esterno/costelas/bacia/crânio) a tempo — confira se o sistema esquelético foi carregado.",
);
return;
}
timer = setTimeout(tryCompute, 200);
}
tryCompute();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [scene]);
if (!anchors) return null;
const techniqueColor = (t: ExamTechnique) => TECHNIQUES.find((x) => x.key === t)?.color ?? "#fff";
return (
<>
{points.map((p) => {
const box = anchors[p.anchor] ?? anchors[fallbackAnchor(p.anchor)];
if (!box) return null;
const [fx, fy, fz] = p.fraction;
const position: [number, number, number] = [
THREE.MathUtils.lerp(min.x, max.x, fx),
THREE.MathUtils.lerp(min.y, max.y, fy),
THREE.MathUtils.lerp(min.z, max.z, fz),
THREE.MathUtils.lerp(box.min.x, box.max.x, fx),
THREE.MathUtils.lerp(box.min.y, box.max.y, fy),
THREE.MathUtils.lerp(box.min.z, box.max.z, fz),
];
const isSelected = p.id === selectedId;
const color = techniqueColor(p.technique);
@@ -56,3 +163,10 @@ export function ExamMarkers({
</>
);
}
// radiusR/radiusL/tibiaR/tibiaL às vezes não existem (bone não encontrado);
// nesse caso cai pro tórax pra não sumir o ponto de vez.
function fallbackAnchor(anchor: ExamAnchor): ExamAnchor {
if (anchor.startsWith("radius") || anchor.startsWith("tibia")) return "thorax";
return anchor;
}