目录
[manifold3d 要求输入必须是严格的流形网格](#manifold3d 要求输入必须是严格的流形网格)
[首选方案:用 remesh-threejs 修复网格](#首选方案:用 remesh-threejs 修复网格)
js版本:
Manifold(manifoldcad):C++ 高性能三角网格布尔几何库,专门做 3D 模型布尔运算(并集 / 差集 / 交集)、修复非流形网格、生成干净闭合网格,3D AIGC、CAD、3D 打印常用。
- 有 Python binding:
pip install manifold3d - 有 JS/WASM binding:
npm install manifold-3d(只是可以在前端调用几何计算,不是 UI 框架)ManifoldCA... - 仓库:https://github.com/elalish/manifold
python版:
bash
pip install manifold3d
glb 中有多个子部件 相邻子部件有相交 manifold3d 可以检测相交面,提取相交轮廓线吗
manifold3d 识别相交轮廓后,一个子部件沿着另一个子部件表面挖空,补面,这样3d打印后还能拼接回去,怎么实现
相交挖孔:
python
from manifold3d import Manifold, Mesh
import trimesh
import numpy as np
from pathlib import Path
def manifold_to_trimesh(man: Manifold):
mesh = man.to_mesh()
return trimesh.Trimesh(
vertices=np.asarray(mesh.vert_properties),
faces=np.asarray(mesh.tri_verts)
)
def mesh_to_manifold(tmesh: trimesh.Trimesh):
"""trimesh -> Manifold,失败返回None"""
try:
vertices = np.ascontiguousarray(tmesh.vertices, dtype=np.float32)
faces = np.ascontiguousarray(tmesh.faces, dtype=np.uint32)
m3d_mesh = Mesh(vert_properties=vertices, tri_verts=faces)
man = Manifold(m3d_mesh)
return man
except Exception as e:
print(f"[ERR] 转Manifold失败: {e}")
return None
# ========== 配置 ==========
glb_path = r"C:\Users\ChanJing-01\Documents\glb_chai - 副本\niu_seg.glb"
out_dir = Path(r"C:\Users\ChanJing-01\Documents\glb_chai\output_cut")
out_dir.mkdir(exist_ok=True)
export_separate_stl = False
out_glb = out_dir / "result_single_side.glb"
# 加载 scene
scene = trimesh.load(glb_path)
parts = list(scene.geometry.items())
man_dict = {}
for name, tm in parts:
man = mesh_to_manifold(tm)
if man is not None:
man_dict[name] = man
print(f"[OK] 加载部件: {name}")
else:
print(f"[SKIP] {name} 无法转为Manifold,跳过")
name_list = list(man_dict.keys())
# 收集相交对
intersect_pairs = []
for i in range(len(name_list)):
n1 = name_list[i]
m1 = man_dict[n1]
for j in range(i + 1, len(name_list)):
n2 = name_list[j]
m2 = man_dict[n2]
overlap = m1 ^ m2
if not overlap.is_empty():
intersect_pairs.append((n1, n2))
print(f"[INTERSECT] {n1} <-> {n2} 准备:{n1}挖槽,{n2}不动")
cut_result = {k: v for k, v in man_dict.items()}
# ========== 关键改动:只单向挖,n1减去n2,n2不变 ==========
for n1, n2 in intersect_pairs:
cut_result[n1] = cut_result[n1] - man_dict[n2]
# 回写scene导出
new_scene = trimesh.Scene()
for name, man_obj in cut_result.items():
out_mesh = manifold_to_trimesh(man_obj)
new_scene.add_geometry(out_mesh, node_name=name)
if export_separate_stl:
stl_file = out_dir / f"{name}_cut.stl"
out_mesh.export(stl_file)
print(f"[EXPORT STL] {stl_file}")
new_scene.export(out_glb)
print(f"\n✅ 单向开槽完成,GLB输出:{out_glb}")
manifold3d 要求输入必须是严格的流形网格
首选方案:用 remesh-threejs 修复网格
remesh-threejs 是一个专门为 Three.js 生态设计的 TypeScript 库,核心能力就是修复和重网格化非流形曲面。它直接针对你担心的痛点。
它提供了你需要的几乎所有修复操作,可以组合成一条修复流水线:
-
检测问题 :用
analyzeManifold(geometry)或isManifold(geometry)快速检查网格是否包含非流形边或顶点。 -
针对性修复:
-
removeIsolatedVertices:清理孤立顶点。 -
removeDegenerateFaces:删除面积为零的退化面。 -
removeDuplicateFaces:删除重复面。 -
removeNonManifoldEdges:处理被超过两个面共享的边。 -
fillHoles:填补孔洞。 -
unifyNormals:统一法线朝向。
-
-
一键修复 :它提供了一个
repairMesh(geometry)函数,可以按最优顺序自动执行上述常见缺陷的修复。
3d人体拆件:
chai_main.py
python
import os
import glob
import time
import numpy as np
import trimesh
import manifold3d
from trimesh.boolean import difference, intersection, union
from trimesh.intersections import slice_mesh_plane
from trimesh.util import concatenate
from collections import defaultdict
# ============================================================
# 人体语义合并配置(与原脚本完全一致)
# ============================================================
HUMAN_CONFIG = {
"vertical_axis": 2,
"lateral_axis": 0,
"left_is_negative": True,
"top2_count": 2,
"head_min_height_ratio": 0.60,
"head_max_body_ratio": 3.0,
"head_max_elongation": 1.60,
"head_score_height_weight": 0.55,
"head_score_volume_weight": 0.45,
"body_candidate_count": 5,
"body_min_height_ratio": 0.22,
"body_max_height_ratio": 0.70,
"body_prefer_height_ratio": 0.45,
"body_height_spread": 0.28,
"body_top_min_ratio": 0.32,
"body_max_lateral_ratio": 0.25,
"body_score_volume_weight": 0.35,
"body_score_height_weight": 0.45,
"body_score_lateral_weight": 0.20,
"head_accessory_volume_ratio": 0.50,
"head_accessory_distance_ratio": 0.22,
"neck_volume_ratio": 0.20,
"neck_distance_ratio": 0.15,
"head_accessory_lateral_extra": 0.05,
"head_accessory_lateral_max": 0.10,
"head_accessory_max_lateral": 0.18,
"head_accessory_height_margin": 0.15,
"arm_elongation_min": 1.35,
"arm_body_volume_max_ratio": 0.80,
"arm_lateral_min_ratio": 0.18,
"arm_height_min_ratio": 0.20,
"arm_height_max_ratio": 0.82,
"arm_distance_ratio": 0.45,
"leg_elongation_min": 1.30,
"leg_body_volume_max_ratio": 1.00,
"leg_height_max_ratio": 0.30,
"leg_distance_ratio": 0.45,
"leg_lateral_min_ratio": 0.08,
"leg_lateral_max_ratio": 0.22,
"leg_accessory_volume_ratio": 2.0,
"leg_accessory_distance_ratio": 0.28,
"leg_below_core_threshold": 0.05,
"general_distance_ratio": 0.35,
"tiny_volume_ratio": 0.08,
"limb_min_score": 0.45,
"midline_protect_lateral": 0.15,
"midline_height_margin": 0.08,
"hand_height_max_ratio": 0.45,
"hand_lateral_min_ratio": 0.22,
"hand_volume_ratio": 3.0,
"head_zone_xy_margin": 0.10,
"head_zone_height_offset": 0.12,
"sym_leg_band_extra": 0.12,
"sym_leg_lat_diff_max": 0.06,
"sym_leg_height_diff_max": 0.05,
"verbose_fragment_score": True,
}
# ============================================================
# 基础几何工具(trimesh 版)
# ============================================================
def is_valid_mesh(mesh):
return mesh is not None and not mesh.is_empty and len(mesh.faces) > 0
def get_volume(mesh):
if not is_valid_mesh(mesh):
return 0.0
try:
return float(abs(mesh.volume))
except Exception:
return 0.0
def get_centroid(mesh):
if not is_valid_mesh(mesh):
return np.zeros(3)
return np.asarray(mesh.centroid, dtype=np.float64)
def get_bbox_info(mesh):
if not is_valid_mesh(mesh):
return {"min": np.zeros(3), "max": np.zeros(3),
"size": np.zeros(3), "center": np.zeros(3)}
bmin, bmax = mesh.bounds
bmin = np.asarray(bmin, dtype=np.float64)
bmax = np.asarray(bmax, dtype=np.float64)
return {
"min": bmin, "max": bmax,
"size": bmax - bmin,
"center": (bmin + bmax) * 0.5,
}
def get_elongation(mesh):
info = get_bbox_info(mesh)
size = np.sort(np.asarray(info["size"]))[::-1]
if len(size) < 2 or size[1] < 1e-8:
return 999.0
return float(size[0] / size[1])
def get_distance(a, b):
return float(np.linalg.norm(np.asarray(a) - np.asarray(b)))
def compute_bbox_gap(mesh_a, mesh_b):
ia, ib = get_bbox_info(mesh_a), get_bbox_info(mesh_b)
gap = np.zeros(3, dtype=np.float64)
for axis in range(3):
a_min, a_max = ia["min"][axis], ia["max"][axis]
b_min, b_max = ib["min"][axis], ib["max"][axis]
if a_max < b_min:
gap[axis] = b_min - a_max
elif b_max < a_min:
gap[axis] = a_min - b_max
else:
gap[axis] = 0.0
return float(np.linalg.norm(gap))
def calculate_scene_info(meshes):
bounds = np.array([m.bounds for m in meshes if is_valid_mesh(m)])
if len(bounds) == 0:
raise ValueError("没有有效顶点")
bbox_min = bounds[:, 0, :].min(axis=0)
bbox_max = bounds[:, 1, :].max(axis=0)
size = bbox_max - bbox_min
return {
"min": bbox_min, "max": bbox_max, "size": size,
"center": (bbox_min + bbox_max) * 0.5,
"height": size[HUMAN_CONFIG["vertical_axis"]],
"width": size[HUMAN_CONFIG["lateral_axis"]],
"diagonal": float(np.linalg.norm(size)),
}
def normalized_height(point, scene_info):
axis = HUMAN_CONFIG["vertical_axis"]
mn, mx = scene_info["min"][axis], scene_info["max"][axis]
return 0.5 if mx - mn < 1e-8 else float((point[axis] - mn) / (mx - mn))
def normalized_lateral(point, scene_info, body_center):
axis = HUMAN_CONFIG["lateral_axis"]
width = scene_info["width"]
return 0.0 if width < 1e-8 else float((point[axis] - body_center[axis]) / width)
# ============================================================
# 坐标轴向校正:glTF 是 Y-up,本模块语义规则按 Z-up 编写
# ============================================================
# trimesh 读取 glTF/GLB 时保留原始数据坐标(只套 node 变换,不做 Y-up→Z-up),
# 而 Blender 导入 glTF 会自动转成 Z-up。
# 如果不校正,HUMAN_CONFIG["vertical_axis"]=2 实际取到的是「前后深度」轴,
# 所有高度判断全部失效,最终只剩 head / body 两个部件。
YUP_TO_ZUP = np.array([
[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, -1.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
], dtype=np.float64)
ZUP_TO_YUP = np.array([
[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, -1.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
], dtype=np.float64)
def detect_bounds_up_axis(meshes):
"""返回整体 bbox 跨度最大的轴(站立人形即高度轴)"""
bounds = np.array([m.bounds for m in meshes if is_valid_mesh(m)])
if len(bounds) == 0:
return 2
size = bounds[:, 1, :].max(axis=0) - bounds[:, 0, :].min(axis=0)
return int(np.argmax(size))
def convert_to_zup(meshes):
"""若是 Y-up 数据则就地转成 Z-up,返回是否发生过转换"""
if detect_bounds_up_axis(meshes) != 1:
return False
for m in meshes:
m.apply_transform(YUP_TO_ZUP)
return True
def convert_to_yup(meshes):
"""导出前把 Z-up 转回 glTF 规范的 Y-up(返回新的 mesh 列表,不破坏入参)"""
out = []
for m in meshes:
m2 = m.copy()
m2.apply_transform(ZUP_TO_YUP)
out.append(m2)
return out
# ============================================================
# 语义分类逻辑(原样保留,只把 obj 换成 mesh)
# ============================================================
def _score_body_candidate(idx, meshes, volumes, centroids, head_idx, scene_info):
va = HUMAN_CONFIG["vertical_axis"]
cfg = HUMAN_CONFIG
obj_cent = centroids[idx]
h = normalized_height(obj_cent, scene_info)
lat = abs(normalized_lateral(obj_cent, scene_info, scene_info["center"]))
bbox = get_bbox_info(meshes[idx])
h_top = (bbox["max"][va] - scene_info["min"][va]) / max(scene_info["size"][va], 1e-8)
head_volume = volumes[head_idx] if head_idx is not None else volumes[0]
vol_ratio = volumes[idx] / max(head_volume, 1e-12)
if h < cfg["body_min_height_ratio"] or h > cfg["body_max_height_ratio"]:
return -1.0, f"质心高度异常 h={h:.3f}"
if h_top < cfg["body_top_min_ratio"]:
return -1.0, f"顶部过低 top={h_top:.3f}"
if lat > cfg["body_max_lateral_ratio"]:
return -1.0, f"横向过远 lat={lat:.3f}"
if head_idx is not None and h >= normalized_height(centroids[head_idx], scene_info):
return -1.0, "高于 HEAD 质心"
height_score = max(0.0, 1.0 - abs(h - cfg["body_prefer_height_ratio"]) / cfg["body_height_spread"])
lateral_score = max(0.0, 1.0 - lat / cfg["body_max_lateral_ratio"])
volume_score = min(vol_ratio, 1.0)
score = (cfg["body_score_volume_weight"] * volume_score +
cfg["body_score_height_weight"] * height_score +
cfg["body_score_lateral_weight"] * lateral_score)
return score, f"h={h:.3f} top={h_top:.3f} lat={lat:.3f} vol_ratio={vol_ratio:.3f}"
def find_head_and_body(meshes, volumes, centroids, sorted_indices, scene_info):
if len(sorted_indices) == 0:
raise ValueError("没有部件")
if len(sorted_indices) == 1:
return None, sorted_indices[0]
n_top = min(HUMAN_CONFIG["body_candidate_count"], len(sorted_indices))
top_candidates = sorted_indices[:n_top]
candidate_info = []
for idx in top_candidates:
h = normalized_height(centroids[idx], scene_info)
elon = get_elongation(meshes[idx])
candidate_info.append({"idx": idx, "h": h, "elon": elon,
"shape_ok": elon <= HUMAN_CONFIG["head_max_elongation"]})
valid_head = [c for c in candidate_info if c["shape_ok"]]
if valid_head:
max_vol = max(volumes[c["idx"]] for c in valid_head)
best_score, head_idx = -float("inf"), None
for c in valid_head:
vol_ratio = volumes[c["idx"]] / max(max_vol, 1e-12)
score = (HUMAN_CONFIG["head_score_height_weight"] * c["h"] +
HUMAN_CONFIG["head_score_volume_weight"] * vol_ratio)
if score > best_score:
best_score, head_idx = score, c["idx"]
else:
idx_a, idx_b = top_candidates[0], top_candidates[1]
ha = normalized_height(centroids[idx_a], scene_info)
hb = normalized_height(centroids[idx_b], scene_info)
head_idx = idx_a if ha >= hb else idx_b
body_candidates = [i for i in top_candidates if i != head_idx]
body_scored = []
for idx in body_candidates:
score, _ = _score_body_candidate(idx, meshes, volumes, centroids, head_idx, scene_info)
body_scored.append((score, idx))
body_scored.sort(reverse=True)
if body_scored and body_scored[0][0] > 0:
body_idx = body_scored[0][1]
else:
body_idx = body_candidates[0] if body_candidates else sorted_indices[0]
return head_idx, body_idx
def score_limb_candidate(idx, limb_type, meshes, volumes, centroids, body_idx, head_idx, scene_info):
body_cent = centroids[body_idx]
obj_cent = centroids[idx]
body_volume = volumes[body_idx]
obj_volume = volumes[idx]
scene_height = max(scene_info["height"], 1e-8)
height = normalized_height(obj_cent, scene_info)
lateral = normalized_lateral(obj_cent, scene_info, body_cent)
distance = get_distance(obj_cent, body_cent)
elongation = get_elongation(meshes[idx])
volume_ratio = obj_volume / max(body_volume, 1e-12)
body_h_norm = normalized_height(body_cent, scene_info)
score = 0.0
reasons = []
if "left" in limb_type:
correct_side = lateral < 0 if HUMAN_CONFIG["left_is_negative"] else lateral > 0
else:
correct_side = lateral > 0 if HUMAN_CONFIG["left_is_negative"] else lateral < 0
score += 0.25 if correct_side else -0.30
reasons.append("侧别正确" if correct_side else "侧别错误")
abs_lateral = abs(lateral)
if "arm" in limb_type:
if abs_lateral >= HUMAN_CONFIG["arm_lateral_min_ratio"]:
score += 0.25
elif abs_lateral >= HUMAN_CONFIG["arm_lateral_min_ratio"] * 0.6:
score -= 0.30
else:
score -= 0.60
else:
if HUMAN_CONFIG["leg_lateral_min_ratio"] <= abs_lateral <= HUMAN_CONFIG["leg_lateral_max_ratio"]:
score += 0.20
elif abs_lateral > HUMAN_CONFIG["leg_lateral_max_ratio"]:
score -= 0.20
else:
score -= 0.10
if "arm" in limb_type:
if HUMAN_CONFIG["arm_height_min_ratio"] <= height <= HUMAN_CONFIG["arm_height_max_ratio"]:
score += 0.20
else:
score -= 0.20
score += 0.05 if height > body_h_norm else -0.05
else:
if height <= HUMAN_CONFIG["leg_height_max_ratio"]:
score += 0.25
else:
score -= 0.25
score += 0.1 * (1.0 - min(height / HUMAN_CONFIG["leg_height_max_ratio"], 1.0))
if elongation >= (HUMAN_CONFIG["arm_elongation_min"] if "arm" in limb_type
else HUMAN_CONFIG["leg_elongation_min"]):
score += 0.15
else:
score -= 0.10
if volume_ratio <= (HUMAN_CONFIG["arm_body_volume_max_ratio"] if "arm" in limb_type
else HUMAN_CONFIG["leg_body_volume_max_ratio"]):
score += 0.05
else:
score -= 0.05
max_dist = scene_height * (HUMAN_CONFIG["arm_distance_ratio"] if "arm" in limb_type
else HUMAN_CONFIG["leg_distance_ratio"])
score += 0.10 if distance <= max_dist else -0.05
return max(0.0, min(1.0, score)), reasons
def is_head_accessory(idx, meshes, volumes, centroids, head_idx, body_idx, scene_info):
if head_idx is None or idx == head_idx:
return False
head_cent, obj_cent = centroids[head_idx], centroids[idx]
head_volume, obj_volume = volumes[head_idx], volumes[idx]
va, la = HUMAN_CONFIG["vertical_axis"], HUMAN_CONFIG["lateral_axis"]
if obj_volume / max(head_volume, 1e-12) > HUMAN_CONFIG["head_accessory_volume_ratio"]:
return False
body_cent = centroids[body_idx]
obj_lat_abs = abs(normalized_lateral(obj_cent, scene_info, body_cent))
obj_h_norm = normalized_height(obj_cent, scene_info)
if (obj_lat_abs >= HUMAN_CONFIG["arm_lateral_min_ratio"] and
obj_h_norm <= HUMAN_CONFIG["arm_height_max_ratio"]):
return False
if obj_lat_abs > HUMAN_CONFIG["head_accessory_max_lateral"]:
if obj_h_norm < HUMAN_CONFIG.get("head_min_height_ratio", 0.60) + 0.10:
return False
head_info = get_bbox_info(meshes[head_idx])
obj_info = get_bbox_info(meshes[idx])
head_lat_center = (head_info["min"][la] + head_info["max"][la]) * 0.5
head_w = max(head_info["max"][la] - head_info["min"][la], 1e-8)
obj_lat_center = (obj_info["min"][la] + obj_info["max"][la]) * 0.5
lat_offset_norm = abs(obj_lat_center - head_lat_center) / head_w
obj_h_size = max(obj_info["max"][va] - obj_info["min"][va], 1e-8)
overlap_size = min(head_info["max"][va], obj_info["max"][va]) - max(head_info["min"][va], obj_info["min"][va])
overlap_ratio = overlap_size / obj_h_size
# ★ 底部守卫:头附件(眼镜/帽/角/耳朵)的底部不能低于头底。
# 否则尿布/围兜/下身衣服这类「与巨头 bbox 垂直重叠的中轴小件」会被误判成头附件。
if (lat_offset_norm <= 0.80 and overlap_ratio >= 0.50
and obj_info["min"][va] >= head_info["min"][va] - 1e-6):
return True
obj_lat = abs(normalized_lateral(obj_cent, scene_info, body_cent))
head_lat = abs(normalized_lateral(head_cent, scene_info, body_cent))
lat_limit = max(head_lat + HUMAN_CONFIG["head_accessory_lateral_extra"],
HUMAN_CONFIG["head_accessory_lateral_max"])
if obj_lat > lat_limit:
return False
max_distance = scene_info["height"] * HUMAN_CONFIG["head_accessory_distance_ratio"]
bbox_gap = compute_bbox_gap(meshes[head_idx], meshes[idx])
head_h_norm = normalized_height(head_cent, scene_info)
if bbox_gap <= max_distance and obj_h_norm >= head_h_norm - HUMAN_CONFIG["head_accessory_height_margin"]:
return True
return False
def classify_fragment(idx, meshes, volumes, centroids, head_idx, body_idx, scene_info):
if idx == head_idx:
return "head", 1.0, ["HEAD核心"]
if idx == body_idx:
return "body", 1.0, ["BODY核心"]
body_cent = centroids[body_idx]
obj_cent = centroids[idx]
height = normalized_height(obj_cent, scene_info)
if head_idx is not None:
head_info = get_bbox_info(meshes[head_idx])
head_bottom_h = normalized_height(head_info["min"], scene_info)
if (height >= head_bottom_h + HUMAN_CONFIG["head_zone_height_offset"]
and volumes[idx] / max(volumes[head_idx], 1e-12) <= HUMAN_CONFIG["head_accessory_volume_ratio"]):
va, la = HUMAN_CONFIG["vertical_axis"], HUMAN_CONFIG["lateral_axis"]
da = 3 - va - la
m_la = (head_info["max"][la] - head_info["min"][la]) * HUMAN_CONFIG["head_zone_xy_margin"]
m_da = (head_info["max"][da] - head_info["min"][da]) * HUMAN_CONFIG["head_zone_xy_margin"]
if (head_info["min"][la] - m_la <= obj_cent[la] <= head_info["max"][la] + m_la
and head_info["min"][da] - m_da <= obj_cent[da] <= head_info["max"][da] + m_da):
return "head", 0.9, ["头部附件(耳/发)"]
if is_head_accessory(idx, meshes, volumes, centroids, head_idx, body_idx, scene_info):
return "head", 0.95, ["靠近头部", "小部件/脖子"]
lateral = abs(normalized_lateral(obj_cent, scene_info, body_cent))
if lateral < HUMAN_CONFIG["midline_protect_lateral"] and height >= HUMAN_CONFIG["leg_height_max_ratio"]:
body_h_norm = normalized_height(body_cent, scene_info)
if height > body_h_norm + HUMAN_CONFIG["midline_height_margin"]:
return "head", 0.75, ["近中线高位"]
return "body", 0.75, ["近中线中低位"]
candidates = []
for limb_type in ["left_arm", "right_arm", "left_leg", "right_leg"]:
s, r = score_limb_candidate(idx, limb_type, meshes, volumes, centroids,
body_idx, head_idx, scene_info)
candidates.append((s, limb_type, r))
candidates.sort(key=lambda x: x[0], reverse=True)
best_score, best_type, best_reasons = candidates[0]
if best_score >= HUMAN_CONFIG["limb_min_score"]:
return best_type, best_score, best_reasons
return "body", 0.70, ["四肢置信度不足"]
# ============================================================
# 布尔运算 / 平面切割 ------ manifold3d 核心
# ============================================================
# ============================================================
# 网格修复 ------ manifold 布尔的前置条件
# ============================================================
def repair_mesh(mesh):
"""
把网格修成 manifold 可接受的封闭实体,返回新网格(不破坏入参)。
★ 关键背景:
GLB/PLY 里拆分出来的部件常常是「几何上封闭、但顶点被拆成多份」的
网格(is_watertight=False、euler_number 异常大)。这类网格送进
manifold 做 union 时不会抛异常,而是**静默返回空网格**,
导致该语义组整件消失(表现为导出 GLB 里某个节点没有 mesh,
严重时整个文件 0 个 mesh,BambuStudio 报「此文件不包含任何数据」)。
process() 内部会 merge_vertices(),把重复顶点缝合起来,
实测即可让这类网格恢复 is_watertight=True(euler_number 回到 2)。
"""
if not is_valid_mesh(mesh):
return mesh
out = mesh.copy()
try:
out.process()
except Exception as e:
print(f" [WARN] mesh.process 失败: {e}")
try:
if not out.is_watertight:
for _ in range(6):
out.fill_holes()
if out.is_watertight:
break
out.fix_normals()
except Exception as e:
print(f" [WARN] fill_holes 失败: {e}")
return out
def repair_meshes(meshes):
"""就地修复一批网格,返回修复成功的数量"""
fixed = 0
for i, m in enumerate(meshes):
if not is_valid_mesh(m):
continue
try:
if m.is_watertight:
continue
except Exception:
pass
meshes[i] = repair_mesh(m)
fixed += 1
return fixed
def boolean_op(meshes, operation, engine='manifold'):
"""
统一入口:difference / intersection / union,强制 manifold 引擎。
★ 与旧版的区别:
1) 运算前先 repair,确保输入封闭;
2) 运算后校验结果非空------manifold 遇到坏网格会「静默返回空」而不抛异常,
旧版只看 None,于是把空结果当成合法结果用了;
3) 兜底修复改用 trimesh 4.x 真实存在的 API
(旧版用的 remove_degenerate_faces / remove_duplicate_faces 在新版已移除,
一进兜底分支就会 AttributeError)。
"""
if len(meshes) < 2:
return meshes[0] if meshes else None
def _run(ms):
if operation == 'difference':
return difference(ms, engine=engine, check_volume=False)
elif operation == 'intersection':
return intersection(ms, engine=engine, check_volume=False)
elif operation == 'union':
return union(ms, engine=engine, check_volume=False)
raise ValueError(operation)
def _ok(res):
# manifold 失败时常常返回 None 或 faces 为 0 的「空网格」
if res is None:
return False
try:
return is_valid_mesh(res) and get_volume(res) > 1e-12
except Exception:
return False
try:
res = _run(meshes)
if _ok(res):
return res
print(f" [WARN] manifold {operation} 返回空结果,修复输入后重试")
except Exception as e:
print(f" [WARN] manifold 布尔失败 ({operation}): {e},修复输入后重试")
fixed = [repair_mesh(m) for m in meshes]
try:
res = _run(fixed)
if _ok(res):
return res
print(f" [ERR] 修复后 {operation} 仍为空")
except Exception as e2:
print(f" [ERR] 修复后仍失败: {e2}")
return None
def split_mesh_by_plane(mesh, plane_origin, plane_normal):
"""等价于 Blender 的 mesh.bisect:返回负侧、正侧两个闭合网格"""
try:
neg = slice_mesh_plane(mesh, plane_normal=plane_normal,
plane_origin=plane_origin, cap=True)
except Exception as e:
print(f" [WARN] slice 负侧失败: {e}")
neg = None
try:
pos = slice_mesh_plane(mesh, plane_normal=-np.asarray(plane_normal),
plane_origin=plane_origin, cap=True)
except Exception as e:
print(f" [WARN] slice 正侧失败: {e}")
pos = None
return neg, pos
def decompose_manifold(mesh):
"""用 manifold3d 的 Decompose 把一个网格拆成多个连通实体"""
try:
m3d = manifold3d.Manifold(
manifold3d.Mesh(
vert_properties=np.ascontiguousarray(mesh.vertices, dtype=np.float32),
tri_verts=np.ascontiguousarray(mesh.faces, dtype=np.uint32),
)
)
parts = m3d.decompose()
out = []
for p in parts:
pm = p.to_mesh()
out.append(trimesh.Trimesh(
vertices=np.asarray(pm.vert_properties),
faces=np.asarray(pm.tri_verts),
))
return out
except Exception as e:
print(f" [WARN] decompose 失败: {e}")
return [mesh]
def resolve_limb_conflicts(assignments, meshes, volumes, centroids, scene_info,
body_idx, head_idx, limb_cores, names=None):
"""
手 / 脚 / 小碎片 / 对称大腿根 的归组修正。
与 Blender 版 chai_body.resolve_limb_conflicts 语义一致(合并腿拆分除外,
由 cut_and_merge_human 单独处理)。
"""
cfg = HUMAN_CONFIG
va = cfg["vertical_axis"]
scene_height = max(scene_info["height"], 1e-8)
body_cent = centroids[body_idx]
body_h_norm = normalized_height(body_cent, scene_info)
if names is None:
names = [f"mesh_{i}" for i in range(len(meshes))]
result = dict(assignments)
has_legs = "left_leg" in limb_cores or "right_leg" in limb_cores
# ---------- 手:低位 + 身体两侧 → 归入同侧手臂 ----------
arm_core_map = {t: i for t, i in limb_cores.items() if "arm" in t}
if arm_core_map:
for idx in range(len(meshes)):
if idx == head_idx or idx == body_idx or idx in limb_cores.values():
continue
if result.get(idx, {}).get("group") is None:
continue
obj_cent = centroids[idx]
obj_h_norm = normalized_height(obj_cent, scene_info)
if obj_h_norm >= cfg["hand_height_max_ratio"]:
continue
obj_lat = normalized_lateral(obj_cent, scene_info, body_cent)
if abs(obj_lat) < cfg["hand_lateral_min_ratio"]:
continue
side = ("left_arm" if obj_lat < 0 else "right_arm") if cfg["left_is_negative"] \
else ("right_arm" if obj_lat < 0 else "left_arm")
if side in arm_core_map:
candidates = [(side, arm_core_map[side])]
else:
candidates = list(arm_core_map.items())
best_type, best_idx = min(
candidates, key=lambda kv: compute_bbox_gap(meshes[idx], meshes[kv[1]]))
gap = compute_bbox_gap(meshes[idx], meshes[best_idx])
vol_ratio = volumes[idx] / max(volumes[best_idx], 1e-12)
if (gap <= scene_height * cfg["arm_distance_ratio"]
and vol_ratio <= cfg["hand_volume_ratio"]):
if result[idx]["group"] != best_type:
print(f" ✋ 手部识别: {names[idx]} "
f"(h={obj_h_norm:.3f}, lat={obj_lat:+.3f}) -> {best_type}")
result[idx] = {"group": best_type, "score": 0.9,
"reasons": ["低位+身体两侧=手", f"归并到{best_type}"]}
# ---------- 脚 ----------
if has_legs:
for idx in range(len(meshes)):
if idx == head_idx or idx == body_idx or idx in limb_cores.values():
continue
if result.get(idx, {}).get("group") is None:
continue
obj_cent = centroids[idx]
obj_h_norm = normalized_height(obj_cent, scene_info)
if obj_h_norm >= cfg["leg_height_max_ratio"]:
continue
if abs(normalized_lateral(obj_cent, scene_info, body_cent)) >= \
cfg["leg_lateral_max_ratio"]:
continue
best_leg, min_gap = None, float("inf")
for leg_type in ["left_leg", "right_leg"]:
if leg_type not in limb_cores:
continue
leg_idx = limb_cores[leg_type]
leg_cent = centroids[leg_idx]
if obj_cent[va] >= leg_cent[va] - scene_height * cfg["leg_below_core_threshold"]:
continue
gap = compute_bbox_gap(meshes[idx], meshes[leg_idx])
if gap < min_gap:
min_gap, best_leg = gap, leg_type
if best_leg is None:
continue
leg_idx = limb_cores[best_leg]
vol_ratio = volumes[idx] / max(volumes[leg_idx], 1e-12)
if (vol_ratio <= cfg["leg_accessory_volume_ratio"]
and min_gap <= scene_height * cfg["leg_accessory_distance_ratio"]):
result[idx] = {"group": best_leg, "score": 0.9,
"reasons": ["脚部附件", f"归并到{best_leg}"]}
# ---------- 小碎片归组 ----------
core_groups = {}
if head_idx is not None:
core_groups[head_idx] = "head"
core_groups[body_idx] = "body"
for limb_type, core_idx in limb_cores.items():
core_groups[core_idx] = limb_type
core_indices = list(core_groups.keys())
tiny_threshold = max(volumes) * cfg["tiny_volume_ratio"]
for idx in range(len(meshes)):
if idx in core_indices or volumes[idx] > tiny_threshold:
continue
grp = result.get(idx, {}).get("group")
if grp is None or grp == "head":
continue
obj_cent = centroids[idx]
obj_h_norm = normalized_height(obj_cent, scene_info)
obj_lat = abs(normalized_lateral(obj_cent, scene_info, body_cent))
dist_map = {c: compute_bbox_gap(meshes[idx], meshes[c]) for c in core_indices}
if cfg["leg_height_max_ratio"] <= obj_h_norm <= body_h_norm + 0.1:
arm_core_indices = [limb_cores[t] for t in ["left_arm", "right_arm"] if t in limb_cores]
if arm_core_indices and obj_lat >= cfg["arm_lateral_min_ratio"]:
nearest_arm = min(arm_core_indices, key=lambda i: dist_map[i])
if dist_map[nearest_arm] <= scene_height * cfg["general_distance_ratio"]:
new_group = core_groups[nearest_arm]
if result[idx]["group"] != new_group:
result[idx] = {"group": new_group, "score": 0.8,
"reasons": [f"邻近{new_group}核心", "小碎片自动归组"]}
continue
if (obj_h_norm < cfg["leg_height_max_ratio"] and has_legs
and obj_lat < cfg["leg_lateral_max_ratio"]):
leg_core_indices = [limb_cores[t] for t in ["left_leg", "right_leg"] if t in limb_cores]
if leg_core_indices:
nearest_leg = min(leg_core_indices, key=lambda i: dist_map[i])
if dist_map[nearest_leg] <= scene_height * cfg["general_distance_ratio"]:
new_group = core_groups[nearest_leg]
if result[idx]["group"] != new_group:
result[idx] = {"group": new_group, "score": 0.8,
"reasons": [f"邻近{new_group}核心", "小碎片自动归组"]}
continue
if (obj_h_norm < cfg["hand_height_max_ratio"]
and obj_lat >= cfg["hand_lateral_min_ratio"]):
arm_typed = [i for i in core_indices
if core_groups[i] in ("left_arm", "right_arm")]
if arm_typed:
nearest_arm = min(arm_typed, key=lambda i: dist_map[i])
if dist_map[nearest_arm] <= scene_height * cfg["general_distance_ratio"]:
new_group = core_groups[nearest_arm]
if result[idx]["group"] != new_group:
result[idx] = {"group": new_group, "score": 0.8,
"reasons": [f"手区碎片归{new_group}", "小碎片自动归组"]}
continue
if obj_lat < cfg["arm_lateral_min_ratio"]:
filtered = [i for i in core_indices
if core_groups[i] not in ("left_arm", "right_arm")]
target_indices = filtered if filtered else core_indices
else:
target_indices = core_indices
nearest_core = min(target_indices, key=lambda i: dist_map[i])
if dist_map[nearest_core] <= scene_height * cfg["general_distance_ratio"]:
new_group = core_groups[nearest_core]
if not has_legs and obj_h_norm < body_h_norm - 0.1 and new_group == "body":
pass
elif result[idx]["group"] != new_group:
result[idx] = {"group": new_group, "score": 0.8,
"reasons": [f"邻近核心部件({new_group})", "小碎片自动归组"]}
# ---------- 对称大腿根配对 ----------
if has_legs and "left_leg" in limb_cores and "right_leg" in limb_cores:
band_lo = cfg["leg_height_max_ratio"]
band_hi = cfg["leg_height_max_ratio"] + cfg["sym_leg_band_extra"]
mid_thr = cfg["leg_lateral_min_ratio"]
vol_min = max(volumes) * 0.02
vol_max = volumes[body_idx] * 1.0
orphan = []
for idx in range(len(meshes)):
if idx in (head_idx, body_idx):
continue
if result.get(idx, {}).get("group") != "body":
continue
h = normalized_height(centroids[idx], scene_info)
if not (band_lo <= h <= band_hi):
continue
lat = normalized_lateral(centroids[idx], scene_info, body_cent)
if abs(lat) < mid_thr:
continue
if not (vol_min <= volumes[idx] <= vol_max):
continue
orphan.append((idx, lat, h))
for (ia, lat_a, h_a) in orphan:
best_ib, best_diff = None, float("inf")
for ib in range(len(meshes)):
if ib == ia or ib in (head_idx, body_idx):
continue
hb = normalized_height(centroids[ib], scene_info)
if abs(h_a - hb) > cfg["sym_leg_height_diff_max"]:
continue
lb = normalized_lateral(centroids[ib], scene_info, body_cent)
if lat_a * lb >= 0:
continue
if abs(abs(lat_a) - abs(lb)) > cfg["sym_leg_lat_diff_max"]:
continue
if not (vol_min <= volumes[ib] <= vol_max):
continue
d = abs(h_a - hb) + abs(abs(lat_a) - abs(lb))
if d < best_diff:
best_diff, best_ib = d, ib
if best_ib is None:
continue
lb = normalized_lateral(centroids[best_ib], scene_info, body_cent)
left_idx = ia if lat_a < 0 else best_ib
right_idx = best_ib if lat_a < 0 else ia
result[left_idx] = {"group": "left_leg", "score": 0.85,
"reasons": ["对称大腿根配对", "归入左腿"]}
result[right_idx] = {"group": "right_leg", "score": 0.85,
"reasons": ["对称大腿根配对", "归入右腿"]}
return result
# ============================================================
# 主流程
# ============================================================
def cut_and_merge_human(filepath, output_path=None, export_mode='glb', do_clear=False):
start_time = time.time()
print("\n" + "=" * 100)
print(f"🧍 人形语义拆件合并 (trimesh + manifold3d) → {export_mode}")
print("=" * 100)
scene = trimesh.load(filepath, force='scene')
geometries = list(scene.geometry.values())
if not geometries:
raise ValueError("没有网格几何体")
meshes, names = [], []
for name, mesh in scene.geometry.items():
if is_valid_mesh(mesh):
meshes.append(mesh.copy())
names.append(name)
print(f"有效网格数: {len(meshes)}")
# ★ 关键:glTF 是 Y-up,trimesh 不会自动转换;语义规则按 Z-up 编写,必须先校正
up_axis = detect_bounds_up_axis(meshes)
converted = convert_to_zup(meshes)
print(f"源数据高度轴 = {'XYZ'[up_axis]} "
f"{'→ 已转换为 Z-up' if converted else '(已是 Z-up)'}")
# ★ 关键:manifold 布尔要求输入封闭。拆分件常见「顶点未缝合」导致的
# 非封闭网格,会让 union 静默返回空网格、该语义组整件丢失。
n_bad = sum(1 for m in meshes if is_valid_mesh(m) and not m.is_watertight)
if n_bad:
n_fixed = repair_meshes(meshes)
print(f"🔧 网格修复: {n_bad} 个非封闭网格已处理({n_fixed} 个重建)")
n_bad = sum(1 for m in meshes if is_valid_mesh(m) and not m.is_watertight)
if n_bad:
print(f"⚠️ 仍有 {n_bad} 个网格无法修复为封闭体")
else:
print("🔧 网格修复: 全部网格已是封闭体")
volumes = [get_volume(m) for m in meshes]
centroids = [get_centroid(m) for m in meshes]
scene_info = calculate_scene_info(meshes)
print(f"整体 bbox: min={scene_info['min']} max={scene_info['max']}")
sorted_idx = sorted(range(len(meshes)), key=lambda i: volumes[i], reverse=True)
head_idx, body_idx = find_head_and_body(meshes, volumes, centroids, sorted_idx, scene_info)
print(f"HEAD = {names[head_idx] if head_idx is not None else None}")
print(f"BODY = {names[body_idx]}")
# 初步分类
assignments = {}
for i in range(len(meshes)):
group, score, reasons = classify_fragment(i, meshes, volumes, centroids,
head_idx, body_idx, scene_info)
assignments[i] = {"group": group, "score": score, "reasons": reasons}
# 手工规则:合并腿拆分 + 手/脚附件归组(简化版,保留核心逻辑)
limb_cores = {}
for group in ["left_arm", "right_arm", "left_leg", "right_leg"]:
cand = [i for i, v in assignments.items() if v["group"] == group]
if cand:
limb_cores[group] = max(cand, key=lambda i: volumes[i])
# 尝试拆分合并腿:只在「左右腿核心没有同时找到」时,
# 对一个低位、跨中轴、体积足够大的部件用中轴面切分。
# ★ 条件必须与 Blender 版保持一致,否则会把领子/围巾/腰带之类的
# 中轴部件误判成"合并腿"切成两半。
la = HUMAN_CONFIG["lateral_axis"]
body_cent = centroids[body_idx]
body_vol = max(volumes[body_idx], 1e-12)
mid_lat = float(scene_info["center"][la])
left_core_now = limb_cores.get("left_leg")
right_core_now = limb_cores.get("right_leg")
merged_leg_candidates = []
if not (left_core_now is not None and right_core_now is not None):
for i, mesh in enumerate(meshes):
if i in (head_idx, body_idx) or i in limb_cores.values():
continue
if assignments.get(i, {}).get("group") in ("left_arm", "right_arm"):
continue
bbox = get_bbox_info(mesh)
if not (bbox["min"][la] < mid_lat < bbox["max"][la]):
continue
h = normalized_height(centroids[i], scene_info)
if h > HUMAN_CONFIG["leg_height_max_ratio"]:
continue
lat = abs(normalized_lateral(centroids[i], scene_info, body_cent))
if lat > 0.15:
continue
if volumes[i] < body_vol * 0.25:
continue
merged_leg_candidates.append((volumes[i], i))
if merged_leg_candidates:
merged_leg_candidates.sort(reverse=True)
_, merged_idx = merged_leg_candidates[0]
plane_co = np.asarray(scene_info["center"], dtype=np.float64)
plane_no = np.zeros(3); plane_no[la] = 1.0
neg, pos = split_mesh_by_plane(meshes[merged_idx], plane_co, plane_no)
if neg is not None and pos is not None and is_valid_mesh(neg) and is_valid_mesh(pos):
# 用 decompose 拆成连通实体后,优先把该侧所有块 union 回来(避免丢几何),
# union 失败时退化为取最大的一块
neg_parts = [p for p in decompose_manifold(neg) if is_valid_mesh(p)]
pos_parts = [p for p in decompose_manifold(pos) if is_valid_mesh(p)]
if len(neg_parts) > 1:
u = boolean_op(neg_parts, 'union', engine='manifold')
if u is not None and is_valid_mesh(u):
neg = u
else:
neg = max(neg_parts, key=lambda m: get_volume(m))
elif neg_parts:
neg = neg_parts[0]
if len(pos_parts) > 1:
u = boolean_op(pos_parts, 'union', engine='manifold')
if u is not None and is_valid_mesh(u):
pos = u
else:
pos = max(pos_parts, key=lambda m: get_volume(m))
elif pos_parts:
pos = pos_parts[0]
if is_valid_mesh(neg) and is_valid_mesh(pos):
print(f" ✂️ 合并腿拆分: {names[merged_idx]} -> 左/右")
new_idx_l = len(meshes); meshes.append(neg); names.append(names[merged_idx] + "_L")
new_idx_r = len(meshes); meshes.append(pos); names.append(names[merged_idx] + "_R")
volumes.extend([get_volume(neg), get_volume(pos)])
centroids.extend([get_centroid(neg), get_centroid(pos)])
assignments[new_idx_l] = {"group": "left_leg", "score": 0.9, "reasons": ["拆分-左"]}
assignments[new_idx_r] = {"group": "right_leg", "score": 0.9, "reasons": ["拆分-右"]}
limb_cores["left_leg"] = new_idx_l
limb_cores["right_leg"] = new_idx_r
assignments[merged_idx] = {"group": None, "score": 0.0, "reasons": ["已拆分"]}
# 手 / 脚 / 小碎片 / 对称大腿根 归组修正
assignments = resolve_limb_conflicts(assignments, meshes, volumes, centroids,
scene_info, body_idx, head_idx,
limb_cores, names)
# 按组构建
groups = defaultdict(list)
for i, info in assignments.items():
if info["group"] is not None:
groups[info["group"]].append(meshes[i])
# 每组用 manifold 做 union 合并
merged = {}
for group, members in groups.items():
members = [m for m in members if is_valid_mesh(m)]
if not members:
continue
if len(members) == 1:
merged[group] = members[0]
continue
u = boolean_op(members, 'union', engine='manifold')
# boolean_op 已经保证「非空」才返回,这里再兜一层:
# union 彻底失败时退化为直接拼接,宁可保留全部几何也不要丢件
if u is None or get_volume(u) <= 1e-12:
print(f" ⚠️ {group}: union 失败,退化为几何拼接(保留全部 {len(members)} 块)")
merged[group] = concatenate(members)
else:
merged[group] = u
print(f"\n✅ 语义合并完成,共 {len(merged)} 个部件:")
for g, m in merged.items():
print(f" {g:12s} vol={get_volume(m):.8f} faces={len(m.faces)}")
# 导出:内部是 Z-up,导出前转回 glTF 规范的 Y-up
export_meshes = convert_to_yup(list(merged.values())) if converted else list(merged.values())
out_scene = trimesh.Scene()
for group, mesh in zip(merged.keys(), export_meshes):
out_scene.add_geometry(mesh, geom_name=group, node_name=group)
if output_path is None:
output_path = os.path.splitext(filepath)[0] + "_human.glb"
out_scene.export(output_path, file_type='glb')
elapsed = time.time() - start_time
print(f"\n📤 已导出: {output_path} ({elapsed:.2f}s)")
return merged, output_path
def explode_and_export_glb(merged_dict, output_path, distance=2.0, convert_yup=True):
"""爆炸视图:每个部件沿远离整体质心方向平移。
merged_dict 内部是 Z-up,导出 glTF 需要转回 Y-up。"""
scene = trimesh.Scene()
names = list(merged_dict.keys())
meshes = convert_to_yup(list(merged_dict.values())) if convert_yup \
else list(merged_dict.values())
centers = [np.asarray(m.centroid) for m in meshes]
global_center = np.mean(centers, axis=0)
for name, mesh, center in zip(names, meshes, centers):
direction = center - global_center
norm = np.linalg.norm(direction)
if norm < 1e-8:
direction = np.array([0, 0, 1.0])
else:
direction = direction / norm
offset = direction * distance
T = np.eye(4)
T[:3, 3] = offset
scene.add_geometry(mesh.copy(), geom_name=name, node_name=name, transform=T)
scene.export(output_path, file_type='glb')
print(f"✅ 爆炸视图: {output_path}")
# ============================================================
# 入口
# ============================================================
if __name__ == "__main__":
base_dir = r"C:\Users\ChanJing-01\Documents\glb_chai\chai"
out_dir = "out3_trimesh"
os.makedirs(out_dir, exist_ok=True)
start=time.time()
input_files = glob.glob(os.path.join(base_dir, "**", "*.glb"), recursive=True)
for ii, input_file in enumerate(input_files):
print(f"\n[{ii}] {input_file}")
base_name = os.path.splitext(os.path.basename(input_file))[0]
merged_path = os.path.join(out_dir, base_name + ".glb")
try:
merged, out_path = cut_and_merge_human(input_file, merged_path,
export_mode='glb', do_clear=False)
explode_and_export_glb(merged, os.path.join(out_dir, base_name + "_baozha.glb"),
distance=2.0)
except Exception as e:
print(f"⚠️ 处理失败 {input_file}: {e}")
print('time',time.time() - start)