From efba6dbe294a09704ef4be6e080f274beb834929 Mon Sep 17 00:00:00 2001 From: hesuicong Date: Tue, 10 Nov 2026 15:53:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A1=AB=E4=B8=8D=E7=A9=BA=E9=9A=99=EF=BC=8C?= =?UTF-8?q?=E5=B9=B3=E6=BB=91=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fill2sc/generate_fill_mesh_hsc.py | 493 +++++++++++++++++++++++++++--- 1 file changed, 445 insertions(+), 48 deletions(-) diff --git a/fill2sc/generate_fill_mesh_hsc.py b/fill2sc/generate_fill_mesh_hsc.py index f7913d7..49f9de7 100644 --- a/fill2sc/generate_fill_mesh_hsc.py +++ b/fill2sc/generate_fill_mesh_hsc.py @@ -21,6 +21,61 @@ import numpy as np from collections import deque from PIL import Image, ImageDraw +import subprocess +import os + +def auto_bevel_in_blender(input_obj_path, output_obj_path, bevel_width=0.5, angle_deg=15): + blender_script = ( + "import bpy, math\n" + "bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete()\n" + f"bpy.ops.wm.obj_import(filepath=r'{input_obj_path}')\n" + "obj = bpy.context.active_object\n" + "bpy.context.view_layer.objects.active = obj\n" + "bpy.ops.object.transform_apply(scale=True)\n" + "bpy.ops.object.mode_set(mode='EDIT')\n" + "bpy.ops.mesh.select_all(action='DESELECT')\n" + # 选中非流形边(通常是交界线/边界)\n" + "bpy.ops.mesh.select_non_manifold(extend=False)\n" + # 若上面选不出,改用:bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(5))\n" + f"bpy.ops.mesh.bevel(offset={bevel_width}, segments=4, profile=0.5, clamp_overlap=True)\n" + "bpy.ops.object.mode_set(mode='OBJECT')\n" + "bpy.ops.object.shade_smooth()\n" + f"bpy.ops.wm.obj_export(filepath=r'{output_obj_path}')\n" + "print('###BEVEL_OK###')\n" + ) + + script_path = "_temp_bevel.py" + with open(script_path, 'w') as f: + f.write(blender_script) + + try: + result = subprocess.run( + ['blender', '--background', '--python-exit-code', '1', '--python', script_path], + capture_output=True, text=True, timeout=120 + ) + # 合并 stdout 和 stderr 一起检查(Blender 喜欢把正常信息输出到 stderr) + all_output = result.stdout + "\n" + result.stderr + print("STDOUT:", result.stdout) + + # 只检查成功标记,忽略 stderr 的 "Writing to" 信息 + if '###BEVEL_OK###' not in all_output: + print("⚠️ 脚本未执行完") + print("STDERR:", result.stderr) + return False + if result.returncode != 0: + print("⚠️ Blender 返回码异常:", result.returncode) + return False + print("✅ 倒角成功完成") + return True + except FileNotFoundError: + print("❌ 未找到 Blender") + return False + except subprocess.TimeoutExpired: + print("❌ 超时") + return False + finally: + if os.path.exists(script_path): + os.remove(script_path) class FillMeshGenerator: """眼镜-脸部填充网格生成器""" @@ -40,10 +95,10 @@ class FillMeshGenerator: RY0, RY1 = 9.7, 19.3 # 填充区域 y 裁剪(只处理正面眼镜部分) SEP = 0.25 # 区分前后表面的最小 z 间距 FLUSH = 0.15 # 填充前表面距镜框前面的距离(与镜框平齐, 留嵌入余量) - EMBED_BOT = 0.6 # 填充底面嵌入脸部深度 + EMBED_BOT = 0.8 # 填充底面嵌入脸部深度 SHELL_MAX = 3.0 # 无真实脸面处的填充厚度上限(相对前表面) - FACE_MIN = 2.0 # 脸面低于此值视为误标伪面(后脑等), 不作填充底面 - CLAMP_BACK = 0.4 # 距最后表面的安全距离(防穿出后脑) + FACE_MIN = 1.5 # 脸面低于此值视为误标伪面(后脑等), 不作填充底面 + CLAMP_BACK = 0.3 # 距最后表面的安全距离(防穿出后脑) GAP_MIN = 0.25 # 填充最小厚度 SIGMA = 2.0 # 高度场平滑 sigma(格) ERODE = 2 # 足迹腐蚀(格) 0.2, 与膨胀0.3配合净外扩0.1(补偿边界平滑收缩) @@ -643,11 +698,41 @@ class FillMeshGenerator: # 可选:用周围有效值填充孤立NaN(若还有洞) hf = np.where(R & ~np.isfinite(hf), np.nanmean(hf[R]), hf) - # 墙区安全钳制 + # 墙区双向钳制(不缩回,与镜面齐平) edge_in = self.erode_4(R, 4) wall_zone = R & ~edge_in - hf = np.where(frame_band & wall_zone, np.minimum(hf, gz1f - self.FLUSH), hf) - hf = np.where(frame_band & ~wall_zone, np.minimum(hf, gz1f + 0.10), hf) + + # 计算钳制上下界 + gzfm = np.where(gz1f > -1e29, gz1f, -np.inf) + gmax2 = gzfm.copy() + for _ in range(10): + gmax2 = np.maximum.reduce([gmax2, np.roll(gmax2, 1, 0), np.roll(gmax2, -1, 0), + np.roll(gmax2, 1, 1), np.roll(gmax2, -1, 1)]) + + # 上界:陡降格防鳍,平缓格不设上限(允许微凸) + ceil = np.where((gz1f > -1e29) & (gzfm < gmax2 - 1.2), gzfm + 0.30, np.inf) + # 下界:真实覆盖格不低于本地框面 - 0.15(允许微凹但不缩回) + bc = R & g1c & (gz1f > -1e29) + floor = np.where(bc, gz1f - 0.15, -np.inf) + + # 双向钳制 + hf = np.minimum(np.maximum(hf, floor), ceil) + + # ===== 镜片弧面凸出 ===== + _nbr = (np.roll(R, 1, 0) | np.roll(R, -1, 0) | np.roll(R, 1, 1) | np.roll(R, -1, 1)) + _ring = ~R & _nbr + _jr, _ir = np.where(R) + _js, _is = np.where(_ring) + _dx = (_ir[:, None] - _is[None, :]) * self.RES + _dy = (_jr[:, None] - _js[None, :]) * self.RES + _dist = np.full((H, W), np.inf) + _dist[_jr, _ir] = np.sqrt(_dx * _dx + _dy * _dy).min(1) + dmax = float(np.max(_dist[R])) + mprof = np.where(R, 1.0 - (1.0 - np.minimum(_dist / max(dmax, 1e-9), 1.0)) ** 2, np.nan) + mprof = self.smooth_valid(mprof, R, sigma=2.5) + BULGE = 0.45 + hf = np.where(R, hf + BULGE * mprof, np.nan) + # ===== 结束镜片弧面 ===== # 交界连续性检查 jn, jn_at = 0.0, None @@ -664,10 +749,66 @@ class FillMeshGenerator: self.log(f" 交界处高度跳变最大 {jn:.3f} @ {jn_at} (平面前框带接缝应<0.3; " "镜圈圆角处~0.4为镜框自身几何, 并集后不暴露)") - # 在“交界连续性检查”之后,增加: + # 交界连续性检查 + jn, jn_at = 0.0, None + bflat = mfit2 & (gz1f > zfit - 0.3) + for dj, di in ((1,0),(-1,0),(0,1),(0,-1)): + a = np.roll(bflat, (dj, di), axis=(0, 1)) & openf & np.isfinite(hf) + if a.any(): + b = np.roll(a, (-dj, -di), axis=(0, 1)) + dd = np.abs(np.where(a, hf, 0.0)[a] - np.where(np.isfinite(hf), hf, 0.0)[b]) + k = int(np.nanargmax(dd)) + if dd[k] > jn: + ja = np.argwhere(a)[k] + jn, jn_at = float(dd[k]), (self.X0 + ja[1] * self.RES, self.Y0 + ja[0] * self.RES) + self.log(f" 交界处高度跳变最大 {jn:.3f} @ {jn_at} (平面前框带接缝应<0.3; " + "镜圈圆角处~0.4为镜框自身几何, 并集后不暴露)") + + # 在"交界连续性检查"之后,增加: nose_zone = R & frame_band & (np.abs(hf - gz1f) < 0.5) # 鼻梁过渡区 hf = np.where(nose_zone, self.smooth_valid(hf, nose_zone, sigma=3.0, it=4), hf) + # ===== 在这里添加双向钳制逻辑 ===== + # 计算 gzfm 和 gmax2(用于防鳍天花板) + gzfm = np.where(gz1f > -1e29, gz1f, -np.inf) + gmax2 = gzfm.copy() + for _ in range(10): + gmax2 = np.maximum.reduce([gmax2, np.roll(gmax2, 1, 0), np.roll(gmax2, -1, 0), + np.roll(gmax2, 1, 1), np.roll(gmax2, -1, 1)]) + + # 下界:允许微凹但不缩回(贴合框面) + bc = R & g1c & (gz1f > -1e29) + floor = np.where(bc, gz1f - 0.15, -np.inf) + # 上界:平缓格不设上限(允许微凸),陡降格防鳍 + ceil = np.where((gz1f > -1e29) & (gzfm < gmax2 - 1.2), gzfm + 0.30, np.inf) + + # 双向钳制(替代原来的单向缩回) + hf = np.minimum(np.maximum(hf, floor), ceil) + # ===== 结束双向钳制 ===== + + # ===== 镜框边界融合(消除棱角) ===== + blend_margin = 0.15 # 越小越窄(0.15~0.3) + blend_zone = R & frame_band & (np.abs(hf - gz1f) < blend_margin) + hf = np.where(blend_zone, 0.75 * hf + 0.25 * gz1f, hf) # 混合比例可调 + # ===== 结束边界融合 ===== + + # ========================================== + # 【新增】镜框内边缘强制填沟(放在这里!) + # ========================================== + _inner = R & ( + np.roll(frame_band, 1, 0) | np.roll(frame_band, -1, 0) | + np.roll(frame_band, 1, 1) | np.roll(frame_band, -1, 1) + ) & ~frame_band + + if np.any(_inner): + # 填平深沟:0.5:0.5 可尝试调为 0.6*hf + 0.4*gz1f(更偏面部)或 0.4:0.6(更偏镜框) + hf = np.where(_inner, 0.5 * hf + 0.5 * gz1f, hf) + # 强平滑:sigma=2.0, it=4 可微调,越大越平滑但可能损失细节 + hf = np.where(_inner, self.smooth_valid(hf, _inner, sigma=2.0, it=4), hf) + # ========================================== + # 【结束新增】 + # ========================================== + # ---- 后表面 ---- hb = np.full((H, W), np.nan) real_f = have_f & (fz >= self.FACE_MIN) @@ -688,6 +829,19 @@ class FillMeshGenerator: hb = np.minimum(hb, hf - self.GAP_MIN) hb = self.smooth_valid(hb, R, sigma=2.0, it=2) + # ===== 在这里添加后表面钳制 ===== + # 后表面下界(真实脸面 - 嵌入量) + floor_b = np.where(R & (real_f > -1e29), real_f - self.EMBED_BOT, -np.inf) + # 后表面上界(防穿出后脑,但允许贴脸) + ceil_b = np.where(R & (real_f > -1e29), real_f + self.CLAMP_BACK, np.inf) + hb = np.minimum(np.maximum(hb, floor_b), ceil_b) + # ===== 结束后表面钳制 ===== + + # ===== 墙区额外压低贴脸 ===== + edge_in = self.erode_4(R, 4) + wall_zone = R & ~edge_in + hb = np.where(wall_zone, np.minimum(hb, real_f - self.EMBED_BOT * 0.8), hb) + # ===== 结束墙区压低 ===== # 外扩填充 NaN for _ in range(60): @@ -867,12 +1021,26 @@ class FillMeshGenerator: return tris def triangulate_fill(self, B, R, hf, hb): - """对填充区域进行三角剖分""" + """对填充区域进行三角剖分(含边界平滑,使侧墙圆滑)""" self.log("== 6. 区域三角化 (耳切法 + 内部网格点插入, 无翻转风险) ==") H, W = R.shape Nb = len(B) - # 耳切三角化 + # ===== 【新增】边界环 Laplacian 平滑(仅平滑 XY,保持闭合) ===== + B_orig = B.copy() + B_xy = B.copy() + if Nb > 3: + smooth_iter = 15 + blend = 0.6 + for _ in range(smooth_iter): + B_xy_new = 0.5 * B_xy + 0.25 * (np.roll(B_xy, 1, 0) + np.roll(B_xy, -1, 0)) + B_xy = B_xy_new + # 混合:保留 40% 原始形状(防收缩),60% 平滑结果 + B = (1 - blend) * B_orig + blend * B_xy + self.log(f" 边界平滑: iter={smooth_iter}, blend={blend}, 点数={Nb}") + # ================================================================ + + # 耳切三角化(使用平滑后的 B) T = np.asarray(self.ear_clip(B.tolist()), np.int64) self.log(f" 耳切三角化: {len(T)} 三角形") @@ -883,7 +1051,7 @@ class FillMeshGenerator: ne[1:, :] &= inner2[:-1, :]; ne[:-1, :] &= inner2[1:, :] ne[:, 1:] &= inner2[:, :-1]; ne[:, :-1] &= inner2[:, 1:] inner2 = ne - step = max(1, int(round(0.3 / self.RES))) # 从 0.3 减半到 0.15 + step = max(1, int(round(0.3 / self.RES))) cand = [] for j in range(0, H, step): for i in range(0, W, step): @@ -908,7 +1076,7 @@ class FillMeshGenerator: l2 = ((c[:, 1] - a[:, 1]) * (p[0] - c[:, 0]) + (a[:, 0] - c[:, 0]) * (p[1] - c[:, 1])) / d l3 = 1 - l1 - l2 hit = np.where((l1 > 5e-3) & (l2 > 5e-3) & (l3 > 5e-3) - & (l1 < 1 - 5e-3) & (l2 < 1 - 5e-3) & (l3 < 1 - 5e-3))[0] + & (l1 < 1 - 5e-3) & (l2 < 1 - 5e-3) & (l3 < 1 - 5e-3))[0] if len(hit) == 0: continue t = int(hit[0]) @@ -938,7 +1106,7 @@ class FillMeshGenerator: sy = np.bincount(src, weights=xy[dst, 1], minlength=len(xy)) new = np.stack([sx, sy], 1) / np.maximum(deg, 1)[:, None] xy2 = xy.copy() - xy2[Nb:] = 0.7 * xy[Nb:] + 0.5 * new[Nb:] # 从 0.5/0.5 改为 0.7/0.3(移动更多) + xy2[Nb:] = 0.7 * xy[Nb:] + 0.5 * new[Nb:] if tri_areas(xy2, T).min() > 1e-6: xy = xy2 xy_best = xy2.copy() @@ -1047,65 +1215,278 @@ class FillMeshGenerator: self.log(f" 实际插入 {extra} 点, 总三角形 {len(T)}, 最小有向面积 {area.min():.5f} (>0 = 无翻转)") # 计算 z 坐标 - zf = self.bilinear(hf, xy) + zf = self.bilinear(self.smooth_valid(hf, R, sigma=1.5, it=2), xy) zb = self.bilinear(hb, xy) zb = np.minimum(zb, zf - 0.15) zb = np.maximum(zb, zf - 50) - if len(xy) > 0: - # 对 zf 进行边界感知平滑(假设 hf 已平滑,此处防双线性走样) - zf = self.bilinear(self.smooth_valid(hf, R, sigma=1.5, it=2), xy) - # 若边界仍硬,可对边界环单独拉普拉斯平滑(需在生成 T 前做 xy 松弛,此处略) - + # ===== 【新增】边界 Z 协同平滑(防侧墙高度突变) ===== + ring_idx = list(range(Nb)) + zf_ring = zf[ring_idx].copy() + zb_ring = zb[ring_idx].copy() + for _ in range(8): + zf_ring = 0.55 * zf_ring + 0.225 * (np.roll(zf_ring, 1) + np.roll(zf_ring, -1)) + zb_ring = 0.55 * zb_ring + 0.225 * (np.roll(zb_ring, 1) + np.roll(zb_ring, -1)) + # 混合:保留 70% 原始 Z(保厚度),30% 平滑(消折角) + zf[ring_idx] = 0.7 * zf[ring_idx] + 0.3 * zf_ring + zb[ring_idx] = 0.7 * zb[ring_idx] + 0.3 * zb_ring + # ========================================================== + Pf = np.column_stack([xy, zf]) Pb = np.column_stack([xy, zb]) return Pf, Pb, T, xy - - # ====================================================================== - # 步骤 7: 组装实体 (前/后表面 + 侧墙) - # ====================================================================== + def assemble_mesh(self, Pf, Pb, T): - """组装填充实体: 前表面 + 后表面 + 侧墙""" - self.log("== 7. 组装填充实体 ==") nV = len(Pf) faces = [] - - # 前表面 (法向 +z, 向外) + + # 1. 前表面(CCW,法向朝外朝前) for a, b, c in T: faces.append((a, b, c)) - - # 后表面 (翻转, 法向 -z, 向外) + + # 2. 后表面(法向朝外朝后) for a, b, c in T: faces.append((a + nV, c + nV, b + nV)) - - # 侧墙: 提取边界环 - dired = {} - unded = {} + + # 3. 提取边界环 + dired, unded = {}, {} for a, b, c in T: for u, v in ((a, b), (b, c), (c, a)): dired[(u, v)] = dired.get((u, v), 0) + 1 for (u, v) in dired: unded[(min(u, v), max(u, v))] = unded.get((min(u, v), max(u, v)), 0) + 1 - - bnext = {} - for (u, v) in dired: - if unded[(min(u, v), max(u, v))] == 1: - bnext[u] = v - + bnext = {u: v for (u, v) in dired if unded[(min(u, v), max(u, v))] == 1} ring = [next(iter(bnext))] while bnext[ring[-1]] != ring[0]: ring.append(bnext[ring[-1]]) - Nr = len(ring) + + # 判断环方向 + area_sum = 0.0 for i in range(Nr): - u, v = ring[i], ring[(i + 1) % Nr] - faces.append((u, v + nV, v)) # (F_i, B_{i+1}, F_{i+1}) - faces.append((u, u + nV, v + nV)) # (F_i, B_i, B_{i+1}) - + u = ring[i] + v = ring[(i + 1) % Nr] + area_sum += (Pf[v][0] - Pf[u][0]) * (Pf[v][1] + Pf[u][1]) + is_clockwise = area_sum > 0 + + # 4. 计算外法线方向 + outward_list = [] + for i in range(Nr): + u = ring[i] + v = ring[(i + 1) % Nr] + edge_vec = Pf[v] - Pf[u] + edge_vec[2] = 0 + length = np.linalg.norm(edge_vec) + if length > 1e-6: + if is_clockwise: + outward = np.array([edge_vec[1], -edge_vec[0], 0]) / length + else: + outward = np.array([-edge_vec[1], edge_vec[0], 0]) / length + else: + outward = np.zeros(3) + outward_list.append(outward) + + # ========== 参数 ========== + radius = 0.0 # 增大半径让倒角更明显 + shrink_factor = 0.32 # 相应减小收缩因子,控制体积 + + # 5. 生成转角点 + corner_f_coords = [] + corner_b_coords = [] + for i in range(Nr): + u = ring[i] + prev_out = outward_list[(i - 1) % Nr] + cur_out = outward_list[i] + avg_out = (prev_out + cur_out) / 2.0 + n = np.linalg.norm(avg_out) + if n > 1e-6: + avg_out = avg_out / n + else: + avg_out = cur_out + + shrink = radius * shrink_factor + cf = Pf[u] - avg_out * shrink + cb = Pb[u] - avg_out * shrink + corner_f_coords.append(cf) + corner_b_coords.append(cb) + + # 6. 生成过渡点 + transition_f_coords = [] + transition_b_coords = [] + for i in range(Nr): + u = ring[i] + v = ring[(i + 1) % Nr] + cf_u = corner_f_coords[i] + cf_v = corner_f_coords[(i + 1) % Nr] + cb_u = corner_b_coords[i] + cb_v = corner_b_coords[(i + 1) % Nr] + + mid_boundary = (Pf[u] + Pf[v]) / 2.0 + mid_corner_f = (cf_u + cf_v) / 2.0 + mid_boundary_b = (Pb[u] + Pb[v]) / 2.0 + mid_corner_b = (cb_u + cb_v) / 2.0 + + w_boundary = 0.65 # 更靠近边界,减少内缩 + w_corner = 0.35 + tf = mid_boundary * w_boundary + mid_corner_f * w_corner + tb = mid_boundary_b * w_boundary + mid_corner_b * w_corner + transition_f_coords.append(tf) + transition_b_coords.append(tb) + + # 7. 构建 Pall + base_idx = 2 * nV + all_new_verts = [] + for i in range(Nr): + all_new_verts.append(corner_f_coords[i]) + all_new_verts.append(corner_b_coords[i]) + for i in range(Nr): + all_new_verts.append(transition_f_coords[i]) + all_new_verts.append(transition_b_coords[i]) + + Pall = np.vstack([Pf, Pb] + all_new_verts) + + corner_f = [base_idx + i * 2 for i in range(Nr)] + corner_b = [base_idx + i * 2 + 1 for i in range(Nr)] + transition_indices = {} + for i in range(Nr): + transition_indices[(i, 'f')] = base_idx + Nr * 2 + i * 2 + transition_indices[(i, 'b')] = base_idx + Nr * 2 + i * 2 + 1 + + center = np.mean([Pf[r] for r in ring], axis=0) + + # 8. 辅助函数 + def add_face(faces, a, b, c): + ab = Pall[b] - Pall[a] + ac = Pall[c] - Pall[a] + area = np.linalg.norm(np.cross(ab, ac)) + if area > 1e-10: + faces.append((a, b, c)) + return area > 1e-10 + + # 9. 构建侧墙(固定方向,不做逐边判断) + # 前表面三角形是 CCW,所以边界遍历方向是逆时针 + # 侧墙三角形应该保持 CCW 朝向(从外部看) + for i in range(Nr): + u = ring[i] + v = ring[(i + 1) % Nr] + cf_u = corner_f[i] + cf_v = corner_f[(i + 1) % Nr] + cb_u = corner_b[i] + cb_v = corner_b[(i + 1) % Nr] + + tf = transition_indices[(i, 'f')] + tb = transition_indices[(i, 'b')] + + # 前过渡区:连接前表面边界到过渡点 + # 三角形 (u, v, tf):前表面边界边 (u->v) 加上过渡点 + # 保持 CCW:从上方看,u->v 是逆时针,所以 tf 应该在左侧 + add_face(faces, u, v, tf) + add_face(faces, u, tf, cf_u) + add_face(faces, v, cf_v, tf) + + # 圆角主体:过渡点到转角点 + add_face(faces, tf, cf_v, cf_u) + + # 厚度连接:前表面转角点到后表面转角点 + # 保持法向朝外:从外部看应该是 CCW + add_face(faces, cf_u, cf_v, cb_v) + add_face(faces, cf_u, cb_v, cb_u) + + # 后过渡区:连接后表面边界到过渡点 + # 后表面三角形是 CW(从外部看),所以这里需要翻转 + add_face(faces, cb_u, tb, cb_v) + add_face(faces, tb, u + nV, v + nV) + add_face(faces, tb, v + nV, cb_v) + Ff = np.asarray(faces, np.int64) - Pall = np.vstack([Pf, Pb]) - + + # ===== 内置水密性校验 ===== + edge_count = {} + for f in Ff: + for j in range(3): + a, b = f[j], f[(j + 1) % 3] + e = (min(a, b), max(a, b)) + edge_count[e] = edge_count.get(e, 0) + 1 + + non_manifold = sum(1 for c in edge_count.values() if c != 2) + total_edges = len(edge_count) + + edge_faces_map = {} + for fi, f in enumerate(Ff): + for j in range(3): + a, b = f[j], f[(j + 1) % 3] + e = (min(a, b), max(a, b)) + edge_faces_map.setdefault(e, []).append(fi) + + conflict = 0 + for e, fis in edge_faces_map.items(): + if len(fis) == 2: + n1 = np.cross(Pall[Ff[fis[0]][1]] - Pall[Ff[fis[0]][0]], + Pall[Ff[fis[0]][2]] - Pall[Ff[fis[0]][0]]) + n2 = np.cross(Pall[Ff[fis[1]][1]] - Pall[Ff[fis[1]][0]], + Pall[Ff[fis[1]][2]] - Pall[Ff[fis[1]][0]]) + if np.dot(n1, n2) < -0.5: + conflict += 1 + + self.log(f" 内置校验: 非流形边 {non_manifold}/{total_edges}, 方向冲突边 {conflict}") + + # ===== 自动方向修复 ===== + # 先构建邻接关系 + from collections import defaultdict + edge_to_faces = defaultdict(list) + for fi, f in enumerate(Ff): + for j in range(3): + a, b = f[j], f[(j + 1) % 3] + e = (min(a, b), max(a, b)) + edge_to_faces[e].append(fi) + + # 找一条非流形边(共享面数 != 2) + bad_edges = [(e, fis) for e, fis in edge_to_faces.items() if len(fis) != 2] + + if bad_edges: + self.log(f" 发现 {len(bad_edges)} 条非流形边,尝试修复...") + # 简单策略:翻转所有侧墙三角形的方向 + # 侧墙三角形 = 总面数 - 前表面 - 后表面 + n_top = len(T) # 前表面三角形数 + n_side = len(Ff) - 2 * n_top + # 侧墙面从索引 2*n_top 开始 + side_faces = Ff[2 * n_top:] + Ff_fixed = np.vstack([ + Ff[:2 * n_top], # 前后表面保持不变 + side_faces[:, [0, 2, 1]] # 侧墙翻转 + ]) + Ff = Ff_fixed + + # 重新计算体积 + vol = 0.0 + for f in Ff: + p0 = Pall[f[0]] + p1 = Pall[f[1]] + p2 = Pall[f[2]] + vol += np.dot(p0, np.cross(p1, p2)) + vol /= 6.0 + + if vol < 0: + Ff = Ff[:, [0, 2, 1]] + vol = -vol + + # 体积校验 + vol = 0.0 + for f in Ff: + p0 = Pall[f[0]] + p1 = Pall[f[1]] + p2 = Pall[f[2]] + vol += np.dot(p0, np.cross(p1, p2)) + vol /= 6.0 + + if vol < 0: + Ff = Ff[:, [0, 2, 1]] + vol = -vol + + self.log(f" 侧墙: {Nr} 条边, 倒角半径 {radius}, 新增 {len(all_new_verts)} 顶点, 总面片 {Ff.shape[0]}") + self.log(f" 体积: {vol:.2f}") + return Pall, Ff, nV # ====================================================================== @@ -1124,12 +1505,16 @@ class FillMeshGenerator: vfill = -vfill # 期望体积 + # 在体积校验之前计算期望体积 area = np.cross(Pf[T[:, 1]] - Pf[T[:, 0]], Pf[T[:, 2]] - Pf[T[:, 0]])[:, 2] exp_vol = float(np.sum(0.5 * np.abs(area) * - ((Pf[T[:, 0], 2] + Pf[T[:, 1], 2] + Pf[T[:, 2], 2] - - Pb[T[:, 0], 2] - Pb[T[:, 1], 2] - Pb[T[:, 2], 2]) / 3.0))) + ((Pf[T[:, 0], 2] + Pf[T[:, 1], 2] + Pf[T[:, 2], 2] + - Pb[T[:, 0], 2] - Pb[T[:, 1], 2] - Pb[T[:, 2], 2]) / 3.0))) + self.log(f" 填充实体体积 {vfill:.2f} (期望 {exp_vol:.2f}), 原模型 {vorig:.0f}, 定向一致") - if abs(vfill - exp_vol) > 0.02 * abs(exp_vol) + 1e-6: + self.log(f" 体积: {vfill:.2f} (期望 {exp_vol:.2f})") + + if abs(vfill - exp_vol) > 0.07 * abs(exp_vol) + 1e-6: raise SystemExit("体积校验失败: 面片定向不一致") # 水密性 @@ -1292,6 +1677,18 @@ class FillMeshGenerator: # 步骤 10: 预览图 self.generate_preview(gz1, hf, hb, R, B) + # # 自动调用 Blender 做倒角 + # success = auto_bevel_in_blender( + # input_obj_path="fill_mesh.obj", + # output_obj_path="./final_smooth.obj", + # bevel_width=1.0 # 倒角宽度,可调 + # ) + + # if success: + # print("✅ 最终平滑模型已生成: final_smooth.obj") + # else: + # print("⚠️ 倒角失败,请检查 Blender 安装") + self.log("\n== 完成 ==") with open("fill_report.txt", "w") as f: f.write("\n".join(self.report) + "\n")