""" Trims M_SHIP_Dorsal_Roof (the roof pod) to the hull's skin. blender -b --factory-startup --python build/roof_trim.py -- [] [--apply] [--again] [--from-mesh ] The pod is a lofted shell that sits on the hull. Its lower part ran on below the hull's skin: 0.6 to 0.7 m along both sides, 1.7 m at its aft end (with two loose open edges in there), 2 cm at the front where an earlier pass had already trimmed it. Inside the ship that showed as strips hanging through the dock room's lining and under the bridge's ceiling. In the interior fork it had been hidden with a Mask modifier, which left a stepped edge and spikes (faces that reached from outside to deep inside). What this does, on a copy of the mesh: * signed distance of every pod vertex to the hull's skin (the hull's own mesh, its modifiers ignored, and triangulated THE WAY BLENDER DRAWS IT, its loop triangles. The first cut of 2026-10-12 measured against BVHTree.FromPolygons, which splits a quad along its own choice of diagonal. On this hull's warped quads the two surfaces lie up to 11 mm apart, so that cut, 5 mm "under the skin", stood up to 3 mm above the drawn skin in twenty places along the fore part of the pod. It was done again the same night against the drawn skin.); * the pod is cut along the surface DEPTH below the skin: a vertex that is already within SLIDE of the cut along an edge is slid there, every other edge that crosses the cut gets a vertex exactly on it (so the pod's visible surface keeps its shape and its UVs), every crossed face is split along the cut, and everything below is removed; * faces left with five or more corners by the cut become a quad and a triangle; * loose bits are dropped. The pod's edge then lies on one smooth curve, DEPTH under the skin, all round. Where the pod shares its edge with the canopy glass that edge is not below the hull's skin and is left exactly as it is. The file it is run on is not saved unless --apply is given. The new mesh (without material, so appending it brings nothing else along) is written to , numbers to . also saves a copy of the file with the trimmed pod in place, to look at before deciding. --apply puts the trimmed mesh on the pod in the file itself (same material, the mask and its vertex group removed, the mesh from before kept with a fake user) and saves it. Not for a file that is open in someone's Blender: there, append the mesh from the library and assign it by hand. --again cut although the pod is trimmed already (its mesh carries the property "trim"); without it such a pod is left alone. --from-mesh N cut the mesh datablock N instead of the mesh the pod carries now (the untrimmed mesh that was kept in the file: "Dorsal roof | regular rounded loft.006"). With --apply the trimmed mesh the pod carried until then is removed. On 2026-10-12 the pod of M_Ship_v2_interior_season2.blend was trimmed this way in the user's open session. """ import collections import json import sys import bmesh import bpy from mathutils.bvhtree import BVHTree RAW = sys.argv[sys.argv.index("--") + 1:] APPLY, AGAIN = "--apply" in RAW, "--again" in RAW FROM = RAW[RAW.index("--from-mesh") + 1] if "--from-mesh" in RAW else None ARGS = [a for i, a in enumerate(RAW) if not a.startswith("--") and not (i > 0 and RAW[i - 1] == "--from-mesh")] OUT_LIB, OUT_REPORT = ARGS[0], ARGS[1] OUT_PREVIEW = ARGS[2] if len(ARGS) > 2 else None DEPTH = 0.005 # the pod's edge ends this far below the hull's skin SNAP = 0.0015 # a vertex this close to the cut surface counts as on it SLIDE = 0.02 # a vertex whose edge meets the cut within this distance is slid onto the cut (no slivers) NEW_NAME = "Dorsal roof | trimmed to the hull skin" MASK = "S2 mask skirt inside the dock room" roof = bpy.data.objects["M_SHIP_Dorsal_Roof"] hull = bpy.data.objects["M_SHIP_Hull"] if roof.data.get("trim") and not AGAIN: print("ROOF already trimmed, nothing done: " + str(roof.data["trim"])) raise SystemExit(0) hm = hull.data hm.calc_loop_triangles() # the hull as it is drawn hbvh = BVHTree.FromPolygons([tuple(hull.matrix_world @ v.co) for v in hm.vertices], [tuple(t.vertices) for t in hm.loop_triangles], epsilon=0.0) RW = roof.matrix_world def sdist(p): q = RW @ p loc, nrm, idx, dist = hbvh.find_nearest(q) return (q - loc).dot(nrm) def audit(bm): return {"verts": len(bm.verts), "faces": len(bm.faces), "tris": sum(1 for f in bm.faces if len(f.verts) == 3), "ngons": sum(1 for f in bm.faces if len(f.verts) > 4), "open_edges": sum(1 for e in bm.edges if len(e.link_faces) == 1), "non_manifold": sum(1 for e in bm.edges if len(e.link_faces) > 2), "zero_area": sum(1 for f in bm.faces if f.calc_area() < 1e-8), "loose_verts": sum(1 for v in bm.verts if not v.link_faces)} def boundary_loops(bm): adj = collections.defaultdict(list) for e in bm.edges: if len(e.link_faces) == 1: adj[e.verts[0]].append(e.verts[1]) adj[e.verts[1]].append(e.verts[0]) seen, loops = set(), [] for s0 in adj: if s0 in seen: continue comp, stack = [], [s0] while stack: a = stack.pop() if a in seen: continue seen.add(a) comp.append(a) stack += adj[a] loops.append(comp) return sorted(loops, key=len, reverse=True) def crossing(lo, hi): """Point on the edge lo -> hi where it meets the cut surface (bisection on the true distance).""" pa, pb = lo.co.copy(), hi.co.copy() f0, f1 = 0.0, 1.0 for _ in range(26): fm = 0.5 * (f0 + f1) if sdist(pa.lerp(pb, fm)) + DEPTH < 0.0: f0 = fm else: f1 = fm fac = 0.5 * (f0 + f1) return fac, pa.lerp(pb, fac) report = {"depth_below_skin_m": DEPTH, "measured_against": "the hull's loop triangles (as drawn)"} carried = roof.data # what the pod has now src = bpy.data.meshes[FROM] if FROM else roof.data bm = bmesh.new() bm.from_mesh(src) dl = bm.verts.layers.deform.active if dl is not None: bm.verts.layers.deform.remove(dl) # the mask's vertex group goes with the mask report["before"] = audit(bm) t = {v: sdist(v.co) + DEPTH for v in bm.verts} report["before"]["verts_more_than_5mm_below_the_skin"] = sum(1 for v in bm.verts if t[v] - DEPTH < -0.005) report["before"]["deepest_below_the_skin_m"] = round(-(min(t.values()) - DEPTH), 3) report["before"]["boundary_loops"] = [len(l) for l in boundary_loops(bm)] old_boundary = set(v for l in boundary_loops(bm) for v in l) cut = set() for v in bm.verts: if abs(t[v]) < SNAP: t[v] = 0.0 cut.add(v) # ---- vertices that are nearly on the cut are slid onto it along their edge ----------------------------------------- n_slid = 0 for _ in range(3): moved = False for e in list(bm.edges): a, b = e.verts if t[a] * t[b] >= 0.0: continue lo, hi = (a, b) if t[a] < 0 else (b, a) fac, p = crossing(lo, hi) d_hi, d_lo = (hi.co - p).length, (lo.co - p).length if d_hi <= SLIDE and d_hi <= d_lo: hi.co = p t[hi] = 0.0 cut.add(hi) n_slid += 1 moved = True elif d_lo <= SLIDE: lo.co = p t[lo] = 0.0 cut.add(lo) n_slid += 1 moved = True if not moved: break report["verts_slid_onto_the_cut"] = n_slid # ---- a vertex on every other edge that crosses the cut ------------------------------------------------------------- n_split = 0 for e in list(bm.edges): a, b = e.verts if t[a] * t[b] >= 0.0: continue lo, hi = (a, b) if t[a] < 0 else (b, a) fac, p = crossing(lo, hi) ne, nv = bmesh.utils.edge_split(e, lo, fac) nv.co = p t[nv] = 0.0 cut.add(nv) n_split += 1 report["edges_cut"] = n_split # ---- split every crossed face along the cut ---------------------------------------------------------------------- n_face_split, unsplit = 0, 0 for f in list(bm.faces): work = [f] guard = 0 while work and guard < 12: guard += 1 g = work.pop() gv = list(g.verts) gs = [t[v] for v in gv] if not (any(s < 0 for s in gs) and any(s > 0 for s in gs)): continue n = len(gv) idx = [i for i in range(n) if gs[i] == 0.0] pair = None for ii in range(len(idx)): a, b = idx[ii], idx[(ii + 1) % len(idx)] if a == b or (a + 1) % n == b: continue # nothing between them pair = (a, b) # between two consecutive cut vertices everything is on one side break if pair is None: unsplit += 1 continue try: nf, nl = bmesh.utils.face_split(g, gv[pair[0]], gv[pair[1]]) except Exception: unsplit += 1 continue if nf is None: unsplit += 1 continue n_face_split += 1 work += [g, nf] report["faces_split"] = n_face_split report["crossed_faces_that_could_not_be_split"] = unsplit # ---- everything below the cut goes ------------------------------------------------------------------------------- below = [v for v in bm.verts if t.get(v, 0.0) < 0.0] report["verts_removed"] = len(below) bmesh.ops.delete(bm, geom=below, context="VERTS") # faces that lie wholly in the cut surface (all corners on it) are not part of the visible pod: only if they hang free seen, comps = set(), [] for v in bm.verts: if v in seen: continue comp, stack = [], [v] while stack: a = stack.pop() if a in seen: continue seen.add(a) comp.append(a) stack += [e.other_vert(a) for e in a.link_edges] comps.append(comp) comps.sort(key=len, reverse=True) report["islands_dropped (vertex counts)"] = [len(c) for c in comps[1:]] for c in comps[1:]: bmesh.ops.delete(bm, geom=c, context="VERTS") loose = [v for v in bm.verts if not v.link_faces] if loose: bmesh.ops.delete(bm, geom=loose, context="VERTS") def skin_point(p): """p moved along the hull's normal onto the cut surface (DEPTH under the skin).""" q = RW @ p loc, nrm, idx, dist = hbvh.find_nearest(q) s = (q - loc).dot(nrm) return RW.inverted() @ (q - nrm * (s + DEPTH)) # where the hull is hollow along the pod's edge, a straight edge between two cut points would rise above the skin # (a hairline gap seen from outside): such an edge gets one more vertex, set down onto the cut surface n_refined = 0 for _ in range(4): todo = [] for e in bm.edges: if len(e.link_faces) != 1 or not (e.verts[0] in cut and e.verts[1] in cut): continue a, b = e.verts[0].co, e.verts[1].co worst, at = -1.0, None for k in range(1, 8): s = sdist(a.lerp(b, k / 8.0)) if s > worst: worst, at = s, k / 8.0 if worst > -0.0015: todo.append((e, at)) if not todo: break for e, at in todo: a = e.verts[0] ne, nv = bmesh.utils.edge_split(e, a, at) nv.co = skin_point(nv.co) t[nv] = 0.0 cut.add(nv) n_refined += 1 report["edge_vertices_added_where_the_hull_is_hollow"] = n_refined ng = [f for f in bm.faces if len(f.verts) > 4] report["ngons_from_the_cut"] = len(ng) if ng: r = bmesh.ops.triangulate(bm, faces=ng) bmesh.ops.join_triangles(bm, faces=r["faces"], angle_face_threshold=0.7, angle_shape_threshold=3.14) for f in bm.faces: f.smooth = True bm.normal_update() # ---- what came out --------------------------------------------------------------------------------------------- after = audit(bm) loops = boundary_loops(bm) after["boundary_loops"] = [len(l) for l in loops] sd_all = [sdist(v.co) for v in bm.verts] after["deepest_below_the_skin_m"] = round(-min(sd_all), 4) after["verts_more_than_6mm_below_the_skin"] = sum(1 for s in sd_all if s < -0.006) edge_sd, kept_old = [], [] for l in loops: for v in l: if v in cut: edge_sd.append(sdist(v.co)) else: kept_old.append(v) after["edge_vertices_on_the_cut"] = len(edge_sd) after["edge_vertices_kept_as_they_were"] = len(kept_old) if kept_old: ks = [sdist(v.co) for v in kept_old] after["kept_edge: signed distance to the skin (min, max)"] = [round(min(ks), 4), round(max(ks), 4)] after["kept_edge: x range"] = [round(min(v.co.x for v in kept_old), 2), round(max(v.co.x for v in kept_old), 2)] if edge_sd: after["cut_edge_depth_m (min, max)"] = [round(-max(edge_sd), 4), round(-min(edge_sd), 4)] mids = [] for e in bm.edges: if len(e.link_faces) == 1 and e.verts[0] in cut and e.verts[1] in cut: mids.append(sdist((e.verts[0].co + e.verts[1].co) * 0.5)) if mids: after["cut_edge_midpoints_depth_m (min, max)"] = [round(-max(mids), 4), round(-min(mids), 4)] after["cut_edge_midpoints_above_the_skin"] = sum(1 for m in mids if m > 0.0) # the whole edge, every edge in nine places: nothing of it may be above the skin along = [] for e in bm.edges: if len(e.link_faces) == 1: a, b = e.verts[0].co, e.verts[1].co along += [sdist(a.lerp(b, k / 8.0)) for k in range(9)] after["edge_samples"] = len(along) after["edge_samples_above_the_skin"] = sum(1 for m in along if m > 0.0) after["edge_highest_mm (negative = under the skin)"] = round(max(along) * 1000, 2) after["edge_lowest_mm"] = round(min(along) * 1000, 2) after["x_range"] = [round(min(v.co.x for v in bm.verts), 3), round(max(v.co.x for v in bm.verts), 3)] after["z_range"] = [round(min(v.co.z for v in bm.verts), 3), round(max(v.co.z for v in bm.verts), 3)] areas = sorted(f.calc_area() for f in bm.faces) after["smallest_face_cm2"] = round(areas[0] * 1e4, 3) after["faces_under_1_cm2"] = sum(1 for a in areas if a < 1e-4) el = sorted(e.calc_length() for e in bm.edges) after["shortest_edge_mm"] = round(el[0] * 1000, 2) report["after"] = after new = bpy.data.meshes.new(NEW_NAME) bm.to_mesh(new) bm.free() new["trim"] = "cut %.0f mm under the hull skin by build/roof_trim.py, 2026-10-12; the mesh before is kept as '%s'" % (DEPTH * 1000, src.name) report["uv_layers"] = [u.name for u in new.uv_layers] report["new_mesh"] = new.name report["old_mesh"] = src.name bpy.data.libraries.write(OUT_LIB, {new}, fake_user=True) with open(OUT_REPORT, "w", encoding="utf-8") as f: json.dump(report, f, indent=1) print("ROOF " + json.dumps(report)) if OUT_PREVIEW or APPLY: # the same steps as were done by hand in the user's open session on 2026-10-12 for m in src.materials: new.materials.append(m) src.use_fake_user = True # the mesh from before stays in the file: assign it back to undo roof.data = new if carried is not src and carried.get("trim") and carried.users == 0: bpy.data.meshes.remove(carried) # an earlier cut that this one replaces new.name = NEW_NAME for m in [m for m in roof.modifiers if m.name == MASK]: roof.modifiers.remove(m) if roof.vertex_groups.get(MASK): roof.vertex_groups.remove(roof.vertex_groups[MASK]) roof["trim"] = "cut %.0f mm under the hull skin (build/roof_trim.py). The mesh before is kept as '%s'." % (DEPTH * 1000, src.name) if OUT_PREVIEW: bpy.ops.wm.save_as_mainfile(filepath=OUT_PREVIEW, copy=True, check_existing=False) print("ROOF preview saved " + OUT_PREVIEW) if APPLY: bpy.context.preferences.filepaths.save_version = 0 bpy.ops.wm.save_mainfile() print("ROOF applied and saved " + bpy.data.filepath)