3d 网格布尔几何 Manifold

目录

js版本:

python版:

相交挖孔:

[manifold3d 要求输入必须是严格的流形网格](#manifold3d 要求输入必须是严格的流形网格)

[首选方案:用 remesh-threejs 修复网格](#首选方案:用 remesh-threejs 修复网格)

3d人体拆件:


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)
相关推荐
Geek-Chow3 小时前
00 · DeepSeek V4.1 Flash 大规模推理部署实践学习指南:总览、思维导图与阅读路线(专栏目录)
人工智能·1024程序员节
JiNan.YouQuan.Soft4 小时前
Windows下编译CGMA
其他·1024程序员节
水饺编程4 小时前
第2章:Linux 基础,在 root 中创建用户账号
linux·c语言·汇编·1024程序员节
梯度下降者5 小时前
全栈 JS 性能监控:在生产环境实现长任务(Long Task)的采集与上报
用户体验·性能监控·1024程序员节·rum·长任务·全栈js
Soonyang Zhang6 小时前
aisimulate分析(一)——算子workload建模
仿真·1024程序员节·vllm·推理框架·sglang
jimy17 小时前
lvalue、xvalue、prvalue(二):引用是哪一类?
开发语言·c++·1024程序员节
贾伟康7 小时前
【健康菜谱助手|04】HarmonyOS ArkTS 一周膳食计划实战:管理日期、餐次与菜谱替换的联动
软件测试·移动开发·harmonyos·arkts·arkui·1024程序员节
AI浩8 小时前
Xiaomi-Lens-0 技术报告
人工智能·计算机视觉·1024程序员节
2601_967980119 小时前
Python之wxPython模块的安装
linux·windows·python·wxpython·gui设计·1024程序员节