news 2026/11/6 1:59:57

glb 修复 ab补面

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
glb 修复 ab补面

目录

指定位置补洞,补成平面:

pymeshfix 补洞

ab补面


指定位置补洞,补成平面:

cap_call_qi.py
from __future__ import annotations import argparse, json, os, sys, time import numpy as np import trimesh sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cap_opening as cap log = cap.log # ────────────────────────────────────────────────────────── 朝向修正 def orient_radial(c: dict, obj_center: np.ndarray, up: int): n = np.asarray(c["normal"], np.float64).copy() rad = np.asarray(c["center"], np.float64) - np.asarray(obj_center, np.float64) if np.linalg.norm(rad) > 1e-9: if float(n @ rad) < 0: n = -n elif n[up] < 0: n = -n e = np.eye(3)[(up + 1) % 3] u = np.cross(n, e) if np.linalg.norm(u) < 1e-8: u = np.cross(n, np.eye(3)[(up + 2) % 3]) u /= np.linalg.norm(u) v = np.cross(n, u) return n, u, v # ────────────────────────────────────────────────────────── 轮廓重排 def order_ring_clockwise(P, center, u, v): P = np.asarray(P, np.float64) q = np.c_[(P - center) @ u, (P - center) @ v] ang = np.arctan2(q[:, 1], q[:, 0]) return np.argsort(ang)[::-1] def zigzag_indices(m): """0, m-1, 1, m-2, ... —— 用于封口三角带。 m=9 → [0,8,1,7,2,6,3,5,4];相邻对角线即 5-6, 4-7, 3-8, 2-9。""" z = [] lo, hi = 0, m - 1 while lo < hi: z.append(lo); z.append(hi) lo += 1; hi -= 1 if lo == hi: z.append(lo) return z # ────────────────────────────────────────────────────────── 几何小工具 def dedup_ring(P, tol=1e-6): """去掉相邻重复点与首尾重合点(闭环)。""" P = np.asarray(P, np.float64) n = len(P) if n < 2: return P d_prev = np.linalg.norm(P - np.roll(P, 1, axis=0), axis=1) keep = d_prev > tol keep[0] = True if np.linalg.norm(P[-1] - P[0]) < tol: keep[-1] = False return P[keep] def is_convex_2d(q, tol=1e-9): n = len(q) if n < 3: return False has_pos = has_neg = False for i in range(n): a = q[i]; b = q[(i + 1) % n]; c = q[(i + 2) % n] cr = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]) if cr > tol: has_pos = True elif cr < -tol: has_neg = True if has_pos and has_neg: return False return True def _cross2(a, b, c): return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) def _point_in_tri(p, a, b, c, eps=1e-12): d1 = _cross2(a, b, p) d2 = _cross2(b, c, p) d3 = _cross2(c, a, p) has_neg = (d1 < -eps) or (d2 < -eps) or (d3 < -eps) has_pos = (d1 > eps) or (d2 > eps) or (d3 > eps) return not (has_neg and has_pos) def ear_clip(q): """q: (N,2) 简单多边形。返回三角列表;每个 (a,b,c) 从 +n 看是逆时针(法线 +n)。""" n = len(q) if n < 3: return [] # 有向面积 > 0 表示逆时针 a2 = 0.0 for i in range(n): x1, y1 = q[i] x2, y2 = q[(i + 1) % n] a2 += x1 * y2 - x2 * y1 idx = list(range(n)) if a2 > 0: # 逆时针 → 统一成顺时针 idx = idx[::-1] tris = [] guard, max_guard = 0, n * n + 10 while len(idx) > 3 and guard < max_guard: guard += 1 L = len(idx) found = False for k in range(L): i0 = idx[k]; i1 = idx[(k + 1) % L]; i2 = idx[(k + 2) % L] a, b, c = q[i0], q[i1], q[i2] if _cross2(a, b, c) >= -1e-12: # 顺时针多边形,凸 ear 叉积 < 0 continue inside = False for jj in idx: if jj == i0 or jj == i1 or jj == i2: continue if _point_in_tri(q[jj], a, b, c): inside = True break if inside: continue # (i0,i1,i2) 从 +n 看是顺时针;反转成逆时针 tris.append((i0, i2, i1)) idx.pop((k + 1) % L) found = True break if not found: for k in range(1, len(idx) - 1): tris.append((idx[0], idx[k + 1], idx[k])) idx = [] break if len(idx) == 3: tris.append((idx[0], idx[2], idx[1])) return tris # ────────────────────────────────────────────────────────── 建棱柱 def build_prism_zip(P, center, n, u, v, depth, over, grow): """顺时针排序 + 去重 → 侧面周边相邻;封口优先 zigzag(凸)/ ear-clipping(凹)。 返回 (prism, faces, P_sorted)。""" P = np.asarray(P, np.float64).copy() center = np.asarray(center, np.float64) n = n / max(np.linalg.norm(n), 1e-12) u = u / max(np.linalg.norm(u), 1e-12) v = v / max(np.linalg.norm(v), 1e-12) # 1) 去重 + 顺时针 P = dedup_ring(P) if len(P) < 3: raise ValueError("轮廓点数 < 3(去重后 %d)" % len(P)) P = P[order_ring_clockwise(P, center, u, v)] # 2) grow if abs(grow) > 1e-12: q = np.c_[(P - center) @ u, (P - center) @ v] r = np.maximum(np.linalg.norm(q, axis=1, keepdims=True), 1e-9) q = q * (1.0 + grow / r) P = center + q[:, 0:1] * u + q[:, 1:2] * v m = len(P) q = np.c_[(P - center) @ u, (P - center) @ v] bot = P - n * depth top = P + n * over V = np.vstack([bot, top]) # 3) 侧面:周边相邻(不会交叉) side_faces = [] for i in range(m): j = (i + 1) % m side_faces.append([i, i + m, j]) side_faces.append([j, i + m, j + m]) # 4) 封口 if is_convex_2d(q): z = zigzag_indices(m) cap = [] for k in range(len(z) - 2): a, b, c = z[k], z[k + 1], z[k + 2] cap.append([a + m, b + m, c + m]) # top +n cap.append([a, c, b ]) # bottom -n prism = trimesh.Trimesh(V, np.array(side_faces + cap), process=True) if prism.is_volume: return prism, prism.faces, P log(" ⚠ 轮廓标称凸但 zigzag 失败(共线/重复点),改用 ear-clipping") tris = ear_clip(q) if not tris: raise ValueError("ear-clipping 失败(轮廓可能自交)") cap = [] for (a, b, c) in tris: # 已按 +n 逆时针 cap.append([a + m, b + m, c + m]) # top +n cap.append([a, c, b ]) # bottom -n prism = trimesh.Trimesh(V, np.array(side_faces + cap), process=True) if not prism.is_volume: log(" ⚠ ear-clipping 后仍非 volume,尝试修复") trimesh.repair.fix_normals(prism, multibody=True) trimesh.repair.fill_holes(prism) return prism, prism.faces, P # ────────────────────────────────────────────────────────── 筛选 def select_loops(cands, V, up, args): lo, hi = float(V[:, up].min()), float(V[:, up].max()) span = hi - lo if hi > lo else 1.0 amax = max(c["area"] for c in cands) if cands else 0.0 amin_frac = amax * args.min_area_frac amin = max(args.min_area, amin_frac) picked, why = [], [] for i, c in enumerate(cands): t = (c["up"] - lo) / span if args.only is not None and i not in args.only: why.append((i, "跳过:不在 --only 里")); continue if args.exclude and i in args.exclude: why.append((i, "跳过:在 --exclude 里")); continue if not (args.min_t <= t <= args.max_t): why.append((i, "跳过:相对高度 %.3f 不在 [%.2f, %.2f]" % (t, args.min_t, args.max_t))); continue if c["area"] < amin: why.append((i, "跳过:面积 %.5f < 阈值 %.5f" % (c["area"], amin))); continue picked.append((i, c, t)) why.append((i, "选中:t=%.3f 面积=%.5f" % (t, c["area"]))) return picked, why, (lo, hi) # ────────────────────────────────────────────────────────── 射线自检 def ray_depths(mesh, P, cen, n, u, v, grid=31, off=0.08): from matplotlib.path import Path q = np.c_[(P - cen) @ u, (P - cen) @ v] path = Path(q) r = float(np.abs(q).max()) * 1.02 g = np.linspace(-r, r, grid) X, Y = np.meshgrid(g, g) pts = np.c_[X.ravel(), Y.ravel()] pts = pts[path.contains_points(pts)] if len(pts) == 0: return np.zeros(0) orig = cen + n * off + pts[:, 0:1] * u + pts[:, 1:2] * v loc, ir, _ = mesh.ray.intersects_location( orig, np.tile(-n, (len(orig), 1)), multiple_hits=False) dep = np.full(len(orig), np.inf) for j, ri in enumerate(ir): d = float((orig[ri] - loc[j]) @ n) dep[ri] = min(dep[ri], d) return dep def main(): ap = argparse.ArgumentParser() ap.add_argument("--up", default="y", choices="xyz") ap.add_argument("--geom", type=int, default=None) ap.add_argument("--list", action="store_true") ap.add_argument("--only", default=None) ap.add_argument("--exclude", default=None) ap.add_argument("--min-t", type=float, default=0.45) ap.add_argument("--max-t", type=float, default=1.01) ap.add_argument("--min-area-frac", type=float, default=0.10) ap.add_argument("--min-area", type=float, default=0.0) ap.add_argument("--min-loop-pts", type=int, default=16) ap.add_argument("--band", type=float, default=0.35) ap.add_argument("--max-pts", type=int, default=400) ap.add_argument("--depth", type=float, default=0.0) ap.add_argument("--over", type=float, default=0.0) ap.add_argument("--grow", type=float, default=-1.0) ap.add_argument("--save-plug", default=None) ap.add_argument("--report", default="cap_all_report.json") ap.add_argument("--plug-color", default="255,120,60") ap.add_argument("--no-ray-check", action="store_true") ap.add_argument("--ray-check", action="store_true") args = ap.parse_args() args.input = r"E:\data\3d_asset\kongdong\kongdong1.glb" args.input = r"E:\data\3d_asset\kongdong\kongdong2.glb" args.output = f"{args.input[:-4]}_bu.glb" args.only = None if args.only is None else {int(x) for x in args.only.split(",") if x.strip() != ""} args.exclude = set() if args.exclude is None else {int(x) for x in args.exclude.split(",") if x.strip() != ""} up = cap.AXES[args.up] scene, m, gname, gidx = cap.load_weld(args.input, args.geom) V = np.asarray(m.vertices, np.float64) diag = float(np.linalg.norm(m.extents)) obj_center = np.asarray(m.centroid, np.float64) bl = cap.open_boundary_loops(m, args.min_loop_pts) if bl: src, loops = "boundary", bl log("口沿来源 = 真·边界环,%d 个候选" % len(loops)) else: loops = cap.crease_loops(m, args.min_loop_pts, args.band) src = "crease" log("口沿来源 = 翻边折痕,%d 个候选" % len(loops)) cands = cap.loop_info(loops, V, up) picked, why, (lo, hi) = select_loops(cands, V, up, args) log("高度区间 y∈[%.4f, %.4f]" % (lo, hi)) for i, msg in why: log(" #%d %s" % (i, msg)) if args.list: return if not picked: log("没有符合条件的口"); return depth = args.depth if args.depth > 0 else diag * 0.03 over = args.over if args.over > 0 else diag * 0.002 grow = args.grow if args.grow >= 0 else diag * 0.0015 log("参数:depth=%.5f over=%.5f grow=%.5f(对角线 %.4f)" % (depth, over, grow, diag)) solid = m.copy() if not solid.is_volume: trimesh.repair.fix_normals(solid, multibody=True) trimesh.repair.fill_holes(solid) if not solid.is_volume: log("⚠ 主体 mesh 不是 volume,布尔可能失败") prisms, recs = [], [] for i, c, t in picked: try: n, u, v = orient_radial(c, obj_center, up) P = cap.decimate_ring(V[c["idx"]], args.max_pts) prism, faces, Ps = build_prism_zip(P, c["center"], n, u, v, depth, over, grow) log(" #%d:轮廓 %d 点 → 三角 %d;柱体 %d 面,volume=%s" % (i, len(P), len(faces), len(prism.faces), prism.is_volume)) if not prism.is_volume: log(" ⚠ 跳过该棱柱(无法构成 volume)"); continue prisms.append(prism) recs.append(dict(index=i, t=float(t), area=float(c["area"]), n_pts=int(c["n"]), center=np.asarray(c["center"]).tolist(), normal=n.tolist(), resid=float(c["resid"]), contour_pts=int(len(P)), prism_faces=int(len(prism.faces)))) c["_n"], c["_u"], c["_v"], c["_P"] = n, u, v, P except Exception as e: log(" #%d 构建失败:%s,跳过" % (i, e)); continue if not prisms: log("没有生成有效柱体,放弃导出"); return # ========== 合并 + 一次性布尔差 ========== prism_all = trimesh.util.concatenate(prisms) prism_all.merge_vertices(1e-7) mask = prism_all.nondegenerate_faces() prism_all.update_faces(mask) prism_all.remove_unreferenced_vertices() log("合并柱体:%d 顶点 / %d 面,is_volume=%s,watertight=%s" % (len(prism_all.vertices), len(prism_all.faces), prism_all.is_volume, prism_all.is_watertight)) t_bool = time.time() plug_all = trimesh.boolean.difference([prism_all, solid], engine="manifold") t_bool = time.time() - t_bool if len(plug_all.faces) == 0: log("批量布尔结果为空"); return log("批量布尔差耗时 %.2fs,合并塞子:%d 顶点 / %d 面" % (t_bool, len(plug_all.vertices), len(plug_all.faces))) plugs = plug_all.split() uni = None; uni_wt = None try: uni = trimesh.boolean.union([m] + plugs, engine="manifold") bodies = uni.split(only_watertight=False) log("自检·并集:watertight=%s volume %.6g → %.6g(+%.2f%%) 实体块 %d 个" % (uni.is_watertight, m.volume, uni.volume, 100 * (uni.volume / m.volume - 1) if m.volume else 0, len(bodies))) uni_wt = uni.is_watertight except Exception as e: log("自检·并集失败:%s" % e) run_ray = args.ray_check and (not args.no_ray_check) and (uni is not None) if run_ray: for i, c, t in picked: if "_P" not in c: continue try: d0 = ray_depths(m, c["_P"], c["center"], c["_n"], c["_u"], c["_v"]) d1 = ray_depths(uni, c["_P"], c["center"], c["_n"], c["_u"], c["_v"]) if len(d0) == 0: continue esc0 = int(np.isinf(d0).sum() + (d0 > 0.8 * 0.125).sum()) blocked = int((d1 < 0.08 * 1.3).sum()) log("自检·射线 #%d:口内 %d 条,补前 %.4f~%.4f(穿透 %d)→ 补后 %.4f~%.4f(挡 %d)" % (i, len(d0), np.nanmin(d0), np.nanmax(d0), esc0, np.nanmin(d1), np.nanmax(d1), blocked)) except Exception as e: log("自检·射线 #%d 失败:%s" % (i, e)) if args.output: cols = cap.read_glb_colors(args.input) if args.plug_color.strip().lower() == "auto": rgb = cols.get(gname, (200, 200, 200)) else: rgb = tuple(int(x) for x in args.plug_color.split(",")) out = trimesh.Scene() for gn, g in scene.geometry.items(): out.add_geometry(cap.as_solid_color(g, cols.get(gn, (200, 200))), node_name=gn, geom_name=gn) out.add_geometry(cap.as_solid_color(plug_all, rgb), node_name="cap_plug_all", geom_name="cap_plug_all") out.export(args.output) log("已导出 %s(塞子配色 %s)" % (args.output, rgb)) if args.save_plug: plug_all.export(args.save_plug) log("塞子已导出 %s" % args.save_plug) if args.report: rep = dict(input=args.input, geom=gname, source=src, diag=diag, height_range=[lo, hi], depth=depth, over=over, grow=grow, selected=recs, plug=dict(verts=int(len(plug_all.vertices)), faces=int(len(plug_all.faces))), union_watertight=uni_wt, candidates=[{k: (v.tolist() if isinstance(v, np.ndarray) else v) for k, v in c.items() if k in ("n", "up", "area", "perimeter", "resid", "center", "normal")} for c in cands]) with open(args.report, "w", encoding="utf-8") as f: json.dump(rep, f, ensure_ascii=False, indent=2) log("报告 %s" % args.report) if __name__ == "__main__": main()

