diff --git a/frontend/src/components/viewer/ExamMarkers.tsx b/frontend/src/components/viewer/ExamMarkers.tsx index b69153f..57e41b2 100644 --- a/frontend/src/components/viewer/ExamMarkers.tsx +++ b/frontend/src/components/viewer/ExamMarkers.tsx @@ -1,11 +1,17 @@ -import { useEffect, useState } from "react"; -import { useThree } from "@react-three/fiber"; +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 } from "../../data/physicalExam.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 @@ -130,10 +136,14 @@ 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) — @@ -206,9 +216,31 @@ export function ExamMarkers({ 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("anterior"); + const visibleSideRef = useRef("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.map((p) => { + {points + .filter((p) => p.side === visibleSide) + .map((p) => { const box = resolveBox(p.anchor); if (!box) return null; const [fx, fy, fz] = p.fraction; diff --git a/frontend/src/data/physicalExam.ts b/frontend/src/data/physicalExam.ts index b40575b..79cfb9d 100644 --- a/frontend/src/data/physicalExam.ts +++ b/frontend/src/data/physicalExam.ts @@ -19,6 +19,11 @@ // 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"; +// De que lado do corpo o ponto fica (usado pra só mostrar o marcador +// quando a câmera está olhando pro lado certo — ver ExamMarkers.tsx — +// senão um ponto da frente e um das costas no mesmo lugar aparente +// confundiriam qual está sendo clicado). +export type ExamSide = "anterior" | "posterior"; export type ExamAnchor = | "thorax" | "abdomen" @@ -43,6 +48,7 @@ export interface ExamPoint { system: string; technique: ExamTechnique; anchor: ExamAnchor; + side: ExamSide; fraction: [number, number, number]; howTo: string; whatFor: string; @@ -56,6 +62,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Geral", technique: "inspecao", anchor: "head", + side: "anterior", 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.", @@ -68,6 +75,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -78,6 +86,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -88,6 +97,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -98,6 +108,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -108,6 +119,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "ausculta", anchor: "thorax", + side: "anterior", 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).", @@ -118,6 +130,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "palpacao", anchor: "thorax", + side: "anterior", 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.", @@ -128,6 +141,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Cardiovascular", technique: "palpacao", anchor: "thorax", + side: "anterior", 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.", @@ -140,6 +154,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -150,6 +165,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -160,6 +176,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -170,6 +187,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "ausculta", anchor: "thorax", + side: "anterior", 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.", @@ -180,7 +198,8 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "ausculta", anchor: "thorax", - fraction: [0.16, 0.2, 0.65], + side: "posterior", + fraction: [0.16, 0.2, 0.15], 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).", }, @@ -190,7 +209,8 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "ausculta", anchor: "thorax", - fraction: [0.84, 0.2, 0.65], + side: "posterior", + fraction: [0.84, 0.2, 0.15], howTo: "Mesma região, do lado esquerdo.", whatFor: "Comparação bilateral das bases — assimetria é o achado mais valioso.", }, @@ -200,6 +220,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "palpacao", anchor: "thorax", + side: "posterior", 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.", @@ -210,6 +231,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "palpacao", anchor: "thorax", + side: "anterior", 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.", @@ -220,7 +242,8 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Respiratório", technique: "percussao", anchor: "thorax", - fraction: [0.25, 0.55, 0.8], + side: "posterior", + fraction: [0.4, 0.75, 0.15], 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.", }, @@ -232,6 +255,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "inspecao", anchor: "abdomen", + side: "anterior", 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.", @@ -242,6 +266,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "ausculta", anchor: "abdomen", + side: "anterior", 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.", @@ -252,6 +277,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "percussao", anchor: "abdomen", + side: "anterior", 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.", @@ -262,6 +288,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "percussao", anchor: "abdomen", + side: "anterior", 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.", @@ -272,6 +299,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "palpacao", anchor: "abdomen", + side: "anterior", 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.", @@ -282,6 +310,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "palpacao", anchor: "abdomen", + side: "anterior", 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).", @@ -292,6 +321,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "palpacao", anchor: "abdomen", + side: "anterior", 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.", @@ -302,6 +332,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "palpacao", anchor: "abdomen", + side: "anterior", 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.", @@ -312,6 +343,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "palpacao", anchor: "abdomen", + side: "anterior", 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.", @@ -322,6 +354,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Abdominal", technique: "palpacao", anchor: "abdomen", + side: "anterior", 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.", @@ -334,6 +367,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Vascular", technique: "palpacao", anchor: "neck", + side: "anterior", 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.", @@ -344,6 +378,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Vascular", technique: "palpacao", anchor: "radiusR", + side: "anterior", 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.", @@ -354,6 +389,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Vascular", technique: "palpacao", anchor: "pelvis", + side: "anterior", 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.", @@ -364,6 +400,7 @@ export const EXAM_POINTS: ExamPoint[] = [ system: "Vascular", technique: "palpacao", anchor: "tibiaR", + side: "anterior", 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 812f9af..52969a7 100644 --- a/frontend/src/routes/PhysicalExamPage.tsx +++ b/frontend/src/routes/PhysicalExamPage.tsx @@ -5,7 +5,7 @@ import { Scene } from "../components/viewer/Scene.js"; import { ExamMarkers } from "../components/viewer/ExamMarkers.js"; import { LayerToggle } from "../components/LayerToggle.js"; import { QuickRegionNav } from "../components/QuickRegionNav.js"; -import { EXAM_POINTS, TECHNIQUES, type ExamTechnique } from "../data/physicalExam.js"; +import { EXAM_POINTS, TECHNIQUES, type ExamTechnique, type ExamSide } from "../data/physicalExam.js"; // Módulo de estudo pro exame físico, sobre o corpo 3D REAL do Z-Anatomy // (mesmo visualizador do Atlas — camadas, navegação por região, câmera @@ -17,9 +17,15 @@ import { EXAM_POINTS, TECHNIQUES, type ExamTechnique } from "../data/physicalExa // 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"]; +// Visualmente, um corpo com a pele/regiões de superfície faz muito mais +// sentido pra exame físico do que o esqueleto nu (é na pele que se +// ausculta/palpa/percute) — esse é o layer mostrado por padrão ao entrar +// nesta página; o usuário pode trocar/adicionar outras camadas no painel. +const DEFAULT_VISIBLE = ["regioes"]; export default function PhysicalExamPage() { const visibleSystems = useAtlasStore((s) => s.visibleSystems); + const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems); const fetchSystems = useMemo( () => new Set([...visibleSystems, ...ALWAYS_LOAD]), [visibleSystems], @@ -31,9 +37,13 @@ export default function PhysicalExamPage() { () => new Set(TECHNIQUES.map((t) => t.key)), ); const [selectedId, setSelectedId] = useState(null); + const [viewSide, setViewSide] = useState("anterior"); const [leftOpen, setLeftOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => setVisibleSystems(DEFAULT_VISIBLE), []); + useEffect(() => { if (selectedStructureId) { setRightOpen(true); @@ -95,9 +105,10 @@ export default function PhysicalExamPage() {

- Ligue uma camada (ex: Esquelético) como referência, marque as - técnicas que quer estudar e clique nos pontos coloridos sobre o - corpo. Use o menu de regiões pra focar rápido numa parte do corpo. + Marque as técnicas que quer estudar e clique nos pontos coloridos + sobre o corpo. Use o menu de regiões pra focar rápido numa parte + do corpo, e gire o modelo pra ver os pontos da frente ou de trás + — só aparecem os do lado que você está olhando.

@@ -113,13 +124,22 @@ export default function PhysicalExamPage() {
- { - setSelectedId(id); - setRightOpen(true); - }} /> + { + setSelectedId(id); + setRightOpen(true); + }} + onSideChange={setViewSide} + /> +
+ Vista: {viewSide === "anterior" ? "Anterior" : "Posterior"} +
+