novo corpo
This commit is contained in:
1 parent
270cdbb965
commit
12342f442d
5 files changed
+230
-125
No files matched your search
@@ -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
|
||||||
|
<https://www.blender.org/download/demo-files/#assets>.
|
||||||
|
- **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
|
||||||
|
```
|
||||||
@@ -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}`);
|
||||||
@@ -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()
|
||||||
Binary file not shown.
@@ -1,135 +1,42 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
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
|
useGLTF.preload(MODEL_URL);
|
||||||
// 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],
|
|
||||||
];
|
|
||||||
|
|
||||||
function useTorsoGeometry() {
|
// Corpo humano esculpido de verdade (não caixas/cilindros) em posição
|
||||||
return useMemo(() => {
|
// anatômica, usado só pela página de Planos Anatômicos — ver
|
||||||
const points = TORSO_PROFILE.map(([r, y]) => new THREE.Vector2(r, y));
|
// data-pipeline/scripts/mannequin-asset/README.md pra origem (Blender
|
||||||
const geo = new THREE.LatheGeometry(points, 20);
|
// Studio "Human Base Meshes", CC0), licença e como essa malha foi
|
||||||
// O tronco humano é mais largo (ombro a ombro) do que profundo
|
// reposicionada da pose original (braços afastados) pra anatômica (braços
|
||||||
// (peito a costas) — achatando o eixo Z do sólido de revolução (que
|
// ao lado do corpo). Não tem nenhuma relação com o dataset Z-Anatomy usado
|
||||||
// sai circular) a gente sai de "cilindro" pra uma seção elíptica bem
|
// no Atlas — é só uma silhueta externa, sem estruturas internas.
|
||||||
// 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 (
|
|
||||||
<group>
|
|
||||||
{/* segmento superior (braço/coxa) */}
|
|
||||||
<mesh position={[x, midY + upperLength / 2, 0]}>
|
|
||||||
<capsuleGeometry args={[topRadius, Math.max(upperLength - topRadius * 1.6, 0.02), 4, 8]} />
|
|
||||||
<meshStandardMaterial color={SKIN} />
|
|
||||||
</mesh>
|
|
||||||
{/* segmento inferior (antebraço/perna), mais fino */}
|
|
||||||
<mesh position={[x, bottomY + lowerLength / 2, 0]}>
|
|
||||||
<capsuleGeometry args={[bottomRadius, Math.max(lowerLength - bottomRadius * 1.6, 0.02), 4, 8]} />
|
|
||||||
<meshStandardMaterial color={SKIN} />
|
|
||||||
</mesh>
|
|
||||||
{/* mão/pé — só uma cápsula achatada na ponta, o bastante pra não
|
|
||||||
"sumir" o membro no ar */}
|
|
||||||
<mesh
|
|
||||||
position={[x, bottomY - 0.03, footZ ?? 0]}
|
|
||||||
rotation={footZ ? [Math.PI / 2, 0, 0] : [0, 0, 0]}
|
|
||||||
>
|
|
||||||
<capsuleGeometry args={[bottomRadius * 0.85, footZ ? 0.09 : 0.05, 3, 6]} />
|
|
||||||
<meshStandardMaterial color={SKIN} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 <Scene>.
|
|
||||||
export function Mannequin() {
|
export function Mannequin() {
|
||||||
const torsoGeometry = useTorsoGeometry();
|
const { scene } = useGLTF(MODEL_URL);
|
||||||
|
|
||||||
return (
|
const normalized = useMemo(() => {
|
||||||
<group>
|
const clone = scene.clone(true);
|
||||||
{/* cabeça */}
|
const box = new THREE.Box3().setFromObject(clone);
|
||||||
<mesh position={[0, 1.65, 0]}>
|
const size = new THREE.Vector3();
|
||||||
<sphereGeometry args={[0.115, 20, 16]} />
|
box.getSize(size);
|
||||||
<meshStandardMaterial color={SKIN} />
|
const scale = size.y > 0 ? TARGET_HEIGHT / size.y : 1;
|
||||||
</mesh>
|
const center = new THREE.Vector3();
|
||||||
{/* pescoço */}
|
box.getCenter(center);
|
||||||
<mesh position={[0, 1.555, 0]}>
|
|
||||||
<capsuleGeometry args={[0.055, 0.03, 3, 8]} />
|
|
||||||
<meshStandardMaterial color={SKIN} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* tronco (revolução do perfil acima) */}
|
const group = new THREE.Group();
|
||||||
<mesh geometry={torsoGeometry}>
|
group.add(clone);
|
||||||
<meshStandardMaterial color={SKIN} />
|
// Centraliza em X/Z e põe o pé no chão (y=0) ANTES de escalar, depois
|
||||||
</mesh>
|
// 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) => (
|
return <primitive object={normalized} />;
|
||||||
<Limb
|
|
||||||
key={`arm-${side}`}
|
|
||||||
x={side * 0.235}
|
|
||||||
topY={1.42}
|
|
||||||
midY={1.08}
|
|
||||||
bottomY={0.83}
|
|
||||||
topRadius={0.052}
|
|
||||||
bottomRadius={0.042}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{[1, -1].map((side) => (
|
|
||||||
<Limb
|
|
||||||
key={`leg-${side}`}
|
|
||||||
x={side * 0.09}
|
|
||||||
topY={0.58}
|
|
||||||
midY={0.3}
|
|
||||||
bottomY={0.02}
|
|
||||||
topRadius={0.075}
|
|
||||||
bottomRadius={0.055}
|
|
||||||
footZ={0.09}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user