pymeshfix 补洞

发现补洞没有效果:

import trimesh import pymeshfix import numpy as np path_in = r"E:\data\3d_asset\kongdong\kongdong1.glb" path_out = path_in[:-4] + "_fixed.glb" scene = trimesh.load(path_in, force='scene') # 用 to_geometry 替代已弃用的 dump(concatenate=True) combined = scene.to_geometry() # ---- 关键修复1:焊接顶点 ---- print("焊接前:", combined.vertices.shape, combined.faces.shape) combined = trimesh.Trimesh( vertices=combined.vertices, faces=combined.faces, process=True, # merge_vertices + fix_normals + 去重 ) print("焊接后:", combined.vertices.shape, combined.faces.shape) print("is_watertight:", combined.is_watertight) print("平均每个顶点被几个面使用:", combined.faces.shape[0] * 3 / max(combined.vertices.shape[0], 1)) # ---- 关键修复2:去掉 NaN / Inf / 退化面 ---- combined.update_faces(combined.nondegenerate_faces()) combined.remove_infinite_values() combined.remove_unreferenced_vertices() print("清理后:", combined.vertices.shape, combined.faces.shape) # 如果已经闭合就跳过 pymeshfix if combined.is_watertight: sealed = combined else: # ---- 归一化后调用 pymeshfix(避免坐标误差)---- v = combined.vertices.astype(np.float64) f = combined.faces.astype(np.int32) center = v.mean(axis=0) scale = float(np.abs(v - center).max()) or 1.0 v_n = (v - center) / scale tin = pymeshfix.PyTMesh() tin.load_array(v_n, f) tin.fill_small_boundaries(nbe=0) # 补所有边界 # tin.clean() # ← 不要调用!它会删光所有面 va, fa = tin.return_arrays() print("fill 后:", va.shape, fa.shape) if len(fa) == 0: # 退路:纯 trimesh 补洞 sealed = combined.copy() sealed.fill_holes() sealed.fix_normals() else: va = va * scale + center sealed = trimesh.Trimesh(vertices=va, faces=fa, process=True) print("最终:", sealed.vertices.shape, sealed.faces.shape, "watertight:", sealed.is_watertight, "is_empty:", sealed.is_empty) assert not sealed.is_empty and len(sealed.faces) > 0 trimesh.Scene(sealed).export(path_out, file_type="glb") print("已导出:", path_out)

