From 6b5adc34031eae9eea5dabd3fdb4aa7d3c2cc201 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Thu, 10 Sep 2026 20:25:28 -0300 Subject: [PATCH] fix exame fisico2 --- .../src/components/viewer/ExamMarkers.tsx | 185 ++++++++++-------- 1 file changed, 108 insertions(+), 77 deletions(-) diff --git a/frontend/src/components/viewer/ExamMarkers.tsx b/frontend/src/components/viewer/ExamMarkers.tsx index f707eaf..be723f7 100644 --- a/frontend/src/components/viewer/ExamMarkers.tsx +++ b/frontend/src/components/viewer/ExamMarkers.tsx @@ -2,37 +2,50 @@ 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, ExamAnchor } from "../../data/physicalExam.js"; import { TECHNIQUES } from "../../data/physicalExam.js"; -// 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 = { - 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"], +// Os pontos de exame são ancorados no bounding box de OSSOS REAIS do +// esqueleto (esterno, costelas, vértebras, bacia, crânio, rádio, tíbia) em +// vez de uma fração do corpo inteiro — assim herdam a escala/posição +// corretas de graça. Só que a versão anterior exigia TODOS os ossos de uma +// região de uma vez (se um só faltasse — nome bruto do Z-Anatomy diferente +// do esperado, sufixo de lado ausente etc. — a região inteira sumia, e com +// ela TODOS os pontos que dependiam dela). Esta versão é tolerante a falha: +// cada região usa vários ossos candidatos (só precisa achar ALGUM) e, se +// mesmo assim nada for encontrado, cai pro bounding box do corpo inteiro +// (contentBox, o mesmo do plano de corte) — os pontos ficam menos precisos +// nesse caso, mas nunca somem. +const RIB_ORDINALS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; +function ribSlugs(): string[] { + const out: string[] = []; + for (const n of RIB_ORDINALS) { + out.push(`esqueletico-${n}-costela-direito`, `esqueletico-${n}-costela-esquerdo`); + } + return out; +} + +const REGION_CANDIDATES: Record = { + thorax: [ + "esqueletico-esterno", + "esqueletico-vertebra-t1", + "esqueletico-vertebra-t12", + ...ribSlugs(), + ], + pelvis: [ + "esqueletico-osso-do-quadril-direito", + "esqueletico-osso-do-quadril-esquerdo", + "esqueletico-sacro", + ], + skull: ["esqueletico-osso-frontal", "esqueletico-mandibula", "esqueletico-osso-occipital"], radiusR: ["esqueletico-radio-direito"], radiusL: ["esqueletico-radio-esquerdo"], tibiaR: ["esqueletico-tibia-direito"], tibiaL: ["esqueletico-tibia-esquerdo"], }; -type AnchorBoxes = Record; - -function boxFromSlugs(scene: THREE.Object3D, slugs: string[]): THREE.Box3 | null { +function unionFromSlugs(scene: THREE.Object3D, slugs: string[]): THREE.Box3 | null { const box = new THREE.Box3(); let found = false; for (const slug of slugs) { @@ -46,50 +59,61 @@ function boxFromSlugs(scene: THREE.Object3D, slugs: string[]): THREE.Box3 | null return found ? box : null; } -function computeAnchorBoxes(scene: THREE.Object3D): AnchorBoxes | null { - const raw: Record = {}; - 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; +type AnchorBoxes = Partial>; - const thorax = raw.sternum.clone().union(raw.ribsTop).union(raw.ribsBottom); - const pelvis = raw.pelvis.clone(); - const skull = raw.skull.clone(); +function computeAnchorBoxes(scene: THREE.Object3D): AnchorBoxes { + const thorax = unionFromSlugs(scene, REGION_CANDIDATES.thorax); + const pelvis = unionFromSlugs(scene, REGION_CANDIDATES.pelvis); + const skull = unionFromSlugs(scene, REGION_CANDIDATES.skull); + + const boxes: AnchorBoxes = {}; + if (thorax) boxes.thorax = thorax; + if (pelvis) boxes.pelvis = pelvis; + if (skull) boxes.head = skull; // 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), - ); + // entre a base do crânio e o topo do tórax — só dá pra calcular se as + // duas pontas envolvidas foram encontradas. + if (thorax && pelvis) { + boxes.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), + ), + ); + } + if (thorax && skull) { + boxes.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 = { 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; + for (const key of ["radiusR", "radiusL", "tibiaR", "tibiaL"] as const) { + const found = unionFromSlugs(scene, REGION_CANDIDATES[key]); + if (found) boxes[key] = found; + } - return boxes as AnchorBoxes; + return boxes; } +// Ordem de fallback quando o anchor "ideal" de um ponto não foi encontrado +// — tenta uma região vizinha razoável antes de recorrer ao corpo inteiro. +const FALLBACK_CHAIN: Partial> = { + neck: ["thorax", "head"], + abdomen: ["thorax", "pelvis"], + radiusR: ["thorax"], + radiusL: ["thorax"], + tibiaR: ["pelvis"], + tibiaL: ["pelvis"], +}; + export function ExamMarkers({ points, selectedId, @@ -100,7 +124,8 @@ export function ExamMarkers({ onSelect: (id: string) => void; }) { const { scene } = useThree(); - const [anchors, setAnchors] = useState(null); + const contentBox = useAtlasStore((s) => s.contentBox); + const [anchors, setAnchors] = useState({}); useEffect(() => { let cancelled = false; @@ -109,18 +134,21 @@ export function ExamMarkers({ function tryCompute() { if (cancelled) return; const boxes = computeAnchorBoxes(scene); - if (boxes) { - setAnchors(boxes); - return; - } + setAnchors(boxes); + // "thorax" é o anchor mais usado (a maioria dos pontos cardio/resp + // dependem dele) — se ainda não achou, continua tentando (o glb do + // esqueleto pode ainda estar carregando); só desiste depois de 10s. attempts += 1; - if (attempts >= 50) { + if (!boxes.thorax && attempts < 50) { + timer = setTimeout(tryCompute, 200); + } else if (!boxes.thorax) { 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.", + "ExamMarkers: não encontrou o tórax (esterno/costelas/vértebras) — os pontos vão usar o corpo inteiro como referência aproximada. Anchors encontrados:", + Object.keys(boxes), ); - return; + } else { + console.log("ExamMarkers: anchors encontrados:", Object.keys(boxes)); } - timer = setTimeout(tryCompute, 200); } tryCompute(); return () => { @@ -129,13 +157,23 @@ export function ExamMarkers({ }; }, [scene]); - if (!anchors) return null; const techniqueColor = (t: ExamTechnique) => TECHNIQUES.find((x) => x.key === t)?.color ?? "#fff"; + const bodyBox = contentBox + ? new THREE.Box3(new THREE.Vector3(...contentBox.min), new THREE.Vector3(...contentBox.max)) + : null; + + function resolveBox(anchor: ExamAnchor): THREE.Box3 | null { + if (anchors[anchor]) return anchors[anchor]!; + for (const fallback of FALLBACK_CHAIN[anchor] ?? []) { + if (anchors[fallback]) return anchors[fallback]!; + } + return bodyBox; + } return ( <> {points.map((p) => { - const box = anchors[p.anchor] ?? anchors[fallbackAnchor(p.anchor)]; + const box = resolveBox(p.anchor); if (!box) return null; const [fx, fy, fz] = p.fraction; const position: [number, number, number] = [ @@ -163,10 +201,3 @@ 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; -}