diff --git a/data-pipeline/scripts/mannequin-asset/README.md b/data-pipeline/scripts/mannequin-asset/README.md new file mode 100644 index 0000000..94fc307 --- /dev/null +++ b/data-pipeline/scripts/mannequin-asset/README.md @@ -0,0 +1,56 @@ +# Boneco realista de Planos Anatômicos + +`frontend/public/mannequin/body-male-realistic.glb` (usado pelo componente +`Mannequin` em `frontend/src/components/viewer/Mannequin.tsx`, exclusivo da +página `/planos-anatomicos`) não é um asset do Z-Anatomy — é um corpo humano +esculpido de verdade (não geometria procedural tipo caixa/cilindro), mas +extraído fora do dataset de anatomia real por dois motivos: (1) o Z-Anatomy é +pesado demais pra um boneco decorativo que só serve pra indicar planos/eixos, +e (2) essa página nunca precisou de fidelidade anatômica interna (músculos, +órgãos) — só de uma silhueta externa correta. + +## Fonte e licença + +- **Origem**: pacote oficial **Human Base Meshes** da Blender Studio + (equipe por trás dos "Blender Open Movies"), distribuído gratuitamente em + . +- **Licença**: CC0 (domínio público) — sem exigência de atribuição, mas + citamos a fonte aqui por transparência/manutenção futura. +- **Malha usada**: objeto `GEO-body_male_realistic` dentro do bundle + v1.4.1 (`human_base_meshes_bundle.blend`), nível 1 do modificador + Multires (~42k triângulos antes da otimização final — ver abaixo). + +## O que foi alterado + +A malha original vem numa pose relaxada tipo "A" (braços afastados do +corpo em ~20-23°), não em posição anatômica (braços estendidos ao lado do +corpo). Como o arquivo não tem armature/rig, a repose foi feita rotacionando +os vértices do braço em torno do ombro (ângulo medido a partir da própria +malha, não chutado), com uma transição suave (`smoothstep`) na axila pra +não deixar costura — ver `repose_and_export.py`. + +## Como reproduzir + +Precisa do Blender (`brew install --cask blender` no macOS, ou o pacote da +distro no Linux) e do Node do `data-pipeline` já instalado (`npm install`). + +```sh +# 1. Baixar o bundle oficial (~50MB) +curl -L -o /tmp/human-base-meshes.zip \ + "https://mirror.blender.org/demo/asset-bundles/human-base-meshes/human-base-meshes-bundle-v1.4.1.zip" +unzip -o /tmp/human-base-meshes.zip -d /tmp/hbm + +# 2. Reposicionar os braços e exportar o GLB bruto +blender --background \ + "/tmp/hbm/human-base-meshes-bundle-v1.4.1/human_base_meshes_bundle.blend" \ + --python data-pipeline/scripts/mannequin-asset/repose_and_export.py + +# 3. Otimizar (weld + dedup + quantize + remover UV não usada — sem +# textura, a malha usa só uma cor sólida) — reduz de ~3.3MB pra ~1.3MB +cd data-pipeline +node scripts/mannequin-asset/optimize.mjs + +# 4. Copiar pro frontend +cp /tmp/mannequin-realistic.optimized.glb \ + ../frontend/public/mannequin/body-male-realistic.glb +``` diff --git a/data-pipeline/scripts/mannequin-asset/optimize.mjs b/data-pipeline/scripts/mannequin-asset/optimize.mjs new file mode 100644 index 0000000..0554cd0 --- /dev/null +++ b/data-pipeline/scripts/mannequin-asset/optimize.mjs @@ -0,0 +1,29 @@ +// Otimiza o GLB bruto exportado pelo repose_and_export.py: solda vértices +// duplicados, remove dados não usados e quantiza as posições/normais pra +// menos bytes por vértice — sem precisar de um decoder novo no frontend +// (KHR_mesh_quantization já é suportado nativamente pelo GLTFLoader do +// three.js). A malha não tem textura (só cor sólida), então a UV é +// descartada antes de tudo — ela não serve pra nada e ainda impedia o +// quantize de comprimir aquele atributo. +import { NodeIO } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { prune, dedup, weld, quantize } from "@gltf-transform/functions"; + +const INPUT = "/tmp/mannequin-realistic.glb"; +const OUTPUT = "/tmp/mannequin-realistic.optimized.glb"; + +const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); +const doc = await io.read(INPUT); + +for (const mesh of doc.getRoot().listMeshes()) { + for (const prim of mesh.listPrimitives()) { + if (prim.getAttribute("TEXCOORD_0")) { + prim.setAttribute("TEXCOORD_0", null); + } + } +} + +await doc.transform(weld({ tolerance: 0.0001 }), dedup(), quantize(), prune()); + +await io.write(OUTPUT, doc); +console.log(`Otimizado: ${INPUT} -> ${OUTPUT}`); diff --git a/data-pipeline/scripts/mannequin-asset/repose_and_export.py b/data-pipeline/scripts/mannequin-asset/repose_and_export.py new file mode 100644 index 0000000..894ac9c --- /dev/null +++ b/data-pipeline/scripts/mannequin-asset/repose_and_export.py @@ -0,0 +1,113 @@ +"""Reposiciona os bracos do 'GEO-body_male_realistic' (bundle Human Base +Meshes da Blender Studio, CC0) da pose relaxada original para posicao +anatomica (bracos retos ao lado do corpo), e exporta pra GLB. + +Rodar de dentro do Blender, sem interface: + + blender --background human_base_meshes_bundle.blend \ + --python repose_and_export.py + +A malha nao tem armature, entao a repose e feita rotacionando os vertices +do braco em torno de um pivo no ombro. O angulo de rotacao e MEDIDO na +propria malha (distancia/posicao do vertice mais distante do ombro dentro +da faixa de altura do braco), nao e um valor chutado, entao continua +funcionando mesmo que uma versao futura do bundle mude a pose de origem. +Uma janela suave (smoothstep) evita costura na axila. +""" + +import math + +import bpy + +BODY_OBJECT = "GEO-body_male_realistic" +EYE_OBJECTS = ["GEO-body_male_realistic.eye.L", "GEO-body_male_realistic.eye.R"] +MULTIRES_LEVEL = 1 # ~42k triangulos: mantem a definicao muscular visivel a distancia normal de camera, bem mais leve que o nivel 3 (~677k) do arquivo original +OUTPUT_PATH = "/tmp/mannequin-realistic.glb" +SKIN_COLOR = (0.56, 0.64, 0.78, 1.0) # mesmo tom usado no Mannequin.tsx anterior + + +def smoothstep(edge0, edge1, x): + t = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0))) + return t * t * (3 - 2 * t) + + +def repose_arms(obj): + mesh = obj.data + zs = [v.co.z for v in mesh.vertices] + z_min, z_max = min(zs), max(zs) + height = z_max - z_min + centerline_x = sum(v.co.x for v in mesh.vertices) / len(mesh.vertices) + + shoulder_z = z_min + 0.84 * height + # comfortably above o alargamento do pe/tornozelo, abaixo de onde a mao + # fica nesta pose relaxada — deixa uma folga clara entre perna e braço + arm_low_z = z_min + 0.30 * height + torso_half_width = 0.20 # metade da largura biacromial aproximada, nesta escala + + for side in (1, -1): + pivot_x = centerline_x + side * torso_half_width * 0.85 + + candidates = [ + v + for v in mesh.vertices + if side * (v.co.x - centerline_x) > torso_half_width + and arm_low_z < v.co.z < shoulder_z - 0.02 + ] + far = max( + candidates, + key=lambda v: (v.co.x - pivot_x) ** 2 + (v.co.z - shoulder_z) ** 2, + ) + angle = math.atan2(far.co.x - pivot_x, -(far.co.z - shoulder_z)) + + for v in mesh.vertices: + dx = v.co.x - centerline_x + if side * dx <= 0 or v.co.z >= shoulder_z + 0.03: + continue + weight_x = smoothstep(torso_half_width * 0.7, torso_half_width * 1.15, abs(dx)) + weight_z = smoothstep(arm_low_z - 0.08, arm_low_z + 0.07, v.co.z) + weight = weight_x * weight_z + if weight <= 0.001: + continue + rot = -weight * angle + cos_a, sin_a = math.cos(rot), math.sin(rot) + px, pz = v.co.x - pivot_x, v.co.z - shoulder_z + v.co.x = pivot_x + (px * cos_a - pz * sin_a) + v.co.z = shoulder_z + (px * sin_a + pz * cos_a) + + mesh.update() + + +def main(): + body = bpy.data.objects[BODY_OBJECT] + repose_arms(body) + + for mod in body.modifiers: + if mod.type == "MULTIRES": + mod.levels = MULTIRES_LEVEL + + mat = bpy.data.materials.new("MannequinSkin") + mat.use_nodes = True + mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = SKIN_COLOR + mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.6 + + targets = [body] + [bpy.data.objects[n] for n in EYE_OBJECTS if n in bpy.data.objects] + for obj in targets: + obj.data.materials.clear() + obj.data.materials.append(mat) + + bpy.ops.object.select_all(action="DESELECT") + for obj in targets: + obj.select_set(True) + bpy.context.view_layer.objects.active = body + + bpy.ops.export_scene.gltf( + filepath=OUTPUT_PATH, + export_format="GLB", + use_selection=True, + export_apply=True, + export_yup=True, + ) + print(f"Exportado para {OUTPUT_PATH}") + + +main() diff --git a/frontend/public/mannequin/body-male-realistic.glb b/frontend/public/mannequin/body-male-realistic.glb new file mode 100644 index 0000000..79f4ea5 Binary files /dev/null and b/frontend/public/mannequin/body-male-realistic.glb differ diff --git a/frontend/src/components/viewer/Mannequin.tsx b/frontend/src/components/viewer/Mannequin.tsx index 012b84d..d86737d 100644 --- a/frontend/src/components/viewer/Mannequin.tsx +++ b/frontend/src/components/viewer/Mannequin.tsx @@ -1,135 +1,42 @@ import { useMemo } from "react"; +import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; -const SKIN = "#8fa3c8"; +const MODEL_URL = "/mannequin/body-male-realistic.glb"; +// Altura alvo em metros, decidida pra bater com as âncoras de seta/plano já +// calibradas em PlanesPage.tsx (ex: ombro em y≈1.4, quadril em y≈0.9). +const TARGET_HEIGHT = 1.78; -// Perfil do tronco (raio, altura) de baixo pra cima — vira uma superfície -// lisa e arredondada ao ser revolvido (LatheGeometry), bem mais parecido -// com um corpo de verdade do que uma caixa. Não é uma malha esculpida (não -// dá forma às costelas, escápulas etc.) — é só um contorno correto o -// bastante pra indicar plano/direção sobre uma região reconhecível, e por -// ser 100% procedural (nenhum arquivo importado) continua leve e sem -// nenhuma questão de licença, ao contrário de baixar um basemesh pronto. -const TORSO_PROFILE: [radius: number, y: number][] = [ - [0.13, 0.55], // base da pelve - [0.165, 0.63], // quadril (mais largo) - [0.145, 0.75], - [0.12, 0.9], // cintura (mais estreito) - [0.135, 1.0], - [0.16, 1.12], - [0.19, 1.25], // tórax/ombros (mais largo) - [0.175, 1.36], - [0.11, 1.46], // base do pescoço - [0.065, 1.52], -]; +useGLTF.preload(MODEL_URL); -function useTorsoGeometry() { - return useMemo(() => { - const points = TORSO_PROFILE.map(([r, y]) => new THREE.Vector2(r, y)); - const geo = new THREE.LatheGeometry(points, 20); - // O tronco humano é mais largo (ombro a ombro) do que profundo - // (peito a costas) — achatando o eixo Z do sólido de revolução (que - // sai circular) a gente sai de "cilindro" pra uma seção elíptica bem - // mais realista, sem precisar de um perfil 2D por eixo. - geo.scale(1, 1, 0.62); - return geo; - }, []); -} - -function Limb({ - x, - topY, - midY, - bottomY, - topRadius, - bottomRadius, - footZ, -}: { - x: number; - topY: number; - midY: number; - bottomY: number; - topRadius: number; - bottomRadius: number; - footZ?: number; -}) { - const upperLength = topY - midY; - const lowerLength = midY - bottomY; - return ( - - {/* segmento superior (braço/coxa) */} - - - - - {/* segmento inferior (antebraço/perna), mais fino */} - - - - - {/* mão/pé — só uma cápsula achatada na ponta, o bastante pra não - "sumir" o membro no ar */} - - - - - - ); -} - -// Boneco esquemático usado só pela página de Planos Anatômicos, pra ter -// uma silhueta humana reconhecível (tronco arredondado + membros afunilados) -// sobre a qual desenhar planos de corte e setas de direção — não precisa -// (nem deveria, pelo tamanho) da fidelidade do dataset real do Z-Anatomy, -// que é usado no Atlas e no Exame Físico via . +// Corpo humano esculpido de verdade (não caixas/cilindros) em posição +// anatômica, usado só pela página de Planos Anatômicos — ver +// data-pipeline/scripts/mannequin-asset/README.md pra origem (Blender +// Studio "Human Base Meshes", CC0), licença e como essa malha foi +// reposicionada da pose original (braços afastados) pra anatômica (braços +// ao lado do corpo). Não tem nenhuma relação com o dataset Z-Anatomy usado +// no Atlas — é só uma silhueta externa, sem estruturas internas. export function Mannequin() { - const torsoGeometry = useTorsoGeometry(); + const { scene } = useGLTF(MODEL_URL); - return ( - - {/* cabeça */} - - - - - {/* pescoço */} - - - - + const normalized = useMemo(() => { + const clone = scene.clone(true); + const box = new THREE.Box3().setFromObject(clone); + const size = new THREE.Vector3(); + box.getSize(size); + const scale = size.y > 0 ? TARGET_HEIGHT / size.y : 1; + const center = new THREE.Vector3(); + box.getCenter(center); - {/* tronco (revolução do perfil acima) */} - - - + const group = new THREE.Group(); + group.add(clone); + // Centraliza em X/Z e põe o pé no chão (y=0) ANTES de escalar, depois + // aplica a escala no grupo pai — assim o pivô da escala é a origem já + // corrigida, em vez do pivô torto que a malha trouxe do Blender. + clone.position.set(-center.x, -box.min.y, -center.z); + group.scale.setScalar(scale); + return group; + }, [scene]); - {[1, -1].map((side) => ( - - ))} - - {[1, -1].map((side) => ( - - ))} - - ); + return ; }