"""The boarding gate: a pressure door set into the works hall's port wall, through the hull. The user, 2026-10-15, on a picture of the Season 1 kit's lattice gate in the works hall's port wall (the marker `Boarding gate (port, Anchorage bridge)` stands in front of it): "replace this part of the vessel change it with [models/pressure-door] basically you gonna open a hole on the main hull of the vessel all the way so it is going to be a pressure door". Later the same morning, with a second door built by another session: "Save a copy. now do the newly created pressure door I want to see which one will be looking better". So there are two doors, DOORS below, and one of them is in the file at a time: - "hinged": models/pressure-door (the round vault-style door, Ø 2.16 m clear, the leaf on a swing arm); - "sliding": models/pressure-door-sliding (two leaves sliding into pockets in a door plate, 1.40 x 2.10 m clear, a handle disc that turns a quarter before the leaves slide; its second version of 2026-10-15, built to this gate's 3.30 m). apply(door), in the open file: - appends the door's collection from its own file (rigged, with its collision hulls in a collection that is excluded here) and stands it with its floor on the floor strip along the wall (z -4.248), its wall face on the kit wall pieces' front face (y 7.30), on the gate's centre line (x -20.39). What the door brings for a wall of its own is not used (the ship's wall and floor are there); what would stand inside the ship's wall is dropped or cut (behind the wall is the lining void). - takes the kit's gate out: the two lattice leaves, their two edge pieces, the tech dressing in the niche behind them (server, wires, pipes, panel, two electric boxes, the light over the gate with its spot lamp), and for the hinged door the energy node in the bay's fore corner, where its hinge housing stands. - a wall plate (SM_BG_Gate_Plate) fills the 3.30 x 3.89 m gate opening between the two wall pieces round the door, made with the hinged door's plate code (pd_build) in the door's own paint; a base plate, 11.5 mm, in front (the kit's floor strip along the wall stands 12 mm proud of the floor and the door's front parts would stand across its edge). - a sleeve (SM_BG_Gate_Sleeve): the door's opening carried on from its back through the back wall and the lining void to the hull's skin and 7 cm beyond; a collar (SM_BG_Gate_Collar) round its mouth on the hull's outside, 7 cm proud, let into the skin (the hull flares 8 degrees there), with 16 bolts. - the hole: the back wall piece behind the gate (SI_SM_WVD_Wall_C) gets the opening's shape as a cut copy, and the hull mesh (M_SHIP_Hull) gets it cut into the mesh (exact boolean, the cutter's own faces taken out again: the hull is a skin). The hull as it was is kept as a mesh datablock with a fake user, for remove(). Everything is written to the text block RECORD; remove() puts the kit gate back and takes all of this out. To change the door: remove(), then apply(door=...). check(): nothing of this crosses the ship or the works hall's pieces; the moving parts through their whole travel against everything near; the hole is open along the axis; the parts stand where they should. In Unreal: the door's parts carry the asset paths of its layout JSON (the FBX files in its folder); the plate, base, sleeve and collar are own parts (meshes with "made_for"). M_Ship_Body in the GAME EXPORT scene is the old merged body: it has no hole. """ import json import math import os import sys import bpy import bmesh from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) import reactor_room as rr # noqa: E402 (the record, the cut-copy conventions, the crossing test's geometry cache) import shipfit # noqa: E402 TAG = "boarding_gate" ZONE = "10.2 | Works hall" RECORD = "BOARDING GATE | what the kit gate was" MADE_FOR = "M-Ship v2 boarding gate (build/boarding_gate.py)" PRE = "SM_BG_" COLL = "10.2 | Works hall | boarding gate (pressure door)" MODELS = os.path.normpath(os.path.join(HERE, "..", "..")) HULL = "M_SHIP_Hull" INTERIOR_ROOT = "M_SHIP_INTERIOR_S2" # where the door stands (ship frame: +X bow, +Y port, Z up). A door's own frame: floor z 0, wall front y 0, opens to -Y. AXIS_X = -20.39 # the gate's centre line (the marker's x) WALL_Y = 7.30 # the WVD wall pieces' front face FLOOR_Z = -4.248 # the floor strip along the wall (SI_SM_Floor_Main_Side_D) R_RING = 0.35 # the collar's width on the hull's outside PROUD = 0.07 # the collar over the skin; the sleeve's mouth is flush with its face RING_OFF = -0.012 # the collar's back is let into the skin (the skin is faceted; a ring laid on it would show slits) CHAMFER = 0.03 HOLE_CLEAR = 0.01 # the hull's hole round the sleeve WALL_C_CLEAR = 0.015 # the back wall piece's hole round the sleeve PLATE_HALF = 1.649 # the wall plate: between the wall pieces at x -22.04 and -18.74 (1 mm off each) PLATE_TOP = 3.887 # to the ceiling piece's underside (-0.36), 1 mm off PLATE_T = 0.30 # the doors' wall thickness (their Y_WB) SEAM_Z = 2.96 # the seam in the hinged door's own plate BASE_Y = (-0.67, -0.301, 0.0115) # the base plate in front: from the floor's seam groove (y 6.58-6.62) to 1 mm short of # the strip; 11.5 mm thick, 1 mm off the floor N = 96 TAU = 2.0 * math.pi OFF = 0.001 DOORS = { "hinged": dict( dir="pressure-door", blend="PressureDoor.blend", coll="PressureDoor", root="PressureDoor_Root", prefix="SM_PressureDoor_", ue="/Game/Vessel/PressureDoor/", static="PD | static", collision="PD | collision", mats=("M_PD_Paint", "M_PD_Steel", "M_PD_Dark"), masks="T_PD_Masks", unused=("SM_PressureDoor_Bulkhead", "SM_PressureDoor_FloorPlate"), opening=dict(kind="circle", r=1.08, zc=1.28), # the frame's bore (pd_build.R_OPEN, ZC) sleeve_y0=0.42, # the frame's back flange face (pd_build.Y_FB) sleeve_wall=0.12, tread_z=0.25, # the frame's tread, carried on inside the sleeve plate="round", base_x=(-1.80, 3.45), take_node=True, expected=15, look=dict(prefix="M_PD_", masks_name="T_PD_Masks"), movers=("SM_PressureDoor_Leaf", "SM_PressureDoor_Wheel", "SM_PressureDoor_Bolt_"), sliders=(("open", 0.0, 1.0),), atlas=lambda c: c.x < -1.75 and 0.3 < c.z < 1.4, # a flat panel of the door's own (unused) wall plate ), "sliding": dict( dir="pressure-door-sliding", blend="PressureDoorSliding.blend", coll="PressureDoorSliding", root="PressureDoorSliding_Root", prefix="SM_PDSliding_", ue="/Game/Vessel/PressureDoorSliding/", static="PDS | static", collision="PDS | collision", mats=("M_PDS_Paint", "M_PDS_Steel", "M_PDS_Dark"), masks="T_PDS_Masks", unused=(), opening=dict(kind="rrect", w=1.40, h=2.10, r=0.32, zc=1.21), # the clear opening (sd_build: 1.40 x 2.10, R 0.32, centre 1.21) sleeve_y0=0.33, # the back bezel's face; the bezel is the opening + 8 cm, as the sleeve's outside sleeve_wall=0.08, tread_z=None, plate="rect", plate_hole=(1.647, 3.0), # the door plate: x +-1.645 (5 mm inside the gate's wall pieces), 3.0 m high; # the side strips start 2 mm off it; # the dark recess on it to 3.4 (its first version had cables there) header_z0=3.401, base_x=(-2.45, 2.50), take_node=False, expected=14, look=dict(prefix="M_PDS_", masks_name="T_PDS_Masks"), movers=("SM_PDSliding_LeafL", "SM_PDSliding_LeafR", "SM_PDSliding_Handle", "SM_PDSliding_Monitor", "SM_PDSliding_Camera"), sliders=(("open", 0.0, 1.0), ("handle_turn", -1.0, 1.0), ("arm_swing", -1.0, 1.0), ("cam_yaw", -1.0, 1.0)), atlas=lambda c: abs(c.x) > 1.0 and 0.3 < c.z < 2.5, # the door plate's surround panel beside the opening ), } DEFAULT_DOOR = "sliding" # what the file holds now (the user compares the two; not a choice yet) # the kit gate: what stands in the niche (x within 1.8 of the axis, y from the wall face to the back wall) GATE_MESHES = {"SI_SM_WVD_Gate", "SI_SM_WVD_GateEdge", "SI_SM_WVD_Server", "SI_SM_WVD_Wires_A", "SI_SM_WVD_Wires_B", "SI_SM_WVD_Pipes_A", "SI_SM_WVD_Pipes_B", "SI_SM_WVD_Panel_A", "EN_SM_ElectricBox_C", "SI_SM_ElectricBox_A", "EP_SM_Light_B"} NICHE = (AXIS_X - 1.8, AXIS_X + 1.8, WALL_Y, WALL_Y + 0.75, FLOOR_Z - 0.1, 0.0) NODE_MESH = "SI_SM_EnergyNode_B" # the energy node in the bay's fore corner NODE_AT = (AXIS_X + 2.9, AXIS_X + 3.8, 6.5, 7.5) WALL_C_MESH = "SI_SM_WVD_Wall_C" # the back wall of the niche REGION = (-24.2, -16.5, 2.4, 10.6, -6.5, 0.2) # what the checks look at # ---- geometry ------------------------------------------------------------------------------------------------------ def _bb(o): lo, hi = rr._bb(o) return lo, hi def _in_box(o, box): lo, hi = _bb(o) x0, x1, y0, y1, z0, z1 = box return not (hi.x < x0 or lo.x > x1 or hi.y < y0 or lo.y > y1 or hi.z < z0 or lo.z > z1) def _skin(): """y of the hull's skin at (x, z), in the door's frame (world x, z offset by the door's origin), met from inside.""" hull = bpy.data.objects[HULL] tree = shipfit.drawn_tree(hull) def sk(xm, zm): o = Vector((xm + AXIS_X, WALL_Y + 0.5, zm + FLOOR_Z)) loc, nrm, idx, dist = tree.ray_cast(o, Vector((0.0, 1.0, 0.0)), 20.0) if loc is None: raise RuntimeError("no hull skin behind the gate at x %.2f z %.2f" % (o.x, o.z)) return loc.y - WALL_Y return sk def _pd(): """The hinged door's own build code (pd_build.py with pd_lib.py), without its auto-run: the geometry helpers.""" g = {"__name__": "pd_build_for_the_ship", "PD_NO_AUTORUN": True} with open(os.path.join(MODELS, "pressure-door", "build", "pd_build.py"), encoding="utf-8") as f: exec(f.read(), g) return g def _outline(op, margin, n=N): """n points (x, z) round the opening, offset outward by margin, counter-clockwise seen from the front, starting at the right-hand side at the opening's centre height""" if op["kind"] == "circle": r = op["r"] + margin return [(r * math.cos(TAU * k / n), op["zc"] + r * math.sin(TAU * k / n)) for k in range(n)] hw, hh, rc, zc = op["w"] / 2 + margin, op["h"] / 2 + margin, op["r"] + margin, op["zc"] sx, sz = hw - rc, hh - rc # the corner centres # the path: straight up the right side, then corners and straights round, by arc length segs = [] # (length, function t in 0..1 -> (x, z)) corners = [((sx, sz), 0.0), ((-sx, sz), 0.5 * math.pi), ((-sx, -sz), math.pi), ((sx, -sz), 1.5 * math.pi)] straights = [((hw, 0.0), (hw, sz)), ((sx, hh), (-sx, hh)), ((-hw, sz), (-hw, -sz)), ((-sx, -hh), (sx, -hh)), ((hw, -sz), (hw, 0.0))] order = [straights[0], corners[0], straights[1], corners[1], straights[2], corners[2], straights[3], corners[3], straights[4]] for item in order: if isinstance(item[1], tuple): # a straight: two points (a, b) = item segs.append((math.hypot(b[0] - a[0], b[1] - a[1]), (lambda a, b: lambda t: (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t))(a, b))) else: (c, a0) = item segs.append((0.5 * math.pi * rc, (lambda c, a0: lambda t: (c[0] + rc * math.cos(a0 + 0.5 * math.pi * t), c[1] + rc * math.sin(a0 + 0.5 * math.pi * t)))(c, a0))) total = sum(s[0] for s in segs) pts = [] for k in range(n): d = total * k / n for L, f in segs: if d <= L + 1e-9: x, z = f(d / L if L > 0 else 0.0) pts.append((x, zc + z)) break d -= L return pts def _dist2d(p, pts): """distance from p=(x, z) to the closed polyline pts""" best = 1e9 px, pz = p n = len(pts) for i in range(n): ax, az = pts[i] bx, bz = pts[(i + 1) % n] dx, dz = bx - ax, bz - az L2 = dx * dx + dz * dz t = 0.0 if L2 < 1e-12 else max(0.0, min(1.0, ((px - ax) * dx + (pz - az) * dz) / L2)) qx, qz = ax + dx * t, az + dz * t d = math.hypot(px - qx, pz - qz) if d < best: best = d return best def _prism(pts, y0, y1): """a mesh: the outline extruded along +Y from y0 to y1 (door frame), capped""" bm = bmesh.new() a = [bm.verts.new((x, y0, z)) for x, z in pts] b = [bm.verts.new((x, y1, z)) for x, z in pts] n = len(pts) for k in range(n): k2 = (k + 1) % n bm.faces.new([a[k], a[k2], b[k2], b[k]]) bm.faces.new(a[::-1]) bm.faces.new(b) bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) me = bpy.data.meshes.new("BG tmp cutter") bm.to_mesh(me) bm.free() return me def _bool_cut(ob, new_name, cutter_me, cutter_mw, skin=None): """ob's mesh with the cutter taken out of it (exact boolean), as a new mesh datablock; ob is not changed. skin=(outline, y0, y1): the mesh is a skin, not a solid: the faces the boolean adds on the cutter's own surface (its wall inside the shell and its caps) are deleted again, so that only the hole is left.""" cut = bpy.data.objects.new("BG tmp cutter", cutter_me) cut.matrix_world = cutter_mw tmp = bpy.data.objects.new("BG tmp target", ob.data) tmp.matrix_world = ob.matrix_world sc = bpy.context.scene.collection sc.objects.link(cut) sc.objects.link(tmp) md = tmp.modifiers.new("hole", 'BOOLEAN') md.operation = 'DIFFERENCE' md.solver = 'EXACT' md.object = cut bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() me = bpy.data.meshes.new_from_object(tmp.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg) me.name = new_name for k in ob.data.keys(): me[k] = ob.data[k] bpy.data.objects.remove(tmp, do_unlink=True) bpy.data.objects.remove(cut, do_unlink=True) bpy.data.meshes.remove(cutter_me) if skin is not None: pts, y0, y1 = skin to_door = cutter_mw.inverted() @ ob.matrix_world tol = 1.5e-3 # (new vertices lie on the cutter's flat sides, inside the curve) def on_cutter(co): p = to_door @ co return _dist2d((p.x, p.z), pts) < tol or abs(p.y - y0) < 1e-3 or abs(p.y - y1) < 1e-3 me["cutter_faces_dropped"] = _drop_faces(me, on_cutter) return me def _drop_faces(me, pred): """delete the faces whose every vertex satisfies pred(co); returns how many""" bm = bmesh.new() bm.from_mesh(me) kill = [f for f in bm.faces if all(pred(v.co) for v in f.verts)] bmesh.ops.delete(bm, geom=kill, context='FACES') loose = [v for v in bm.verts if not v.link_faces] bmesh.ops.delete(bm, geom=loose, context='VERTS') bm.to_mesh(me) bm.free() me.update() return len(kill) def _components(bm): seen = set() out = [] for v in bm.verts: if v.index in seen: continue stack, comp = [v], [] while stack: w = stack.pop() if w.index in seen: continue seen.add(w.index) comp.append(w) for e in w.link_edges: o = e.other_vert(w) if o.index not in seen: stack.append(o) out.append(comp) return out def _bisect_components(me, pick, cuts): """cut the connected components for which pick(min_co, max_co) holds, by each (plane_co, plane_no), keeping the side the normal points away from; returns how many components were cut""" bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() chosen = [] for comp in _components(bm): cs = [w.co for w in comp] lo = Vector((min(c.x for c in cs), min(c.y for c in cs), min(c.z for c in cs))) hi = Vector((max(c.x for c in cs), max(c.y for c in cs), max(c.z for c in cs))) if pick(lo, hi): chosen.append(comp) verts = {w for comp in chosen for w in comp} for co, no in cuts: verts = {v for v in verts if v.is_valid} faces = {f for v in verts for f in v.link_faces} edges = {e for v in verts for e in v.link_edges} geom = list(verts) + list(edges) + list(faces) r = bmesh.ops.bisect_plane(bm, geom=geom, dist=1e-5, plane_co=co, plane_no=no, clear_outer=True, clear_inner=False) verts = {g for g in r["geom"] if isinstance(g, bmesh.types.BMVert)} | {v for v in verts if v.is_valid} loose = [v for v in bm.verts if not v.link_faces] bmesh.ops.delete(bm, geom=loose, context='VERTS') bm.to_mesh(me) bm.free() me.update() return len(chosen) def _uvs(me, atlas_pt): """UVMap: every loop on one clean point of the door's mask atlas. UV_Tile: metres, box-projected (the tiling sets).""" for nm in ("UVMap", "UV_Tile"): if nm not in me.uv_layers: me.uv_layers.new(name=nm) uv0, uv1 = me.uv_layers["UVMap"], me.uv_layers["UV_Tile"] for p in me.polygons: n = p.normal ax = max(range(3), key=lambda i: abs(n[i])) for li in p.loop_indices: co = me.vertices[me.loops[li].vertex_index].co uv0.data[li].uv = atlas_pt uv1.data[li].uv = (co.y, co.z) if ax == 0 else ((co.x, co.z) if ax == 1 else (co.x, co.y)) me.uv_layers.active = uv0 def _atlas_point(ob, pick, masks): """a point inside a flat, front-facing panel of the door (pick(centre) says which) in its mask atlas, where the bake says the panel is open (occlusion near 1): the largest such face's centre""" me = ob.data uv = me.uv_layers["UVMap"] cands = [] for p in me.polygons: c = p.center if p.normal.y < -0.99 and pick(c): us = [uv.data[li].uv for li in p.loop_indices] cands.append((p.area, (sum(u.x for u in us) / len(us), sum(u.y for u in us) / len(us)))) cands.sort(key=lambda t: -t[0]) for area, pt in cands[:400]: rgb = _mask_at(masks, pt) if rgb is None or (rgb[0] > 0.85 and rgb[1] < 0.1): return pt return cands[0][1] if cands else (0.5, 0.5) def _mask_at(name, pt): img = bpy.data.images.get(name) if img is None or not img.size[0]: return None w, h = img.size x, y = min(w - 1, int(pt[0] * w)), min(h - 1, int(pt[1] * h)) i = (y * w + x) * 4 return [round(v, 3) for v in img.pixels[i:i + 3]] # ---- the parts drawn here ------------------------------------------------------------------------------------------- def _band(bm, F, ra, rb, mat, outward, mats_by_seg=None): n = len(ra) for k in range(n): k2 = (k + 1) % n am = TAU * (k + 0.5) / n rad = Vector((math.cos(am), 0.0, math.sin(am))) m = mats_by_seg[k] if mats_by_seg is not None and mats_by_seg[k] is not None else mat F(bm, [ra[k], ra[k2], rb[k2], rb[k]], m, out=outward(k, rad)) def _rivet_row(g, bm, a, b, kind, covered, step=0.075): a, b = Vector(a), Vector(b) cnt = int((b - a).length / step) for i in range(cnt + 1): p = a + (b - a) * (i / max(cnt, 1)) qs = [p] if kind == 'v' else [p + Vector((0, s * 0.035)) for s in (-1, 1)] for q in qs: if not covered(q.x, q.y): g["rivet"](bm, Matrix.Translation((q.x, 0.0, q.y))) def _build_plate_round(g, mats, coll): """the wall plate in the gate opening for the hinged door: its own plate code, 3.30 wide, to the ceiling""" bm = bmesh.new() F, PAINT, V = g["face"], g["PAINT"], g["V"] H, T = PLATE_HALF, PLATE_TOP fr = (V(1, 0, 0), V(0, 0, 1), V(0, -1, 0)) g["hole_panel"](bm, -H, H, 0.0, SEAM_Z, 0.0, 1.0) g["slab"](bm, [Vector((-H, SEAM_Z)), Vector((H, SEAM_Z)), Vector((H, T)), Vector((-H, T))], 0.006, 0.006, 2, origin=(0, 0.006, 0), basis=fr, mat=PAINT) g["hole_panel"](bm, -H, H, 0.0, T, PLATE_T, -1.0) F(bm, [bm.verts.new(p) for p in ((-H, 0.006, 0), (-H, PLATE_T - 0.006, 0), (-H, PLATE_T - 0.006, T - 0.006), (-H, 0.006, T - 0.006))], PAINT, out=V(-1, 0, 0)) F(bm, [bm.verts.new(p) for p in ((H, 0.006, 0), (H, PLATE_T - 0.006, 0), (H, PLATE_T - 0.006, T - 0.006), (H, 0.006, T - 0.006))], PAINT, out=V(1, 0, 0)) F(bm, [bm.verts.new(p) for p in ((-H + 0.006, 0.006, T), (H - 0.006, 0.006, T), (H - 0.006, PLATE_T - 0.006, T), (-H + 0.006, PLATE_T - 0.006, T))], PAINT, out=V(0, 0, 1)) def covered(x, z): # as the door's own plate: no rivets where something stands on the wall if math.hypot(x, z - g["ZC"]) < 1.62: return True if 1.50 < x < 2.78 and z < 2.34: return True return False for (a, b, kind) in [((-H + 0.034, 0.04), (-H + 0.034, T - 0.04), 'v'), ((H - 0.034, 0.04), (H - 0.034, T - 0.04), 'v'), ((-H + 0.07, SEAM_Z), (H - 0.07, SEAM_Z), 'h')]: _rivet_row(g, bm, a, b, kind, covered) g["drop_floor_faces"](bm) ob = g["finish"](bm, PRE + "Gate_Plate", coll, mats) ob.data["note"] = "the wall plate in the gate opening, with the round hole under the door's collar and flange" return ob def _build_plate_rect(g, mats, coll, x_in, z_in, header_z0): """the wall plate round a rectangular door plate: two side strips and a header, 6 mm rounded edges, rivet rows""" bm = bmesh.new() PAINT, V = g["PAINT"], g["V"] H, T = PLATE_HALF, PLATE_TOP pieces = [((H - x_in), PLATE_T, z_in, ((x_in + H) / 2, PLATE_T / 2, z_in / 2)), ((H - x_in), PLATE_T, z_in, (-(x_in + H) / 2, PLATE_T / 2, z_in / 2)), (2 * H, PLATE_T, T - header_z0, (0.0, PLATE_T / 2, (header_z0 + T) / 2))] for sx, sy, sz, c in pieces: g["rbox"](bm, (sx, sy, sz), 0.006, 2, M=Matrix.Translation(c), mat=PAINT) def covered(x, z): # no rivets where there is no plate (the door's recess between the strips' top and the header) return (z_in < z < header_z0) or (abs(x) < x_in and z < z_in) # or where the door's own plate stands for (a, b, kind) in [((-H + 0.034, 0.04), (-H + 0.034, T - 0.04), 'v'), ((H - 0.034, 0.04), (H - 0.034, T - 0.04), 'v'), ((-H + 0.10, header_z0 + 0.034), (H - 0.10, header_z0 + 0.034), 'v')]: _rivet_row(g, bm, a, b, kind, covered) g["drop_floor_faces"](bm) ob = g["finish"](bm, PRE + "Gate_Plate", coll, mats) ob.data["note"] = "the wall plate round the door plate: two side strips and a header" return ob def _build_base(g, mats, coll, base_x): """an 11.5 mm base plate on the floor in front of the door's parts, flush with the floor strip along the wall""" bm = bmesh.new() x0, x1 = base_x y0, y1, t = BASE_Y V = g["V"] g["slab"](bm, [Vector((x0, y0)), Vector((x1, y0)), Vector((x1, y1)), Vector((x0, y1))], t, 0.006, 2, origin=(0, 0, -t), basis=(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), mat=g["PAINT"], back=True) ob = g["finish"](bm, PRE + "Gate_Base", coll, mats) ob.data["note"] = "base plate under the door's front parts: the kit's floor strip along the wall is 12 mm proud" return ob def _build_sleeve(g, mats, coll, sk, D, tread_y0): """the door's opening carried on from its back to the hull's skin (+ PROUD); for the hinged door its tread inside""" bm = bmesh.new() F, PAINT, DARK = g["face"], g["PAINT"], g["DARK"] op, wall, y0 = D["opening"], D["sleeve_wall"], D["sleeve_y0"] inner, outer = _outline(op, 0.0), _outline(op, wall) def yend(x, z): return sk(x, z) + PROUD y1 = min(sk(x, z) for x, z in outer) - 0.30 rings = {} for key, pts, yf in (("i0", inner, lambda x, z: y0), ("i1", inner, lambda x, z: y1), ("i2", inner, yend), ("o0", outer, lambda x, z: y0), ("o1", outer, lambda x, z: y1), ("o2", outer, yend)): rings[key] = [bm.verts.new((x, yf(x, z), z)) for x, z in pts] # the flat bottom of a rounded-rectangle opening is the tread: dark, like the doors' own treads seg_mat = None if op["kind"] == "rrect": zb = op["zc"] - op["h"] / 2 + 0.002 seg_mat = [DARK if (inner[k][1] < zb and inner[(k + 1) % N][1] < zb) else None for k in range(N)] def inward(k, rad): ax, az = inner[k] bx, bz = inner[(k + 1) % N] return Vector((-(bz - az), 0.0, bx - ax)) # the left-hand normal of a counter-clockwise edge: inward... see below # (for a counter-clockwise outline seen from the front (+X right, +Z up), the edge direction turned +90 degrees points # inward; face() only needs the sign) _band(bm, F, rings["i0"], rings["i1"], PAINT, lambda k, r: inward(k, r), seg_mat) _band(bm, F, rings["i1"], rings["i2"], PAINT, lambda k, r: inward(k, r), seg_mat) _band(bm, F, rings["o0"], rings["o1"], PAINT, lambda k, r: -inward(k, r)) _band(bm, F, rings["o1"], rings["o2"], PAINT, lambda k, r: -inward(k, r)) _band(bm, F, rings["i0"], rings["o0"], PAINT, lambda k, r: Vector((0, -1, 0))) _band(bm, F, rings["i2"], rings["o2"], PAINT, lambda k, r: Vector((0, 1, 0))) if D["tread_z"] is not None: # the hinged door's tread: flat at tread_z across the bore's bottom, from the frame's tread to the mouth R, ZC, TZ = op["r"], op["zc"], D["tread_z"] hw = math.sqrt(R ** 2 - (ZC - TZ) ** 2) angs = [TAU * k / N for k in range(N)] a_l = math.atan2(TZ - ZC, -hw) % TAU a_r = math.atan2(TZ - ZC, hw) % TAU ks = [k for k in range(N) if a_l < angs[k] < a_r] cols = [-hw] + [R * math.cos(angs[k]) for k in ks] + [hw] T0 = [bm.verts.new((x, tread_y0, TZ)) for x in cols] T2 = [bm.verts.new((x, yend(x, TZ), TZ)) for x in cols] for j in range(len(cols) - 1): F(bm, [T0[j], T0[j + 1], T2[j + 1], T2[j]], DARK, out=Vector((0, 0, 1))) A0 = [bm.verts.new((R * math.cos(angs[k]), tread_y0, ZC + R * math.sin(angs[k]))) for k in ks] F(bm, [T0[0]] + A0 + [T0[-1]], PAINT, out=Vector((0, -1, 0))) F(bm, [T2[0]] + [rings["i2"][k] for k in ks] + [T2[-1]], PAINT, out=Vector((0, 1, 0))) ob = g["finish"](bm, PRE + "Gate_Sleeve", coll, mats) ob.data["note"] = "the door's opening carried on through the back wall and the lining void to the hull's skin" return ob def _build_collar(g, mats, coll, sk, D): """the ring round the sleeve's mouth on the hull's outside, following the skin, with 16 bolts""" bm = bmesh.new() F, PAINT, STEEL = g["face"], g["PAINT"], g["STEEL"] op, wall = D["opening"], D["sleeve_wall"] P_in, P_ch, P_out = _outline(op, wall), _outline(op, wall + R_RING - CHAMFER), _outline(op, wall + R_RING) def ring(pts, dy): return [bm.verts.new((x, sk(x, z) + dy, z)) for x, z in pts] Bi, Bo = ring(P_in, RING_OFF), ring(P_out, RING_OFF) Fi, Fc, Oc = ring(P_in, PROUD), ring(P_ch, PROUD), ring(P_out, PROUD - CHAMFER) def outward(pts): def f(k, rad): ax, az = pts[k] bx, bz = pts[(k + 1) % N] return Vector(((bz - az), 0.0, -(bx - ax))) return f _band(bm, F, Fi, Fc, PAINT, lambda k, r: Vector((0, 1, 0))) _band(bm, F, Fc, Oc, PAINT, lambda k, r: (outward(P_out)(k, r).normalized() + Vector((0, 1, 0))).normalized()) _band(bm, F, Oc, Bo, PAINT, outward(P_out)) _band(bm, F, Bo, Bi, PAINT, lambda k, r: Vector((0, -1, 0))) bolts = _outline(op, wall + R_RING / 2, 16) for x, z in bolts: g["bolt_head"](bm, Matrix.Translation((x, sk(x, z) + PROUD, z)) @ Matrix.Rotation(math.pi, 4, 'Z'), r=0.022, mat=STEEL, n=12) ob = g["finish"](bm, PRE + "Gate_Collar", coll, mats) ob.data["note"] = "the collar round the sleeve's mouth on the hull's outside, following the skin" return ob # ---- the record ---------------------------------------------------------------------------------------------------- def exists(): return RECORD in bpy.data.texts def record(): return json.loads(bpy.data.texts[RECORD].as_string()) if exists() else None def forget(): """Drop the record without touching any object (for a rebuild, which throws the whole layout away first).""" if not exists(): return False bpy.data.texts.remove(bpy.data.texts[RECORD]) return True def _take(o, rec): rec["removed"].append(rr._snap(o)) if o.data is not None: o.data.use_fake_user = True bpy.data.objects.remove(o, do_unlink=True) def _tag(o, rec): o["made_by"] = TAG o["zone"] = ZONE if o.name not in rec["added"]: rec["added"].append(o.name) return o def _pieces(take_node): """the kit gate's objects, by what they are and where they stand (names differ between the file and a rebuild)""" scene = bpy.data.scenes.get("M-Ship v2 | REFERENCE ASSET") or bpy.context.scene out = [] for o in scene.objects: if o.type == "MESH" and o.data.name in GATE_MESHES and _in_box(o, NICHE): out.append(o) elif o.type == "LIGHT" and _in_box(o, NICHE): out.append(o) elif take_node and o.type == "MESH" and o.data.name == NODE_MESH: m = rr._mid(o) if NODE_AT[0] < m.x < NODE_AT[1] and NODE_AT[2] < m.y < NODE_AT[3]: out.append(o) return out def _wall_c(): scene = bpy.data.scenes.get("M-Ship v2 | REFERENCE ASSET") or bpy.context.scene for o in scene.objects: if o.type == "MESH" and o.data.name in (WALL_C_MESH, PRE + "WVD_Wall_C_hole"): lo, hi = _bb(o) if lo.x < AXIS_X < hi.x and WALL_Y + 0.4 < lo.y < WALL_Y + 0.8: return o return None def _layer_collection(name, lc=None): lc = lc or bpy.context.view_layer.layer_collection if lc.name == name: return lc for ch in lc.children: r = _layer_collection(name, ch) if r is not None: return r return None # ---- the doors' own edits ------------------------------------------------------------------------------------------- def _edits_hinged(rec): """the frame's back step and the housing's control panel on the wall's back: there is no room behind the wall""" frame = bpy.data.objects["SM_PressureDoor_Frame"] rec["edits"]["frame_back_step_faces"] = _drop_faces(frame.data, lambda c: c.y > 0.419 and c.z < 0.135 and abs(c.x) < 0.51) rec["edits"]["housing_back_panel_faces"] = _drop_faces(bpy.data.objects["SM_PressureDoor_Housing"].data, lambda c: c.y > 0.3005) tread_y0 = max([v.co.y for v in frame.data.vertices if 0.19 < v.co.z < 0.26 and abs(v.co.x) < 0.33 and v.co.y > 0.0] or [0.42]) rec["edits"]["frame_tread_end_y"] = round(tread_y0, 4) return tread_y0 def _edits_sliding(rec): """the columns and conduits beside the door plate are cut at the wall face (the kit's wall pieces are solid behind it); the cable trough on top and the cables along it are cut to the gate opening's width (they run into the wall); the frame's 2 cm under the floor is cut off""" wall = bpy.data.objects["SM_PDSliding_Wall"].data rec["edits"]["wall_cut_at_the_wall_face"] = _bisect_components(wall, lambda lo, hi: lo.z < 2.9, [((0.0, 0.0, 0.0), (0.0, 1.0, 0.0))]) rec["edits"]["wall_top_cut_to_the_opening"] = _bisect_components(wall, lambda lo, hi: lo.z >= 2.9, [((PLATE_HALF, 0.0, 0.0), (1.0, 0.0, 0.0)), ((-PLATE_HALF, 0.0, 0.0), (-1.0, 0.0, 0.0))]) frame = bpy.data.objects["SM_PDSliding_Frame"].data rec["edits"]["frame_cut_at_the_floor"] = _bisect_components(frame, lambda lo, hi: lo.z < -0.001, [((0.0, 0.0, 0.0), (0.0, 0.0, -1.0))]) # the left leaf's keypad (its dark bezel, the screen and the lit strip beside it) is taken off: the user, 2026-10-16, # with a red cross over it on a picture: "delete that screen" rec["edits"]["leaf_keypad_removed"] = _drop_keypad(bpy.data.objects["SM_PDSliding_LeafL"].data) rec["edits"]["leaf_keypad_shadow_cleared"] = patch_keypad_masks() rec["edits"]["handle_bar_levelled_deg"] = level_handle_bar() return None def level_handle_bar(): """The bar across the handle disc lies horizontal when the door is shut and stands vertical once the handle has made its quarter turn (unlocked), before the leaves slide: the user, 2026-10-16, on two pictures marked CLOSED (a horizontal stroke over the bar) and OPEN (a vertical one). The door's file has the bar at 45 degrees; its mesh is turned about the disc's axis so the bar is level at rest (the rig's quarter turn is unchanged). Measured, not assumed: a bar already level is left alone. Returns the turn applied, in degrees.""" import numpy as np h = bpy.data.objects.get("SM_PDSliding_Handle") if h is None: return None me = h.data y0 = min(v.co.y for v in me.vertices) P = np.array([[v.co.x, v.co.z] for v in me.vertices if v.co.y < y0 + 0.004]) # the bar: the proudest part if len(P) < 4: return None Q = P - P.mean(0) w_, vec = np.linalg.eigh(Q.T @ Q) ang = math.degrees(math.atan2(vec[1, -1], vec[0, -1])) ang = (ang + 90.0) % 180.0 - 90.0 # the bar's angle from horizontal, -90..90 (seen from the front) if abs(ang) < 0.5: return 0.0 # a turn of +a about +Y takes a direction at angle phi (atan2(z, x)) to phi - a me.transform(Matrix.Rotation(math.radians(ang), 4, 'Y')) me.update() return round(ang, 2) MASKS_SHIP = os.path.join(HERE, "..", "textures", "T_PDS_Masks_ship.png") def patch_keypad_masks(): """The door's baked masks saw the band under the keypad as covered (occlusion 0, a crevice): with the keypad gone that spot renders as a dark matte square. A copy of the atlas for the ship (vessel-interior/textures/ T_PDS_Masks_ship.png; the door's own file is not touched) gets those pixels filled with the face's own clean values round them, and the ship's image T_PDS_Masks points to it.""" import numpy as np img = bpy.data.images.get("T_PDS_Masks") leaf = bpy.data.objects.get("SM_PDSliding_LeafL") if img is None or leaf is None: return "no atlas or no left leaf" src = os.path.join(MODELS, "pressure-door-sliding", "textures", "T_PDS_Masks.png") if os.path.normcase(os.path.normpath(bpy.path.abspath(img.filepath))) != os.path.normcase(os.path.normpath(src)): img.filepath = bpy.path.relpath(src) if bpy.data.filepath else src img.reload() w, h = img.size ch = img.channels px = np.empty(w * h * ch, np.float32) img.pixels.foreach_get(px) px = px.reshape(h, w, ch) me = leaf.data me.calc_loop_triangles() uv = me.uv_layers["UVMap"].data x0, x1, _, _, z0, z1 = KEYPAD_BOX M = 0.015 # the shadow ring round the keypad patched = 0 for p in me.polygons: if p.normal.y > -0.9: continue cs = [me.vertices[i].co for i in p.vertices] if max(c.x for c in cs) < x0 - M or min(c.x for c in cs) > x1 + M or max(c.z for c in cs) < z0 - M or min(c.z for c in cs) > z1 + M: continue tris = [t for t in me.loop_triangles if t.polygon_index == p.index] dirty, clean = [], [] for t in tris: U = np.array([[uv[li].uv.x * w, uv[li].uv.y * h] for li in t.loops], np.float64) P = np.array([[me.vertices[vi].co.x, me.vertices[vi].co.z] for vi in t.vertices], np.float64) xa, xb = int(max(0, np.floor(U[:, 0].min()) - 2)), int(min(w - 1, np.ceil(U[:, 0].max()) + 2)) ya, yb = int(max(0, np.floor(U[:, 1].min()) - 2)), int(min(h - 1, np.ceil(U[:, 1].max()) + 2)) if xb < xa or yb < ya: continue gx, gy = np.meshgrid(np.arange(xa, xb + 1) + 0.5, np.arange(ya, yb + 1) + 0.5) T = np.array([[U[1, 0] - U[0, 0], U[2, 0] - U[0, 0]], [U[1, 1] - U[0, 1], U[2, 1] - U[0, 1]]]) if abs(np.linalg.det(T)) < 1e-9: continue Ti = np.linalg.inv(T) dx, dy = gx - U[0, 0], gy - U[0, 1] b1 = Ti[0, 0] * dx + Ti[0, 1] * dy b2 = Ti[1, 0] * dx + Ti[1, 1] * dy b0 = 1.0 - b1 - b2 pad = 2.0 / max(1.0, np.sqrt(abs(np.linalg.det(T)))) ins = (b0 > -pad) & (b1 > -pad) & (b2 > -pad) X = b0 * P[0, 0] + b1 * P[1, 0] + b2 * P[2, 0] Z = b0 * P[0, 1] + b1 * P[1, 1] + b2 * P[2, 1] foot = ins & (X > x0 - M) & (X < x1 + M) & (Z > z0 - M) & (Z < z1 + M) ring = ins & ~foot & (X > x0 - 3 * M) & (X < x1 + 3 * M) & (Z > z0 - 3 * M) & (Z < z1 + 3 * M) & (b0 > 0.02) & (b1 > 0.02) & (b2 > 0.02) ys, xs = np.nonzero(foot) dirty += list(zip(ys + ya, xs + xa)) ys, xs = np.nonzero(ring) clean += list(zip(ys + ya, xs + xa)) if dirty and clean: cy, cx = np.array(clean).T fill = np.median(px[cy, cx, :], axis=0) dy_, dx_ = np.array(dirty).T px[dy_, dx_, :] = fill patched += len(dirty) if not patched: return "nothing to patch" out = os.path.normpath(MASKS_SHIP) os.makedirs(os.path.dirname(out), exist_ok=True) new = bpy.data.images.new("BG tmp masks", w, h, alpha=(ch == 4)) new.colorspace_settings.name = "Non-Color" if ch == 4: new.pixels.foreach_set(px.ravel()) else: full = np.ones((h, w, 4), np.float32) full[:, :, :ch] = px new.pixels.foreach_set(full.ravel()) new.filepath_raw = out new.file_format = 'PNG' new.save() bpy.data.images.remove(new) img.filepath = bpy.path.relpath(out) if bpy.data.filepath else out img.reload() return {"pixels": patched, "file": out} KEYPAD_BOX = (-0.27, -0.074, -0.056, -0.047, 0.27, 0.486) # the keypad on the left leaf, in the leaf's own frame def _drop_keypad(me): """delete the connected parts that lie wholly inside KEYPAD_BOX; returns how many""" x0, x1, y0, y1, z0, z1 = KEYPAD_BOX bm = bmesh.new() bm.from_mesh(me) kill = [comp for comp in _components(bm) if all(x0 <= w.co.x <= x1 and y0 <= w.co.y <= y1 and z0 <= w.co.z <= z1 for w in comp)] bmesh.ops.delete(bm, geom=[w for comp in kill for w in comp], context='VERTS') bm.to_mesh(me) bm.free() me.update() return len(kill) # ---- building ------------------------------------------------------------------------------------------------------ def apply(door=DEFAULT_DOOR): D = DOORS[door] if exists(): raise RuntimeError("a door is already there (text block %r); remove() puts the kit gate back" % RECORD) blend = os.path.join(MODELS, D["dir"], D["blend"]) if not os.path.exists(blend): raise RuntimeError("the door's file is not there: " + blend) if bpy.data.objects.get(D["root"]) is not None or bpy.data.collections.get(D["coll"]) is not None: raise RuntimeError("a %s is already in this file" % D["coll"]) pieces = _pieces(D["take_node"]) if len(pieces) != D["expected"]: raise RuntimeError("expected %d pieces of the kit gate, found %d: %s" % (D["expected"], len(pieces), [o.name for o in pieces])) wall_c = _wall_c() if wall_c is None: raise RuntimeError("the niche's back wall (%s) is not where it should be" % WALL_C_MESH) hull = bpy.data.objects.get(HULL) if hull is None: raise RuntimeError("no hull object " + HULL) rec = {"door": door, "removed": [], "moved": [], "added": [], "meshes": [], "hull": None, "edits": {}} try: return _apply(rec, D, blend, pieces, wall_c, hull) except Exception: _undo(rec) raise def _apply(rec, D, blend, pieces, wall_c, hull): sk = _skin() # the skin as it is before the hole sk_axis = sk(0.0, D["opening"]["zc"]) door_dir = os.path.dirname(blend) imgs_before = set(bpy.data.images) # ---- the door, appended and placed with bpy.data.libraries.load(blend, link=False) as (src, dst): if D["coll"] not in src.collections: raise RuntimeError("no collection %s in %s" % (D["coll"], blend)) dst.collections = [D["coll"]] top = dst.collections[0] top.name = COLL bpy.data.collections[ZONE].children.link(top) root = bpy.data.objects[D["root"]] inner = bpy.data.objects.get(INTERIOR_ROOT) if inner is not None: root.parent = inner root.matrix_parent_inverse = Matrix.Identity(4) root.location = (AXIS_X, WALL_Y, FLOOR_Z) for o in top.all_objects: _tag(o, rec) for o in top.all_objects: if o.type == "MESH" and o.name.startswith(D["prefix"]): base = o.name[:-3] if o.name[-3] == "_" and o.name[-2:].isdigit() else o.name o.data["ue_mesh"] = D["ue"] + base lc = _layer_collection(D["collision"]) if lc is not None: lc.exclude = True # the images: the pack's sets are in this file already; the door's own are found from its folder for im in [i for i in bpy.data.images if i not in imgs_before]: if im.filepath and not os.path.exists(bpy.path.abspath(im.filepath)): rel = im.filepath[2:] if im.filepath.startswith("//") else im.filepath cand = os.path.normpath(os.path.join(door_dir, rel)) if os.path.exists(cand): im.filepath = bpy.path.relpath(cand) for im in [i for i in bpy.data.images if i not in imgs_before]: if not im.filepath: continue p = os.path.normcase(os.path.normpath(bpy.path.abspath(im.filepath))) for other in imgs_before: if other.filepath and os.path.normcase(os.path.normpath(bpy.path.abspath(other.filepath))) == p: im.user_remap(other) bpy.data.images.remove(im) break missing = [i.name for i in bpy.data.images if i.name.startswith(D["masks"][:5]) and i.filepath and not os.path.exists(bpy.path.abspath(i.filepath))] if missing: raise RuntimeError("door textures not found: %s" % missing) if D["look"]: # the door's surfaces as the user wants them in the ship: the scanned painted steel, dark grey metallic # (pressure-door/build/pd_look.py; the door's own file still carries its first surfaces) pb = os.path.join(MODELS, "pressure-door", "build") if pb not in sys.path: sys.path.insert(0, pb) import pd_look # noqa: E402 rec["edits"]["look"] = pd_look.look(**D["look"]) # a clean point of the door's mask atlas for the parts drawn here, from a flat panel of the door atlas_src = bpy.data.objects[D["unused"][0]] if D["unused"] else bpy.data.objects[D["prefix"] + "Frame"] atlas_pt = _atlas_point(atlas_src, D["atlas"], D["masks"]) rec["edits"]["atlas_point"] = [round(v, 5) for v in atlas_pt] rec["edits"]["atlas_mask_rgb"] = _mask_at(D["masks"], atlas_pt) # what the door brings for a wall or a floor of its own is not used: the ship's wall and floor are there for nm in D["unused"]: o = bpy.data.objects[nm] for ch in list(o.children): d = ch.data bpy.data.objects.remove(ch, do_unlink=True) if d is not None and d.users == 0: bpy.data.meshes.remove(d) d = o.data bpy.data.objects.remove(o, do_unlink=True) if d.users == 0: bpy.data.meshes.remove(d) rec["added"] = [n for n in rec["added"] if n in bpy.data.objects] tread_y0 = (_edits_hinged if rec["door"] == "hinged" else _edits_sliding)(rec) # ---- the kit gate out for o in pieces: _take(o, rec) # ---- the back wall piece: the opening's shape through it (a cut copy, its own part now) M = Matrix.Translation((AXIS_X, WALL_Y, FLOOR_Z)) op, wall = D["opening"], D["sleeve_wall"] rec["moved"].append({"name": wall_c.name, "data": wall_c.data.name, "props": {k: rr._plain(wall_c[k]) for k in wall_c.keys()}}) me = _bool_cut(wall_c, PRE + "WVD_Wall_C_hole", _prism(_outline(op, wall + WALL_C_CLEAR), 0.40, 1.00), M) if "ue_mesh" in me: me["cut_from"] = me["ue_mesh"] del me["ue_mesh"] me["made_for"] = MADE_FOR me["note"] = "the niche's back wall with the hole for the sleeve" wall_c.data = me if "ue_mesh" in wall_c: wall_c["cut_from"] = wall_c["ue_mesh"] del wall_c["ue_mesh"] rec["meshes"].append(me.name) # ---- the hull: the hole, cut into the mesh; the mesh as it was is kept for remove() old = hull.data n_before = len(old.vertices) groups = {} # every vertex group's members, by position (put back after the cut) for vg in hull.vertex_groups: groups[vg.name] = [(v.co.copy(), g.weight) for v in old.vertices for g in v.groups if g.group == vg.index] grp_before = len(groups.get("LAYOUT_pod_opening", [])) cut_pts = _outline(op, wall + HOLE_CLEAR) new = _bool_cut(hull, old.name + " | boarding gate hole", _prism(cut_pts, 0.90, 3.50), M, skin=(cut_pts, 0.90, 3.50)) old.use_fake_user = True old_name = old.name old.name = old_name + " | before the boarding gate hole" new.name = old_name hull.data = new # the vertex groups again (the boolean drops the deform layer; the group names live with the mesh now) for g_ in list(hull.vertex_groups): if g_.name not in groups: hull.vertex_groups.remove(g_) from mathutils.kdtree import KDTree kd = KDTree(len(new.vertices)) for v in new.vertices: kd.insert(v.co, v.index) kd.balance() for name, members in groups.items(): g_ = hull.vertex_groups.get(name) or hull.vertex_groups.new(name=name) for co, w in members: loc, idx, d = kd.find(co) if idx is not None and d < 1e-5: g_.add([idx], w, 'REPLACE') vg = hull.vertex_groups.get("LAYOUT_pod_opening") grp_after = sum(1 for v in new.vertices if vg is not None and any(g.group == vg.index and g.weight > 0.5 for g in v.groups)) if vg else 0 rec["hull"] = {"object": HULL, "before": old.name, "after": new.name, "verts_before": n_before, "verts_after": len(new.vertices), "pod_group_before": grp_before, "pod_group_after": grp_after, "uv_layers": [u.name for u in new.uv_layers], "materials": [m.name for m in new.materials if m]} if vg is not None and grp_after != grp_before: raise RuntimeError("the hull's vertex group LAYOUT_pod_opening changed with the cut: %d -> %d" % (grp_before, grp_after)) # ---- the parts drawn here, in the door's own materials g = _pd() mats = [bpy.data.materials[n] for n in D["mats"]] # PAINT, STEEL, DARK as pd_build numbers them mats = mats + [mats[0]] * (len(g["MAT_NAMES"]) - len(mats)) static = bpy.data.collections[D["static"]] plate = _build_plate_round(g, mats, static) if D["plate"] == "round" else \ _build_plate_rect(g, mats, static, D["plate_hole"][0], D["plate_hole"][1], D["header_z0"]) mine = [plate, _build_base(g, mats, static, D["base_x"]), _build_sleeve(g, mats, static, sk, D, tread_y0), _build_collar(g, mats, static, sk, D)] for o in mine: o.parent = root o.matrix_parent_inverse = Matrix.Identity(4) o.data["made_for"] = MADE_FOR _uvs(o.data, atlas_pt) _tag(o, rec) rec["meshes"].append(o.data.name) txt = bpy.data.texts.new(RECORD) txt.write(json.dumps(rec, indent=1)) bpy.context.view_layer.update() return {"door": rec["door"], "removed": len(rec["removed"]), "added": len(rec["added"]), "meshes": rec["meshes"], "edits": rec["edits"], "hull": rec["hull"], "skin_y_on_the_axis": round(sk_axis + WALL_Y, 3), "door_at": [AXIS_X, WALL_Y, FLOOR_Z]} def _undo(rec): """back out of a half-done apply()""" try: _remove(rec) except Exception as e: # pragma: no cover print("[boarding_gate] undo failed:", e) def remove(): """The kit gate again, from the record.""" rec = record() if rec is None: return {"present": False} out = _remove(rec) bpy.data.texts.remove(bpy.data.texts[RECORD]) bpy.context.view_layer.update() return out def _remove(rec): for n in rec["added"]: o = bpy.data.objects.get(n) if o is not None: d = o.data bpy.data.objects.remove(o, do_unlink=True) if d is not None and d.users == 0: if isinstance(d, bpy.types.Mesh): bpy.data.meshes.remove(d) elif isinstance(d, bpy.types.Light): bpy.data.lights.remove(d) names = [COLL] for D in DOORS.values(): names += [D["collision"], D["static"], D["static"].split(" | ")[0] + " | moving", D["coll"]] for nm in names: c = bpy.data.collections.get(nm) if c is not None and not c.all_objects: bpy.data.collections.remove(c) for d in rec["moved"]: o = bpy.data.objects.get(d["name"]) if o is None: continue me = bpy.data.meshes.get(d["data"]) if me is not None: o.data = me if "cut_from" in o: del o["cut_from"] for k, v in d["props"].items(): o[k] = v h = rec.get("hull") if h: hull = bpy.data.objects.get(h["object"]) old, new = bpy.data.meshes.get(h["before"]), bpy.data.meshes.get(h["after"]) if hull is not None and old is not None: hull.data = old old.use_fake_user = False if new is not None and new.users == 0: bpy.data.meshes.remove(new) old.name = h["after"] back = 0 for d in rec["removed"]: if d["name"] in bpy.data.objects: continue data = bpy.data.meshes.get(d["data"]) if d["type"] == "MESH" else (bpy.data.lights.get(d["data"]) if d["type"] == "LIGHT" else None) if data is None and d["data"] is not None: continue o = bpy.data.objects.new(d["name"], data) for c in d["collections"]: if c in bpy.data.collections: bpy.data.collections[c].objects.link(o) if d["parent"] and d["parent"] in bpy.data.objects: o.parent = bpy.data.objects[d["parent"]] o.matrix_world = Matrix(d["matrix"]) for k, v in d["props"].items(): o[k] = v if data is not None: data.use_fake_user = False for slot, (link, mat) in zip(o.material_slots, d["slots"]): if link == "OBJECT": slot.link = "OBJECT" slot.material = bpy.data.materials.get(mat) if mat else None back += 1 for n in rec["meshes"]: me = bpy.data.meshes.get(n) if me is not None and me.users == 0: bpy.data.meshes.remove(me) for m in [m for m in bpy.data.materials if m.name.startswith(("M_PD_", "M_PDS_")) and m.users == 0]: bpy.data.materials.remove(m) for a in [a for a in bpy.data.actions if a.name.startswith("PressureDoor") and a.users == 0]: bpy.data.actions.remove(a) for i in [i for i in bpy.data.images if i.name.startswith(("T_PD_", "T_PDS_")) and i.users == 0]: bpy.data.images.remove(i) return {"present": True, "door": rec.get("door"), "taken_out": len(rec["added"]), "put_back": back} # ---- checking ------------------------------------------------------------------------------------------------------ def _tri_cross(p, q): """Do two triangles really cross (not only straddle each other's plane)? The line where the two planes meet is clipped to each triangle; the two spans must overlap (Moller's test).""" n1 = (p[1] - p[0]).cross(p[2] - p[0]) n2 = (q[1] - q[0]).cross(q[2] - q[0]) d = n1.cross(n2) if d.length < 1e-12: return False d.normalize() def span(tri, n_other, o_other): ds = [(v - o_other).dot(n_other) for v in tri] ts = [v.dot(d) for v in tri] pts = [] for i in range(3): j = (i + 1) % 3 if (ds[i] <= 0.0) != (ds[j] <= 0.0) or ds[i] == 0.0: f = ds[i] / (ds[i] - ds[j]) if ds[i] != ds[j] else 0.0 pts.append(ts[i] + (ts[j] - ts[i]) * f) return (min(pts), max(pts)) if pts else None a, b = span(p, n2, q[0]), span(q, n1, p[0]) if a is None or b is None: return False return min(a[1], b[1]) - max(a[0], b[0]) > rr.EPS def _crossing(a, b): """face pairs of two meshes that cross: each reaches more than EPS through the other's plane, and the two really meet (the plane test alone counts a chamfer at a wall piece's edge against a face that stands clear of it)""" (va, ta, tra, loa, hia), (vb, tb, trb, lob, hib) = a, b if tra is None or trb is None or any(loa[i] > hib[i] or lob[i] > hia[i] for i in range(3)): return 0 def straddles(p, q): n = (q[1] - q[0]).cross(q[2] - q[0]) if n.length < 1e-12: return False n.normalize() d = [(x - q[0]).dot(n) for x in p] return max(d) > rr.EPS and min(d) < -rr.EPS k = 0 for i, j in tra.overlap(trb): p = [va[v] for v in ta[i]] q = [vb[v] for v in tb[j]] if straddles(p, q) and straddles(q, p) and _tri_cross(p, q): k += 1 return k def _crossings(mine, others, cache): out = {} ms = [o for o in mine if o.type == "MESH"] os_ = [o for o in others if o.type == "MESH" and not rr._kind(o).startswith("DECAL_")] for i, a in enumerate(ms): for b in ms[i + 1:] + os_: if a is b: continue k = _crossing(rr._geo(a, cache), rr._geo(b, cache)) if k: key = " x ".join(sorted((rr._label(a), rr._label(b)))) out[key] = out.get(key, 0) + k return out def _mine(): return [o for o in bpy.data.objects if o.get("made_by") == TAG and o.type == "MESH" and not o.name.startswith("UCX_")] # the collar is let into the hull's skin by RING_OFF: that pair crosses by design EXPECTED_CROSSINGS = {"[ship] M_SHIP_Hull x [ship] SM_BG_Gate_Collar"} OUTSIDE_BY_DESIGN = (PRE + "Gate_Collar", PRE + "Gate_Sleeve") def _others(): scene = bpy.data.scenes.get("M-Ship v2 | REFERENCE ASSET") or bpy.context.scene out = [] for o in scene.objects: if o.type != "MESH" or o.get("made_by") == TAG or o.name.startswith("KIT | ") or o.name.startswith("UCX_"): continue if any(c.name.startswith("11 KIT") for c in o.users_collection): continue if _in_box(o, REGION): out.append(o) return out def _geo_live(m, dg): ev = m.evaluated_get(dg) mw = ev.matrix_world me = m.data me.calc_loop_triangles() vs = [mw @ v.co for v in me.vertices] ts = [tuple(t.vertices) for t in me.loop_triangles] tree = BVHTree.FromPolygons([tuple(v) for v in vs], ts, epsilon=0.0) lo = Vector((min(v.x for v in vs), min(v.y for v in vs), min(v.z for v in vs))) hi = Vector((max(v.x for v in vs), max(v.y for v in vs), max(v.z for v in vs))) return (vs, ts, tree, lo, hi) def _travel(root, movers, statics, sliders, steps=40): """the moving parts through each slider's travel (the others at rest) against everything near: crossings, nearest""" cache = {} sg = [(o, rr._geo(o, cache)) for o in statics] clashes, near, low = {}, {}, 9.0 was = {s: float(root[s]) for s, _, _ in sliders} for s, a, b in sliders: for s2, _, _ in sliders: root[s2] = 0.0 for i in range(steps + 1): v = a + (b - a) * i / steps root[s] = v root.update_tag() bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() for m in movers: geo = _geo_live(m, dg) if "Leaf" in m.name: low = min(low, geo[3].z) for o, og in sg: k = _crossing(geo, og) if k: key = "%s x %s (%s)" % (m.name, o.name, s) c = clashes.setdefault(key, {"first": round(v, 3), "last": round(v, 3), "pairs": 0}) c["last"] = round(v, 3) c["pairs"] = max(c["pairs"], k) if i % 5 == 0 and og[2] is not None and not any(geo[3][j] > og[4][j] + 0.3 or geo[4][j] < og[3][j] - 0.3 for j in range(3)): best = 9.0 for p in geo[0][::3]: r = og[2].find_nearest(p, 0.3) if r[0] is not None and r[3] < best: best = r[3] key = "%s x %s" % (m.name, o.name) if best < 0.3 and (key not in near or best < near[key][0]): near[key] = (round(best, 4), s, round(v, 3)) for s, v in was.items(): root[s] = v root.update_tag() bpy.context.view_layer.update() return {"clashes": clashes, "nearest_m": near, "leaf_lowest_z": round(low, 4)} def check(): if not exists(): return {"present": False} rec = record() D = DOORS[rec["door"]] mine = _mine() others = _others() top = bpy.data.collections.get(COLL) allobs = list(top.all_objects) if top else [] movers = [o for o in allobs if o.type == "MESH" and any(o.name == m or (m.endswith("_") and o.name.startswith(m)) for m in D["movers"])] door_static = [o for o in allobs if o.type == "MESH" and o.name.startswith(D["prefix"]) and o not in movers and not o.name.startswith("UCX_")] statics_mine = [o for o in mine if o not in movers] cache = {} cross = {k: v for k, v in _crossings(statics_mine, others, cache).items() if k not in EXPECTED_CROSSINGS} # the hole, along the door's axis and round it op, wall = D["opening"], D["sleeve_wall"] hull = bpy.data.objects[HULL] tree = shipfit.drawn_tree(hull) o = Vector((AXIS_X, WALL_Y + 0.5, FLOOR_Z + op["zc"])) through = tree.ray_cast(o, Vector((0, 1, 0)), 20.0)[0] is None ring_hits = 0 for x, z in _outline(op, wall + R_RING + 0.05, 12): if tree.ray_cast(Vector((AXIS_X + x, WALL_Y + 0.5, FLOOR_Z + z)), Vector((0, 1, 0)), 20.0)[0] is not None: ring_hits += 1 wc = _wall_c() wc_tree = shipfit.drawn_tree(wc) if wc is not None else None wall_open = wc_tree is not None and wc_tree.ray_cast(o, Vector((0, 1, 0)), 20.0)[0] is None env = shipfit.Envelope() inside = {} for o_ in statics_mine: if o_.name.endswith(OUTSIDE_BY_DESIGN): continue lo, hi = _bb(o_) inside[o_.name] = all(env.inside(p) for p in (lo, hi, (lo + hi) * 0.5)) root = bpy.data.objects.get(D["root"]) travel = _travel(root, movers, others + statics_mine + door_static, D["sliders"]) if movers and root else {} ok = (not cross and through and ring_hits == 12 and wall_open and all(inside.values()) and not travel.get("clashes")) return {"present": True, "door": rec["door"], "ok": ok, "crossings": cross, "hole_through_the_hull": through, "skin_round_the_hole": "%d/12" % ring_hits, "back_wall_open": wall_open, "inside_the_hull": inside, "travel": travel, "removed": len(rec["removed"]), "added": len(rec["added"]), "hull": rec.get("hull")}