""" The station's lift doors, rigged so they open and close (the user, 2026-10-14: "rig the doors so they open and close"). rig() adds the rig to the open file, or renews it, in place sweep() lifts a shut leaf through its travel and says what it meets on the way (also build/check_doors.py) at_rest() stands every leaf where it is shut, whatever the sliders say (for the checks; not saved) The kit's big leaf is one piece; it lifts 4.10 m into the housing over its doorway (exterior.DOOR_TRAVEL, outpost_more.door_housing). The rig is in the collection "11 DOORS | rig (open and close)": "DOOR | ()" an arrow over each doorway with the slider "open": 0 shut, 1 open "DOORS | all" one more, with "open_all", which the timeline drives (the action "DOORS | all | OPEN_CLOSE 2s"): shut at frame 1, open at 61 (2 s at 30 fps), open until 121, shut again at 181. Press play and every door opens and closes once. Each leaf's height has a driver: its shut height + travel x the larger of its own slider and the master's. It is a simple expression, which Blender works out without Python, so it runs in any Blender that opens the file (this one does not run scripts in files by itself). Like the vessel's moon pool doors, every leaf keeps "closed_location"; build/exterior.py (slots, housings) and build/export_layout.py (Unreal) go by it, so a door left open in the file moves neither its housing nor the door in Unreal. The kit seats the shut leaf in its doorway frame: the leaf's edges and top stand in the frame's jambs and head (and the HW doorways' floor plate). Lifting, it rides in them until its foot has passed the head's trim, at 3.9 m. That is the kit's own fit and is allowed while it stays within the faces the shut leaf already meets; anything else the leaf meets on the way is a fault (sweep()). """ import bpy from mathutils import Vector from mathutils.bvhtree import BVHTree import exterior as ext from bl_kit import get_collection COLLECTION = "11 DOORS | rig (open and close)" MASTER = "DOORS | all" ACTION = "DOORS | all | OPEN_CLOSE 2s" OPEN_SECONDS = 2.0 # the leaf lifts its 4.10 m in this time HOLD_SECONDS = 2.0 # how long the timeline keeps them open STEP = 0.05 # sweep() lifts the leaf this much at a time (the leaf is 4 m tall) NAMES = { # what each leaf is, for its control's name "Hall_Door_L_11": "arrivals door", "Hall_Door_L_7": "crossing to cargo hall", "Hall_Door_L_8": "cargo maintenance door", "Hall_Door_L_9": "west quay end door", "Hall_Door_L_9.001": "east quay end door", } def _box(pts): return (Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))), Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))) def shut_mesh(leaf): """The leaf's faces where it stands shut: (world vertices, faces, lo, hi).""" mw = ext.door_closed_matrix(leaf) v = [mw @ p.co for p in leaf.data.vertices] lo, hi = _box(v) return v, [tuple(p.vertices) for p in leaf.data.polygons], lo, hi def around(lo, hi, up, pad=0.05): """The station's meshes whose box meets the column a leaf goes up (no leaves, no decals): [(object, BVH, world vertices, faces)].""" out = [] for ob in bpy.data.collections["ANCHORAGE STATION"].all_objects: if ob.type != "MESH" or ob.get("ue_decal_material") is not None or ob.get("ue_role") == "door_leaf": continue a, b = _box([ob.matrix_world @ Vector(k) for k in ob.bound_box]) if b.x < lo.x - pad or a.x > hi.x + pad or b.y < lo.y - pad or a.y > hi.y + pad or b.z < lo.z or a.z > hi.z + up: continue v = [ob.matrix_world @ p.co for p in ob.data.vertices] f = [tuple(p.vertices) for p in ob.data.polygons] if f: out.append((ob, BVHTree.FromPolygons(v, f), v, f)) return out def sweep(leaf, travel=None, step=STEP): """Lifts the shut leaf to its travel, a step at a time. Returns (seat, faults): seat {piece: (z0, z1)} what the shut leaf stands in (its frame) and the height of the faces it meets there faults {piece: [mesh, first lift, last lift, lowest z, highest z]} everything else it meets on the way, and faces of its frame outside z0..z1 (it would come out through the frame)""" bpy.context.view_layer.update() travel = float(travel if travel is not None else ext.DOOR_TRAVEL) lv, lf, lo, hi = shut_mesh(leaf) near = around(lo, hi, travel) b0 = BVHTree.FromPolygons(lv, lf) seat = {} for ob, bv, v, f in near: pairs = bv.overlap(b0) if pairs: zs = [v[q].z for i, j in pairs for q in f[i]] seat[ob.name] = (min(zs), max(zs)) faults = {} n = max(1, int(round(travel / step))) for k in range(1, n + 1): t = travel * k / n moved = BVHTree.FromPolygons([p + Vector((0.0, 0.0, t)) for p in lv], lf) for ob, bv, v, f in near: pairs = bv.overlap(moved) if pairs and ob.name in seat: z0, z1 = seat[ob.name] pairs = [(i, j) for i, j in pairs if max(v[q].z for q in f[i]) > z1 + 0.005 or min(v[q].z for q in f[i]) < z0 - 0.005] if not pairs: continue zs = [v[q].z for i, j in pairs for q in f[i]] h = faults.setdefault(ob.name, [ob.data.name, t, t, min(zs), max(zs)]) h[2], h[3], h[4] = t, min(h[3], min(zs)), max(h[4], max(zs)) return seat, faults def opening_top(leaf, near): """The underside of the doorway's head over the leaf's middle (a ray across the opening, along the door's axis, climbing 1 cm at a time); None if nothing is found up to 6 m over the leaf's foot.""" v, f, lo, hi = shut_mesh(leaf) mid = (lo + hi) / 2.0 axis = Vector((1.0, 0.0, 0.0)) if hi.x - lo.x < hi.y - lo.y else Vector((0.0, 1.0, 0.0)) for k in range(400): z = lo.z + 2.0 + 0.01 * k o = Vector((mid.x, mid.y, z)) - axis * 0.9 if any(bv.ray_cast(o, axis, 1.8)[0] is not None for ob, bv, vv, ff in near): return z - 0.01 return None def travels(report=print): """{leaf: (travel to use, lift its foot needs to clear the opening, seat, faults)}""" out = {} for ob, lo, hi, thin in ext.door_leaves(): seat, faults = sweep(ob) first = min((h[1] for h in faults.values()), default=None) use = ext.DOOR_TRAVEL if first is None else max(0.0, round(first - STEP, 2)) top = opening_top(ob, around(lo, hi, 0.0)) need = (top - lo.z) if top is not None else None out[ob.name] = (use, need, seat, faults) report("[doors] %-18s %-24s rides in %s; meets nothing else on its way up to %.2f m%s; the opening is clear " "after %s m of lift" % ( ob.name, NAMES.get(ob.name, ""), ", ".join(sorted(seat)) or "nothing", ext.DOOR_TRAVEL if first is None else first, "" if first is None else " (then: %s)" % ", ".join("%s at z %.2f..%.2f" % (k, h[3], h[4]) for k, h in faults.items()), "%.2f" % need if need is not None else "?")) return out def _slider(ob, name, text): ob[name] = 0.0 ui = ob.id_properties_ui(name) ui.update(min=0.0, max=1.0, soft_min=0.0, soft_max=1.0, precision=2, description=text) try: ui.update(subtype="FACTOR") except Exception: # noqa: BLE001 (an older Blender: the slider stays a number field) pass def _height_driver(leaf): ad = leaf.animation_data if ad is None: return None return next((fc for fc in ad.drivers if fc.data_path == "location" and fc.array_index == 2), None) def _undrive(leaf): """Takes a leaf's driver away and stands it shut.""" fc = _height_driver(leaf) if fc is not None: leaf.animation_data.drivers.remove(fc) c = leaf.get("closed_location") if c is not None and len(c) == 3: leaf.location = Vector(c) def at_rest(): """Every leaf where it stands shut, whatever its slider and the timeline say: the drivers are muted. For the checks, which do not save the file.""" n = 0 for ob in bpy.data.objects: if ob.type == "MESH" and ob.get("ue_role") == "door_leaf" and ob.get("closed_location") is not None: fc = _height_driver(ob) if fc is not None: fc.mute = True ob.location = Vector(ob["closed_location"]) n += 1 bpy.context.view_layer.update() return n def _curves(ad): try: from bpy_extras import anim_utils cb = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot) return list(cb.fcurves) if cb is not None else [] except Exception: # noqa: BLE001 (Blender before 4.4 kept the curves on the action) return list(getattr(ad.action, "fcurves", [])) def rig(report=print): station = bpy.data.collections["ANCHORAGE STATION"] sc = bpy.context.scene fps = sc.render.fps / (sc.render.fps_base or 1.0) open_f, hold_f = int(round(OPEN_SECONDS * fps)), int(round(HOLD_SECONDS * fps)) col = get_collection(COLLECTION, station) # an older rig goes first, and every leaf back to where it stands shut for ob in list(col.objects): bpy.data.objects.remove(ob) old = bpy.data.actions.get(ACTION) if old is not None and old.library is None: bpy.data.actions.remove(old) leaves = ext.door_leaves() for ob, lo, hi, thin in leaves: _undrive(ob) if ob.get("closed_location") is None: ob["closed_location"] = [round(c, 5) for c in ob.location] tr = travels(report) leaves = ext.door_leaves() master = bpy.data.objects.new(MASTER, None) master.empty_display_type = "PLAIN_AXES" master.empty_display_size = 3.0 master.location = (0.0, 0.0, 14.0) master.show_name = master.show_in_front = True col.objects.link(master) _slider(master, "open_all", "Opens every door; the timeline drives it (action '%s'): open at frame %d, shut again " "at %d" % (ACTION, 1 + open_f, 1 + 2 * open_f + hold_f)) master["anchorage_rig"] = "doors" rigged = [] for ob, lo, hi, thin in leaves: use, need, seat, faults = tr[ob.name] z0 = float(ob["closed_location"][2]) ob["ue_door_travel_m"] = [0.0, 0.0, use] ob["ue_door_open_seconds"] = OPEN_SECONDS # the control: an arrow over the doorway, on its outer side (the cargo hall's for the inner door) name = NAMES.get(ob.name, ob.name) ctrl = bpy.data.objects.new("DOOR | %s (%s)" % (name, ob.name), None) ctrl.empty_display_type = "SINGLE_ARROW" ctrl.empty_display_size = 1.5 ctrl.show_name = ctrl.show_in_front = True cx, cy = (lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0 out = Vector((1.0 if cx > 0 else -1.0, 0.0, 0.0)) if thin == "x" else Vector((0.0, 1.0 if cy > 0 else -1.0, 0.0)) ctrl.location = (cx + out.x * 1.6, cy + out.y * 1.6, hi.z + 0.6) col.objects.link(ctrl) _slider(ctrl, "open", "Opens this door: 0 shut, 1 open (the leaf lifts %.2f m into its housing)" % use) ctrl["anchorage_rig"] = "doors" ctrl["door_leaf"] = ob.name ob["ue_door_control"] = ctrl.name # the leaf's height: shut height + travel x the larger of the two sliders fc = ob.driver_add("location", 2) for m in list(fc.modifiers): fc.modifiers.remove(m) drv = fc.driver drv.type = "SCRIPTED" while len(drv.variables): drv.variables.remove(drv.variables[0]) for vname, target, path in (("o", ctrl, '["open"]'), ("a", master, '["open_all"]')): var = drv.variables.new() var.name = vname var.type = "SINGLE_PROP" var.targets[0].id_type = "OBJECT" var.targets[0].id = target var.targets[0].data_path = path drv.expression = "%.4f + %.4f * max(o, a)" % (z0, use) if not drv.is_simple_expression: raise RuntimeError("the driver of %s would need Python: %s" % (ob.name, drv.expression)) rigged.append({"leaf": ob.name, "control": ctrl.name, "travel": use, "expression": drv.expression}) # the timeline: every door opens and shuts once keys = ((1, 0.0), (1 + open_f, 1.0), (1 + open_f + hold_f, 1.0), (1 + 2 * open_f + hold_f, 0.0)) for frame, value in keys: master["open_all"] = value master.keyframe_insert(data_path='["open_all"]', frame=frame) ad = master.animation_data ad.action.name = ACTION for fc in _curves(ad): for kp in fc.keyframe_points: kp.interpolation = "BEZIER" kp.handle_left_type = kp.handle_right_type = "AUTO_CLAMPED" # flat at both ends: eases in and out fc.update() master["open_all"] = 0.0 if sc.frame_end < keys[-1][0] + 10: sc.frame_end = keys[-1][0] + 10 sc.frame_set(sc.frame_current) report("[doors] rigged %d doors; controls in '%s'; the timeline (%g fps) opens them by frame %d and shuts them " "again at %d" % (len(rigged), COLLECTION, fps, keys[1][0], keys[-1][0])) return {"rigged": rigged, "keys": keys, "action": ad.action.name, "fps": fps}