Files
anatomia/frontend/src/components/viewer/ExamMarkers.tsx
T

248 lines
9.3 KiB
TypeScript

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<string, string[]> = {
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<string, THREE.Box3>): 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<string, THREE.Box3>): 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<Record<ExamAnchor, THREE.Box3>>;
function computeAnchorBoxes(scene: THREE.Object3D, cache: Map<string, THREE.Box3>): 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<Record<ExamAnchor, ExamAnchor[]>> = {
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<string, number>) => void;
} | null;
const contentBox = useAtlasStore((s) => s.contentBox);
const [anchors, setAnchors] = useState<AnchorBoxes>({});
useEffect(() => {
let cancelled = false;
let attempts = 0;
let timer: ReturnType<typeof setTimeout>;
let framed = false;
const cache = new Map<string, THREE.Box3>();
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.
<Html key={p.id} position={position} center>
<button
onClick={() => onSelect(p.id)}
title={p.name}
className="w-5 h-5 rounded-full border-2 border-white shadow-lg transition-transform hover:scale-125"
style={{
backgroundColor: color,
boxShadow: isSelected ? `0 0 0 6px ${color}66` : "0 1px 4px rgba(0,0,0,0.6)",
transform: isSelected ? "scale(1.4)" : undefined,
}}
/>
</Html>
);
})}
</>
);
}