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"; // 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"], }; // Cada Box3.setFromObject percorre a geometria toda do osso — cacheamos o // resultado por slug pra não recalcular a cada tentativa de retry (o // esqueleto não se move, então o box de um osso já achado nunca muda). function boxForSlug(scene: THREE.Object3D, slug: string, cache: Map): THREE.Box3 | null { const cached = cache.get(slug); if (cached) return cached; const obj = scene.getObjectByName(slug); if (!obj) return null; const box = new THREE.Box3().setFromObject(obj); if (box.isEmpty()) return null; cache.set(slug, box); return box; } function unionFromSlugs(scene: THREE.Object3D, slugs: string[], cache: Map): THREE.Box3 | null { const box = new THREE.Box3(); let found = false; for (const slug of slugs) { const objBox = boxForSlug(scene, slug, cache); if (!objBox) continue; box.union(objBox); found = true; } return found ? box : null; } type AnchorBoxes = Partial>; function computeAnchorBoxes(scene: THREE.Object3D, cache: Map): AnchorBoxes { const thorax = unionFromSlugs(scene, REGION_CANDIDATES.thorax, cache); const pelvis = unionFromSlugs(scene, REGION_CANDIDATES.pelvis, cache); const skull = unionFromSlugs(scene, REGION_CANDIDATES.skull, cache); 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 — 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), ); } for (const key of ["radiusR", "radiusL", "tibiaR", "tibiaL"] as const) { const found = unionFromSlugs(scene, REGION_CANDIDATES[key], cache); if (found) boxes[key] = found; } 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, onSelect, }: { points: ExamPoint[]; selectedId: string | null; onSelect: (id: string) => void; }) { const { scene } = useThree(); // As mesmas CameraControls registradas pelo Scene.tsx (makeDefault) — // acessadas aqui pra enquadrar a câmera na região dos pontos assim que // os ossos são achados, sem depender/competir com o efeito de // enquadramento "corpo inteiro" do Scene.tsx (que tem sua própria // corrida contra o carregamento assíncrono do modelo). const controls = useThree((state) => state.controls) as unknown as { fitToBox: (box: THREE.Box3, enableTransition: boolean, opts?: Record) => void; } | null; const contentBox = useAtlasStore((s) => s.contentBox); const [anchors, setAnchors] = useState({}); useEffect(() => { let cancelled = false; let attempts = 0; let timer: ReturnType; let framed = false; const cache = new Map(); function tryCompute() { if (cancelled) return; const boxes = computeAnchorBoxes(scene, cache); setAnchors(boxes); attempts += 1; // Assim que der pra montar um box com tórax+bacia+crânio, enquadra a // câmera nele DIRETAMENTE (uma vez só) — garante que a câmera mostre // exatamente a região onde os pontos de exame estão, não importa o // que outro efeito de câmera esteja fazendo. const gotCore = boxes.thorax && boxes.pelvis && boxes.head; if (gotCore && !framed && controls) { framed = true; const region = boxes.thorax!.clone().union(boxes.pelvis!).union(boxes.head!); controls.fitToBox(region, true, { paddingLeft: 0.3, paddingRight: 0.3, paddingTop: 0.2, paddingBottom: 0.2, }); } if (!gotCore && attempts < 30) { timer = setTimeout(tryCompute, 300); } else if (!boxes.thorax) { console.warn( "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), ); } else { console.log("ExamMarkers: anchors encontrados:", Object.keys(boxes)); } } tryCompute(); return () => { cancelled = true; clearTimeout(timer); }; }, [scene, controls]); 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 = resolveBox(p.anchor); if (!box) return null; const [fx, fy, fz] = p.fraction; const position: [number, number, number] = [ 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); return ( // Sem distanceFactor de propósito: esse prop escala o elemento // pela distância câmera↔ponto assumindo uma escala "típica" de // cena — como não sabemos a escala real do modelo do Z-Anatomy // (pode estar em cm, m, ou outra unidade arbitrária), a bolinha // podia estar sendo desenhada com um tamanho ínfimo (perto de // zero px) sem erro nenhum no console. Sem esse prop, o marcador // sempre tem o mesmo tamanho em pixels na tela, não importa a // escala/distância — só a posição usa a projeção 3D.