"""Spearfish: collision hulls for Unreal (UCX_SM_Spearfish_Hull_NN in "SPEARFISH | Collision (UCX)"), low-poly and convex. Exec'd by sf_finish.py at the end of a build, or on its own in a file that is already built (it replaces only the UCX objects; standalone it first execs tender_lib.py, sf_measure.py and sf_shape.py for the hull's shape): exec(open(r"...\\build\\sf_collision.py").read(), {"__name__": "ucx"}) 2026-10-20, the user, with a viewport picture of long thin triangles all over the hull: "why is the topology looks like this in blender". Those were these hulls, drawn as wireframes over the model, not the model's topology. They were convex hulls of every vertex of a slice (600 to 1,500 faces each, with the unused inner vertices left in the mesh). Now: * the hull in eight slices along its length, each the convex hull of rings of 16 points on the hull's exact shape, every 0.4 m or less and at both cut stations (shared, so no gaps), pushed 1 cm out (a 16-gon's flats lie up to 2 cm inside); the stern slice also takes the lip and lugs; * the two dorsal rails, the two fins (with their lamp pods), the two canards and the docking clips: hulls of their own vertices outside the skin, cut down to 24-40 points by farthest-point sampling; * no loose or interior vertices; every hull checked convex: no vertex more than 1 mm in front of any face plane (the 0.03-0.7 mm found are float rounding on near-flat faces; Unreal rebuilds each hull from these points anyway); * the collection is hidden in the viewport (the eye in the outliner); the export does not need it visible. """ import bpy, bmesh, math, os from mathutils import Vector HERE = r"D:\HADAL - The Descent\models\tender-spearfish\build" if "sec" not in globals(): # standalone: the hull's shape exec(open(os.path.join(HERE, "tender_lib.py")).read()) exec(open(os.path.join(HERE, "sf_measure.py")).read()) exec(open(os.path.join(HERE, "sf_shape.py")).read()) UCX_COLL = "SPEARFISH | Collision (UCX)" col = bpy.data.collections.get(UCX_COLL) if col is None: col = bpy.data.collections.new(UCX_COLL) bpy.context.scene.collection.children.link(col) for o in list(col.objects): me = o.data bpy.data.objects.remove(o) if me is not None and me.users == 0: bpy.data.meshes.remove(me) src = bpy.data.objects["SM_Spearfish_Hull"] SRC_PTS = [src.matrix_world @ v.co for v in src.data.vertices] def fps(pts, k): """farthest-point sampling: k points spread over the set (keeps the extremes)""" if len(pts) <= k: return list(pts) c = sum(pts, Vector()) / len(pts) chosen = [max(pts, key=lambda p: (p - c).length)] d = [(p - chosen[0]).length for p in pts] while len(chosen) < k: i = max(range(len(pts)), key=lambda j: d[j]) chosen.append(pts[i]) for j, p in enumerate(pts): dj = (p - pts[i]).length if dj < d[j]: d[j] = dj return chosen def hull_vertices(pts): bm = bmesh.new() vs = [bm.verts.new(p) for p in pts] bmesh.ops.convex_hull(bm, input=vs) out = [v.co.copy() for v in bm.verts if v.link_faces] bm.free() return out N_UCX = [0] REPORT_UCX = [] def make(pts, k, tag): """a convex hull object from points, at most k vertices; returns its name""" pts = hull_vertices(pts) pts = fps(pts, k) bm = bmesh.new() vs = [bm.verts.new(p) for p in pts] res = bmesh.ops.convex_hull(bm, input=vs) junk = [g for g in res["geom_interior"] + res["geom_unused"] if isinstance(g, bmesh.types.BMVert)] if junk: bmesh.ops.delete(bm, geom=junk, context='VERTS') loose = [v for v in bm.verts if not v.link_faces] if loose: bmesh.ops.delete(bm, geom=loose, context='VERTS') bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) bm.normal_update() worst = max(max(f.normal.dot(v.co - f.verts[0].co) for v in bm.verts) for f in bm.faces) N_UCX[0] += 1 name = "UCX_SM_Spearfish_Hull_%02d" % N_UCX[0] me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() o = bpy.data.objects.new(name, me) col.objects.link(o) o.display_type = 'WIRE' o.hide_render = True REPORT_UCX.append(dict(name=name, part=tag, verts=len(me.vertices), tris=sum(len(p.vertices) - 2 for p in me.polygons), convex_within_mm=round(max(0.0, worst) * 1000, 2))) # rounding on near-flat faces, < 1 mm return name # ---- the hull in slices, from its exact shape RING = 16 PUSH = 0.010 def ring_pts(y): out = [] for k in range(RING): th = -math.pi / 2 + 2 * math.pi * (k + 0.5) / RING p = sec(y, th) out.append(p + normal_at(p) * PUSH) return out cuts = [Y_NOSE, -3.6, -2.4, -1.2, 0.0, 1.2, 2.4, 3.6, Y_TAIL] for j in range(len(cuts) - 1): a, b = cuts[j], cuts[j + 1] n = max(1, int(math.ceil((b - a) / 0.4))) pts = [] for i in range(n + 1): y = a + (b - a) * i / n if y <= Y_NOSE + 0.02: pts.append(Vector((0, Y_NOSE, Zi(419.0)))) continue pts += ring_pts(y) if j == len(cuts) - 2: # the stern: lip, lugs, end panel pts += [p for p in SRC_PTS if p.y > Y_TAIL - 0.02 and abs(p.x) < 0.6] make(pts, 64, "hull slice %.2f .. %.2f m" % (a, b)) # ---- parts that stand out of the skin: their own vertices outside it (5 mm or more) def outside(p): return inside_depth(p) < -0.005 rx0, rx1, rlat, rhw, rtop = RAILS def _r_clamp(p): return math.hypot(p.x, p.y - Yi(CL_IMG)) # (the bounds keep each part's hull off its neighbours: the window and canopy frames stand 10 mm proud of the skin next to # the fin and canard roots, and the clips' disc overhangs the rails) PARTS = [ ("dorsal rail, port", 24, lambda p: 0.85 < p.z < 1.105 and 0.30 < p.x < 0.75 and Yi(rx1) - 0.05 < p.y < Yi(rx0) + 0.05), ("dorsal rail, starboard", 24, lambda p: 0.85 < p.z < 1.105 and -0.75 < p.x < -0.30 and Yi(rx1) - 0.05 < p.y < Yi(rx0) + 0.05), ("fin and lamp pod, port", 32, lambda p: p.x > 1.09 and p.z < 0.05 and -0.1 < p.y < 1.9), ("fin and lamp pod, starboard", 32, lambda p: p.x < -1.09 and p.z < 0.05 and -0.1 < p.y < 1.9), # (the canards moved aft of the bigger canopy on 2026-10-20: y -0.82 .. +0.09; above the fin root fairing, z > 0.20) ("canard, port", 24, lambda p: p.x > 1.11 and p.z > 0.20 and -0.90 < p.y < 0.16), ("canard, starboard", 24, lambda p: p.x < -1.11 and p.z > 0.20 and -0.90 < p.y < 0.16), ("docking clips and column", 40, lambda p: (p.z > 1.11 and _r_clamp(p) < 0.62) or (p.z > 0.98 and _r_clamp(p) < 0.385)), ] for tag, k, sel in PARTS: pts = [p for p in SRC_PTS if sel(p) and outside(p)] if len(pts) >= 4: make(pts, k, tag) lc = bpy.context.view_layer.layer_collection.children.get(UCX_COLL) if lc is not None: lc.hide_viewport = True # the eye in the outliner: not drawn over the model result = {"ucx": REPORT_UCX}