From 9a848416ef65ebb6ec208e7e1fed81e2a011e21e Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Thu, 10 Sep 2026 20:19:37 -0300 Subject: [PATCH] fix exame fisico --- .../src/components/viewer/ExamMarkers.tsx | 146 ++++++++++++++++-- frontend/src/components/viewer/Scene.tsx | 16 +- frontend/src/data/physicalExam.ts | 137 ++++++++++------ frontend/src/routes/PhysicalExamPage.tsx | 13 +- 4 files changed, 246 insertions(+), 66 deletions(-) diff --git a/frontend/src/components/viewer/ExamMarkers.tsx b/frontend/src/components/viewer/ExamMarkers.tsx index ed4f43e..f707eaf 100644 --- a/frontend/src/components/viewer/ExamMarkers.tsx +++ b/frontend/src/components/viewer/ExamMarkers.tsx @@ -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 do Scene (recebido via prop "children" — -// ver Scene.tsx) pra poder usar 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 = { + 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; + +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 = {}; + 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 = { 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(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; + 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; +} diff --git a/frontend/src/components/viewer/Scene.tsx b/frontend/src/components/viewer/Scene.tsx index a34c2e1..8265151 100644 --- a/frontend/src/components/viewer/Scene.tsx +++ b/frontend/src/components/viewer/Scene.tsx @@ -176,6 +176,7 @@ function CameraController({ modelFiles }: { modelFiles: string[] }) { export function Scene({ bySlug, children, + alwaysLoadSystems, }: { bySlug: Map; // Ponto de extensão pra conteúdo 3D extra dentro do MESMO Canvas (e do @@ -183,6 +184,13 @@ export function Scene({ // Físico pra desenhar os pontos de exame em cima do corpo real, sem // duplicar toda a lógica de carregamento de malhas/câmera daqui. children?: ReactNode; + // Sistemas cujo .glb deve carregar mesmo com a camada desligada na barra + // lateral (fica invisível — mesh.visible continua obedecendo + // visibleSystems — mas presente na cena). Usado pelo Exame Físico: os + // ossos de referência (esterno, bacia, crânio...) usados pra posicionar + // os pontos de exame precisam existir na cena mesmo se o usuário não + // marcou "Esquelético". + alwaysLoadSystems?: string[]; }) { const visibleSystems = useAtlasStore((s) => s.visibleSystems); const selectedStructureId = useAtlasStore((s) => s.selectedStructureId); @@ -214,9 +222,13 @@ export function Scene({ const modelFiles = useMemo( () => [...structuresByFile.entries()] - .filter(([, items]) => items.some((s) => visibleSystems.has(s.system.slug))) + .filter(([, items]) => + items.some( + (s) => visibleSystems.has(s.system.slug) || alwaysLoadSystems?.includes(s.system.slug), + ), + ) .map(([file]) => file), - [structuresByFile, visibleSystems], + [structuresByFile, visibleSystems, alwaysLoadSystems], ); return ( diff --git a/frontend/src/data/physicalExam.ts b/frontend/src/data/physicalExam.ts index b9897e3..b40575b 100644 --- a/frontend/src/data/physicalExam.ts +++ b/frontend/src/data/physicalExam.ts @@ -1,21 +1,34 @@ // Pontos do módulo de Exame Físico, posicionados sobre o corpo 3D REAL do -// Z-Anatomy (não um boneco geométrico) — por isso a posição de cada ponto -// é dada como FRAÇÃO (0 a 1) do bounding box do conteúdo carregado, e não -// como coordenada absoluta: o corpo real não tem escala/origem fixa (é -// recalculado a cada carga, ver computeVisibleBoundingBox em Scene.tsx), só -// a PROPORÇÃO do corpo é estável. As frações de altura (fy) usam as mesmas -// faixas por região já validadas em Scene.tsx (BODY_REGION_Y_RANGES): -// cabeça [0.85,1], pescoço [0.8,0.87], tórax [0.62,0.83], abdome [0.52,0.65], -// pelve [0.42,0.55], membros superiores [0.55,0.88], membros inferiores [0,0.45]. +// Z-Anatomy. Cada ponto é ancorado num OSSO/REGIÃO REAL do esqueleto (não +// numa fração do corpo inteiro, que não bate com a proporção real deste +// modelo específico) — ver a derivação dos anchors em ExamMarkers.tsx: +// thorax = esterno + 1ª e 12ª costelas (bounding box do tórax todo) +// abdomen = vão entre a base do tórax e o topo da bacia +// pelvis = osso do quadril (esq.+dir.) +// head = osso frontal + mandíbula +// neck = vão entre a base do crânio e o topo do tórax +// radiusR/radiusL = rádio (antebraço) de cada lado — usado pro pulso radial +// tibiaR/tibiaL = tíbia (perna) de cada lado — usado pro pulso pedioso // -// fx = 0..1 esquerda↔direita da TELA (não do paciente — cada ponto já diz -// o lado anatômico por extenso na descrição); fz = 0..1 trás↔frente -// (assumindo o corpo de frente pra +Z, mesma convenção usada em -// PlanesPage). Como as posições são só uma estimativa geométrica (não dá -// pra inspecionar visualmente as coordenadas reais do modelo a partir daqui), -// os números podem precisar de ajuste fino depois de testar no app — se um -// ponto aparecer decolado do corpo, é só mudar a fração aqui. +// fraction = [fx, fy, fz], cada um de 0 a 1 (ou um pouco fora, se o ponto +// fica além do próprio osso-âncora) DENTRO do bounding box daquele anchor: +// fy = 0 é a base do anchor, fy = 1 o topo; fx = 0/1 são as bordas laterais +// (fx = 0.5 é o meio); fz = 0 é a face posterior, fz = 1 a anterior. +// +// Como não dá pra abrir o navegador e conferir visualmente as coordenadas +// reais a partir daqui, o lado esquerdo/direito (fx) é uma estimativa — +// se aparecer trocado no app, é só inverter o fx do ponto (1 - fx). export type ExamTechnique = "inspecao" | "palpacao" | "percussao" | "ausculta"; +export type ExamAnchor = + | "thorax" + | "abdomen" + | "pelvis" + | "head" + | "neck" + | "radiusR" + | "radiusL" + | "tibiaR" + | "tibiaL"; export const TECHNIQUES: { key: ExamTechnique; label: string; color: string }[] = [ { key: "inspecao", label: "Inspeção", color: "#f2c14e" }, @@ -29,6 +42,7 @@ export interface ExamPoint { name: string; system: string; technique: ExamTechnique; + anchor: ExamAnchor; fraction: [number, number, number]; howTo: string; whatFor: string; @@ -41,7 +55,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Ectoscopia Geral", system: "Geral", technique: "inspecao", - fraction: [0.5, 0.93, 0.85], + anchor: "head", + fraction: [0.5, 0.6, 0.8], howTo: "Observe o paciente por completo antes de tocar: estado geral, nível de consciência, postura, fácies, coloração da pele e mucosas, hidratação.", whatFor: "Primeira etapa de qualquer exame físico — muitas pistas diagnósticas (icterícia, cianose, palidez, dispneia) aparecem só de olhar, antes de qualquer manobra.", }, @@ -52,7 +67,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Foco Aórtico", system: "Cardiovascular", technique: "ausculta", - fraction: [0.44, 0.8, 0.85], + anchor: "thorax", + fraction: [0.42, 0.88, 0.85], howTo: "2º espaço intercostal, à direita do esterno. Ausculte com o diafragma do estetoscópio, paciente sentado ou deitado.", whatFor: "Melhor foco para a valva aórtica — sopros de estenose/insuficiência aórtica costumam ser mais audíveis aqui.", }, @@ -61,7 +77,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Foco Pulmonar", system: "Cardiovascular", technique: "ausculta", - fraction: [0.56, 0.8, 0.85], + anchor: "thorax", + fraction: [0.58, 0.88, 0.85], howTo: "2º espaço intercostal, à esquerda do esterno.", whatFor: "Melhor foco para a valva pulmonar — sopros de estenose/insuficiência pulmonar e desdobramento de B2.", }, @@ -70,7 +87,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Foco Aórtico Acessório (Erb)", system: "Cardiovascular", technique: "ausculta", - fraction: [0.56, 0.76, 0.85], + anchor: "thorax", + fraction: [0.58, 0.78, 0.85], howTo: "3º espaço intercostal, à esquerda do esterno.", whatFor: "Bom ponto para captar sopros diastólicos de insuficiência aórtica, que às vezes irradiam melhor pra cá do que pro foco aórtico clássico.", }, @@ -79,7 +97,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Foco Tricúspide", system: "Cardiovascular", technique: "ausculta", - fraction: [0.55, 0.71, 0.85], + anchor: "thorax", + fraction: [0.56, 0.68, 0.85], howTo: "4º-5º espaço intercostal, na borda esternal esquerda baixa.", whatFor: "Melhor foco para a valva tricúspide — sopros de insuficiência/estenose tricúspide.", }, @@ -88,7 +107,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Foco Mitral (Ápice)", system: "Cardiovascular", technique: "ausculta", - fraction: [0.62, 0.68, 0.85], + anchor: "thorax", + fraction: [0.68, 0.58, 0.85], howTo: "5º espaço intercostal esquerdo, na linha hemiclavicular (região do ictus cordis).", whatFor: "Melhor foco para a valva mitral — sopro de estenose mitral (ruflar diastólico) e insuficiência mitral (sopro holossistólico).", }, @@ -97,7 +117,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Ictus Cordis (Choque de Ponta)", system: "Cardiovascular", technique: "palpacao", - fraction: [0.63, 0.68, 0.86], + anchor: "thorax", + fraction: [0.69, 0.58, 0.87], howTo: "Palpe com a polpa dos dedos (ou a região tenar) o 5º espaço intercostal esquerdo, linha hemiclavicular.", whatFor: "Localiza o ápice do coração; desvio lateral ou impulso muito forte sugere cardiomegalia/hipertrofia ventricular.", }, @@ -106,7 +127,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Frêmito Precordial", system: "Cardiovascular", technique: "palpacao", - fraction: [0.58, 0.74, 0.85], + anchor: "thorax", + fraction: [0.6, 0.73, 0.85], howTo: "Apoie a palma da mão sobre o precórdio e sinta uma vibração (\"gato ronronando\").", whatFor: "Frêmito = sopro cardíaco intenso o bastante para ser sentido, geralmente indica lesão valvar significativa.", }, @@ -117,7 +139,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Ápice Pulmonar Direito", system: "Respiratório", technique: "ausculta", - fraction: [0.38, 0.815, 0.8], + anchor: "thorax", + fraction: [0.22, 0.92, 0.75], howTo: "Ausculte logo acima da clavícula direita, pedindo ao paciente para respirar fundo pela boca.", whatFor: "Murmúrio vesicular reduzido/ausente ou ruídos adventícios (estertores, sibilos) localizados aqui sugerem acometimento do ápice pulmonar direito.", }, @@ -126,7 +149,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Ápice Pulmonar Esquerdo", system: "Respiratório", technique: "ausculta", - fraction: [0.62, 0.815, 0.8], + anchor: "thorax", + fraction: [0.78, 0.92, 0.75], howTo: "Mesma manobra do lado esquerdo, logo acima da clavícula esquerda.", whatFor: "Comparação bilateral é essencial — assimetria entre os dois ápices é o achado mais importante.", }, @@ -135,7 +159,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Campo Médio Direito", system: "Respiratório", technique: "ausculta", - fraction: [0.35, 0.72, 0.78], + anchor: "thorax", + fraction: [0.18, 0.6, 0.7], howTo: "Ausculte na linha hemiclavicular/axilar direita, altura do 4º espaço intercostal.", whatFor: "Zona onde sopros tubários e estertores de pneumonia em lobo médio costumam aparecer primeiro.", }, @@ -144,7 +169,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Campo Médio Esquerdo", system: "Respiratório", technique: "ausculta", - fraction: [0.65, 0.72, 0.78], + anchor: "thorax", + fraction: [0.82, 0.6, 0.7], howTo: "Mesma altura, do lado esquerdo.", whatFor: "Comparação bilateral do murmúrio vesicular e de ruídos adventícios.", }, @@ -153,7 +179,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Base Pulmonar Direita", system: "Respiratório", technique: "ausculta", - fraction: [0.33, 0.645, 0.75], + anchor: "thorax", + fraction: [0.16, 0.2, 0.65], howTo: "Ausculte nas bases (região inferior do tórax, de preferência também pelas costas).", whatFor: "Local clássico de estertores crepitantes (insuficiência cardíaca, pneumonia de base) e de derrame pleural (murmúrio abolido).", }, @@ -162,7 +189,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Base Pulmonar Esquerda", system: "Respiratório", technique: "ausculta", - fraction: [0.67, 0.645, 0.75], + anchor: "thorax", + fraction: [0.84, 0.2, 0.65], howTo: "Mesma região, do lado esquerdo.", whatFor: "Comparação bilateral das bases — assimetria é o achado mais valioso.", }, @@ -171,7 +199,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Expansibilidade Torácica", system: "Respiratório", technique: "palpacao", - fraction: [0.5, 0.7, 0.15], + anchor: "thorax", + fraction: [0.5, 0.45, 0.05], howTo: "Coloque as mãos espalmadas nas costas do paciente, polegares próximos à linha média, e peça para respirar fundo — observe se os polegares se afastam simetricamente.", whatFor: "Redução da expansibilidade de um lado sugere derrame pleural, atelectasia ou pneumotórax naquele hemitórax.", }, @@ -180,7 +209,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Frêmito Toracovocal", system: "Respiratório", technique: "palpacao", - fraction: [0.6, 0.73, 0.8], + anchor: "thorax", + fraction: [0.68, 0.55, 0.8], howTo: "Apoie a borda ulnar das mãos no tórax e peça ao paciente para dizer \"trinta e três\" repetidamente, comparando os dois lados.", whatFor: "Aumentado em condensações (pneumonia); diminuído/abolido em derrame pleural, pneumotórax ou DPOC.", }, @@ -189,7 +219,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Percussão dos Campos Pulmonares", system: "Respiratório", technique: "percussao", - fraction: [0.4, 0.73, 0.8], + anchor: "thorax", + fraction: [0.25, 0.55, 0.8], howTo: "Percuta comparando os dois hemitóraxes, de cima para baixo, com o dedo plexímetro bem apoiado entre os espaços intercostais.", whatFor: "Som claro pulmonar normal; hipertimpanismo sugere pneumotórax; macicez/submacicez sugere derrame pleural ou consolidação.", }, @@ -200,7 +231,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Inspeção Abdominal", system: "Abdominal", technique: "inspecao", - fraction: [0.5, 0.63, 0.85], + anchor: "abdomen", + fraction: [0.5, 0.7, 0.85], howTo: "Com o paciente deitado e relaxado, observe forma, simetria, distensão, cicatrizes, circulação colateral e peristaltismo visível.", whatFor: "Abdome distendido, assimétrico ou com peristaltismo visível pode indicar obstrução, massa ou ascite.", }, @@ -209,7 +241,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Ausculta dos Ruídos Hidroaéreos", system: "Abdominal", technique: "ausculta", - fraction: [0.5, 0.58, 0.8], + anchor: "abdomen", + fraction: [0.5, 0.5, 0.8], howTo: "Ausculte ANTES de palpar ou percutir (essas manobras alteram o peristaltismo) — um ponto já basta, ouça por 1-2 minutos se estiver avaliando ausência de ruídos.", whatFor: "Ruídos aumentados sugerem obstrução inicial; ausência de ruídos (\"silêncio abdominal\") é sinal de íleo paralítico/abdome agudo.", }, @@ -218,7 +251,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Percussão Abdominal (Timpanismo)", system: "Abdominal", technique: "percussao", - fraction: [0.44, 0.56, 0.8], + anchor: "abdomen", + fraction: [0.4, 0.4, 0.8], howTo: "Percuta os 4 quadrantes — o timpanismo (som mais alto, de tambor) é normal, pela presença de gás nas alças intestinais.", whatFor: "Macicez onde deveria haver timpanismo sugere massa, ascite ou visceromegalia; timpanismo excessivo sugere distensão gasosa.", }, @@ -227,7 +261,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Delimitação da Loja Hepática", system: "Abdominal", technique: "percussao", - fraction: [0.35, 0.63, 0.8], + anchor: "abdomen", + fraction: [0.2, 0.75, 0.8], howTo: "Percuta de cima para baixo na linha hemiclavicular direita, do som claro pulmonar até encontrar macicez (borda superior do fígado), depois do timpanismo abdominal até a macicez (borda inferior).", whatFor: "Estima o tamanho do fígado; uma loja hepática maior que o esperado sugere hepatomegalia.", }, @@ -236,7 +271,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Palpação Superficial", system: "Abdominal", technique: "palpacao", - fraction: [0.56, 0.6, 0.78], + anchor: "abdomen", + fraction: [0.6, 0.55, 0.78], howTo: "Com a mão relaxada, palpe levemente todos os quadrantes, avaliando tensão da parede e sensibilidade — sempre deixando a área dolorosa por último.", whatFor: "Detecta defesa muscular voluntária/involuntária e localiza dor antes de aprofundar a palpação.", }, @@ -245,7 +281,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Palpação Profunda", system: "Abdominal", technique: "palpacao", - fraction: [0.56, 0.55, 0.78], + anchor: "abdomen", + fraction: [0.6, 0.4, 0.78], howTo: "Com as duas mãos sobrepostas, palpe mais profundamente para identificar massas, visceromegalias e pontos dolorosos específicos.", whatFor: "Identifica massas abdominais, contornos de vísceras e sinais de irritação peritoneal (dor à descompressão brusca).", }, @@ -254,7 +291,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Ponto de McBurney", system: "Abdominal", technique: "palpacao", - fraction: [0.32, 0.5, 0.78], + anchor: "abdomen", + fraction: [0.2, 0.12, 0.78], howTo: "Localize o ponto entre o terço lateral e os dois terços mediais da linha que liga a espinha ilíaca ântero-superior direita ao umbigo; palpe e avalie a descompressão brusca.", whatFor: "Dor à palpação (e principalmente à descompressão brusca) neste ponto é clássica de apendicite aguda.", }, @@ -263,7 +301,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Sinal de Murphy", system: "Abdominal", technique: "palpacao", - fraction: [0.34, 0.64, 0.78], + anchor: "abdomen", + fraction: [0.22, 0.78, 0.78], howTo: "Palpe sob o rebordo costal direito e peça ao paciente para inspirar fundo.", whatFor: "Sinal positivo = parada súbita da inspiração por dor — sugere colecistite aguda.", }, @@ -272,7 +311,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Palpação Hepática (Borda do Fígado)", system: "Abdominal", technique: "palpacao", - fraction: [0.33, 0.63, 0.8], + anchor: "abdomen", + fraction: [0.2, 0.7, 0.8], howTo: "Com a mão em garra sob o rebordo costal direito, peça para o paciente inspirar fundo e sinta a borda hepática \"escapar\" sob os dedos.", whatFor: "Avalia tamanho, consistência e borda do fígado — borda romba/endurecida sugere hepatopatia crônica.", }, @@ -281,7 +321,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Palpação do Baço", system: "Abdominal", technique: "palpacao", - fraction: [0.68, 0.64, 0.75], + anchor: "abdomen", + fraction: [0.82, 0.78, 0.75], howTo: "Com o paciente em decúbito lateral direito, palpe sob o rebordo costal esquerdo durante a inspiração profunda.", whatFor: "O baço normal não é palpável — se for palpável, geralmente indica esplenomegalia.", }, @@ -292,7 +333,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Pulso Carotídeo", system: "Vascular", technique: "palpacao", - fraction: [0.44, 0.87, 0.7], + anchor: "neck", + fraction: [0.4, 0.5, 0.6], howTo: "Palpe suavemente (nunca os dois lados ao mesmo tempo) na borda anterior do músculo esternocleidomastóideo, no pescoço.", whatFor: "Avalia amplitude e ritmo do pulso central; útil também pra avaliar sopros carotídeos com a ausculta.", }, @@ -301,7 +343,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Pulso Radial", system: "Vascular", technique: "palpacao", - fraction: [0.1, 0.58, 0.6], + anchor: "radiusR", + fraction: [0.5, 0.05, 0.6], howTo: "Palpe com 2-3 dedos na face anterior do punho, lateralmente (lado do polegar).", whatFor: "Pulso periférico mais usado na prática — avalia frequência, ritmo, amplitude e simetria entre os dois lados.", }, @@ -310,7 +353,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Pulso Femoral", system: "Vascular", technique: "palpacao", - fraction: [0.4, 0.44, 0.75], + anchor: "pelvis", + fraction: [0.25, 0.15, 0.75], howTo: "Palpe na região inguinal, no ponto médio entre a espinha ilíaca ântero-superior e a sínfise púbica.", whatFor: "Assimetria ou atraso entre pulso femoral e radial (\"pulso em atraso\") sugere coarctação de aorta.", }, @@ -319,7 +363,8 @@ export const EXAM_POINTS: ExamPoint[] = [ name: "Pulso Pedioso", system: "Vascular", technique: "palpacao", - fraction: [0.38, 0.02, 0.65], + anchor: "tibiaR", + fraction: [0.5, 0.02, 0.6], howTo: "Palpe no dorso do pé, lateralmente ao tendão do extensor longo do hálux.", whatFor: "Ausência sugere doença arterial periférica — compare sempre os dois pés.", }, diff --git a/frontend/src/routes/PhysicalExamPage.tsx b/frontend/src/routes/PhysicalExamPage.tsx index 0647943..812f9af 100644 --- a/frontend/src/routes/PhysicalExamPage.tsx +++ b/frontend/src/routes/PhysicalExamPage.tsx @@ -13,9 +13,18 @@ import { EXAM_POINTS, TECHNIQUES, type ExamTechnique } from "../data/physicalExa // manobra (inspeção/palpação/percussão/ausculta). Reaproveita o mesmo // do Atlas (via prop "children", ver Scene.tsx) em vez de um corpo // geométrico à parte. +// O esqueleto serve de referência pra posicionar os pontos de exame (ver +// ExamMarkers.tsx) mesmo que o usuário não tenha marcado essa camada — +// por isso é sempre buscado/carregado aqui, independente do toggle. +const ALWAYS_LOAD = ["esqueletico"]; + export default function PhysicalExamPage() { const visibleSystems = useAtlasStore((s) => s.visibleSystems); - const { bySlug } = useSystemStructures(visibleSystems); + const fetchSystems = useMemo( + () => new Set([...visibleSystems, ...ALWAYS_LOAD]), + [visibleSystems], + ); + const { bySlug } = useSystemStructures(fetchSystems); const selectedStructureId = useAtlasStore((s) => s.selectedStructureId); const [activeTechniques, setActiveTechniques] = useState>( @@ -103,7 +112,7 @@ export default function PhysicalExamPage() { )}
- + { setSelectedId(id); setRightOpen(true);