ab补面

def load_glb_mesh(path, target_size_mm=None): m = trimesh.load(path, force="mesh") # 合为一个 mesh if not m.is_watertight: print(f"⚠ {path} 不是水密体,尝试修复 ...") m.fill_holes() m = m.process(validate=True) if target_size_mm is not None: # 按最长边缩放到目标尺寸(关键:两个输入必须用同一尺度) L = float(np.linalg.norm(m.bounds[1] - m.bounds[0])) if L > 0: m.apply_scale(target_size_mm / L) return m column = load_glb_mesh("column.glb", target_size_mm=40) # 柱 ball = load_glb_mesh("ball.glb", target_size_mm=40) # 球 # 关键:确认球体与柱口是"相交"的,而不是悬空 # 按你的场景摆正球的位置(这里假设球心在柱口 z 平面): rim_z = column.bounds[1][2] ball.apply_translation([0, 0, rim_z - ball.centroid[2]]) # 如果球原本在别的位置,用坐标变换把球心放到 (0, 0, rim_z) result = fill_with_ball(column, ball, rim_z=rim_z, keep_whole_ball=False) result.export("merged.stl")
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 1:56:25

WorkBuddy做工具小程序实测:以自有小程序「清印」为例的搭建笔记

仓库&#xff1a;https://github.com/Maynor996/qingyin-workbuddy-miniapp-notes-2026 本文是把一篇公众号里「WorkBuddy 全自动开发小程序」的流程&#xff0c;改写成**结合作者自有小程序【清印】**的实战笔记。对外案例数字保留出处口径&#xff1b;清印本身不写虚构广告收…

作者头像 李华
网站建设 2026/11/6 1:55:47

【Claude Code · VibeCoding】09|环境:WSL 与 Docker Desktop

第二章 开发环境与工具 实战课要用 docker compose 拉起 MySQL / Redis / MinIO。本课在 Windows 上:启用 WSL 相关组件 → 安装/更新 WSL → 安装 Docker Desktop → 完成初始设置并验证。可跟课件一样让 Claude Code 协助执行启用命令,但权限弹窗必须你本人确认。 本课目…

作者头像 李华
网站建设 2026/11/6 1:55:15

第十二届能源、环境与资源国际会议(ICEER2027)参会指南:能源环境领域学者的投稿与参会路径

一、引言 学术会议是研究者了解前沿技术、交流研究方法并建立合作的重要场合。能源、环境与资源领域涉及自然科学、工程技术与政策管理的交叉问题&#xff0c;参会者常需在多学科语境中讨论研究设计与数据方法。 第十二届能源、环境与资源国际会议&#xff08;ICEER2027&#x…

作者头像 李华
网站建设 2026/11/6 1:54:53

FDE薪资逻辑:半导体CIM方向为什么能开到30k–70k

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华