280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useFrame, 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, ExamSide } from "../../data/physicalExam.js";
|
|
import { TECHNIQUES } from "../../data/physicalExam.js";
|
|
|
|
// Mesma convenção usada em PlanesPage (AXES: "Anterior" aponta pra +Z) —
|
|
// assumimos que o corpo real do Z-Anatomy segue a mesma orientação. Se na
|
|
// prática ficar invertido (pontos anteriores só aparecem vendo de trás),
|
|
// é só trocar o sinal aqui.
|
|
const ANTERIOR_DIR = new THREE.Vector3(0, 0, 1);
|
|
|
|
// 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,
|
|
onSideChange,
|
|
}: {
|
|
points: ExamPoint[];
|
|
selectedId: string | null;
|
|
onSelect: (id: string) => void;
|
|
// Avisa a página (fora do Canvas) de que lado o usuário está vendo agora
|
|
// — só pra mostrar uma dica textual, opcional.
|
|
onSideChange?: (side: ExamSide) => 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;
|
|
}
|
|
|
|
// Só mostra os pontos do lado que a câmera está encarando (anterior ou
|
|
// posterior) — senão um ponto da frente e um das costas ficam colados no
|
|
// mesmo lugar aparente na tela, e não dá pra saber em qual se está
|
|
// clicando. Atualiza a cada frame, mas só dispara re-render quando o
|
|
// lado realmente muda (não a cada frame).
|
|
const [visibleSide, setVisibleSide] = useState<ExamSide>("anterior");
|
|
const visibleSideRef = useRef<ExamSide>("anterior");
|
|
useFrame(({ camera }) => {
|
|
const center = bodyBox?.getCenter(new THREE.Vector3()) ?? anchors.thorax?.getCenter(new THREE.Vector3());
|
|
if (!center) return;
|
|
const toCamera = camera.position.clone().sub(center);
|
|
if (toCamera.lengthSq() < 1e-8) return;
|
|
const side: ExamSide = toCamera.dot(ANTERIOR_DIR) >= 0 ? "anterior" : "posterior";
|
|
if (side !== visibleSideRef.current) {
|
|
visibleSideRef.current = side;
|
|
setVisibleSide(side);
|
|
onSideChange?.(side);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{points
|
|
.filter((p) => p.side === visibleSide)
|
|
.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>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|