""" Builds models/anchorage/Anchorage_Station.blend: the Anchorage facility assembled from the Modular SciFi Season 1 Starter Bundle. blender -b --factory-startup --python build/build_anchorage.py [-- --no-kit-source] [--exterior] [--out FILE] --exterior also builds the outer shell (build/exterior.py) and the ice the station stands on (build/ice.py): that is models/anchorage/Anchorage_Station_exterior.blend, the completed station --out FILE writes there instead of Anchorage_Station.blend How it is put together The pack ships demo maps in which its author has already composed rooms out of the kit (floors, walls, ceilings, pipes, lights). Those rooms are cut out at their doorway planes ("modules") and re-arranged into Anchorage's own plan; every piece stays an instance of the original kit mesh and keeps its Unreal asset path. On top of that comes Anchorage's own dressing: zone lettering, arrows, cargo, intake fittings (build/dressing.py). Plan (Blender metres, origin = centre of the Central Crossing, +Y = north): ARRIVALS lock (north door: where the convict is set down) | PROCESSING (north hall, security / medical side rooms, supply hall) | RELAY CONTROL -- W hall -- CROSSING -- CARGO hall -- (maintenance door, sealed) | MOORING GALLERY (windows on both sides) | west quay ----------- DOCK HEAD ----------- east quay (observation galleries along the hull) | boarding bridge ========================|==========================================> the vessel (CANTICLE), bow east, moored port side to the quay The station exists to hold the vessel: the main axis from the arrivals door runs straight to the boarding bridge, which meets the hull level with the ship's lower deck. """ import glob import json import math import os import sys import bpy sys.dont_write_bytecode = True # leave no __pycache__ next to the build scripts from mathutils import Matrix, Vector HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import bl_demo_map as demo # noqa: E402 from bl_kit import GAME, KIT_DIR, ROOT, Kit, exclude_collection, get_collection, ue_rows_to_matrix # noqa: E402 ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] # "-- --out " builds somewhere else (to try a change without touching the station file, which may be open # in the user's Blender with hand edits: see README "Rebuilding") OUT_BLEND = ARGS[ARGS.index("--out") + 1] if "--out" in ARGS else os.path.join(ROOT, "Anchorage_Station.blend") STATION = "ANCHORAGE STATION" ZONES = { "ARR": "01 ARRIVALS lock", "PROC": "02 PROCESSING", "XING": "03 CENTRAL CROSSING", "RELAY": "04 RELAY CONTROL", "WHALL": "04 RELAY CONTROL", "CARGO": "05 CARGO hall", "GAL": "06 MOORING GALLERY", "DOCK": "07 DOCK (head, boarding bridge, quays)", } # Frame depth of a hallway doorway and the half width of the junction square (m): the four frames of the crossing # end 5.13 m from its centre, and that is where the neighbouring rooms begin. FRAME_OUT = 4.4 + 0.73 # The dock. The interiors pack's three-bay gallery is 15.73 m from the frame it starts at to the outer face of # its own end frame; that is the pitch at which gallery units follow each other. GALLERY_PITCH = 44.3 - 28.57 # Centre of the dock head: its north frame takes the place of the mooring gallery's end frame. DOCK_HEAD_Y = -(FRAME_OUT + GALLERY_PITCH) - (FRAME_OUT - 0.76) QUAY_UNITS = 2 # One hall bay is 5.14 m between frames; an interiors portal frame is 0.70 m deep. HALL_BAY = 5.14 PORTAL = 0.70 # Where each quay's first window unit begins, measured from the dock head's centre along the quay. QUAY_START = FRAME_OUT + HALL_BAY + PORTAL # The one-bay boarding bridge: the vessel's widest point stands 5 cm beyond its end. BRIDGE_END_Y = DOCK_HEAD_Y - FRAME_OUT - HALL_BAY HULL_CLEARANCE = 0.05 # The vessel is the user's M-Ship v2 (models/M_Ship_v2.blend): bow +X, 122 x 20.4 x 21 m, port side +Y. # It is boarded where the bridge meets its port flank, level with its lower deck, around its service hatch. SHIP_HALF_BEAM = 10.19 SHIP_HATCH_X = -19.2 # centre of the port service hatch along the hull (ship's own x) SHIP_LOWER_DECK_Z = -3.36 # the ship's lower deck (ship's own z); it is set level with the station floor STATION_FLOOR_Z = 0.05 SHIP_LOCATION = (-SHIP_HATCH_X, BRIDGE_END_Y - HULL_CLEARANCE - SHIP_HALF_BEAM, STATION_FLOOR_Z - SHIP_LOWER_DECK_Z) # Preview sea level. It has to lie below everything the station has: Relay Control's sunken window ring reaches # 1.70 m under the main floor (a sea at -1.2 m cut through that room; found 2026-10-13). dressing.py lowers it # further by itself if a later change puts geometry deeper. SEA_LEVEL_Z = -1.9 # Each demo map fixes its own camera exposure (post-process "auto exposure" min = max brightness: interiors # 0.125, hallways 0.4, command centre 0.5) and its lights are tuned to that. Put in one station they need one # exposure, so the lights are scaled to the hallway map's: interiors x3.2, command centre x0.8. The factor is # stored on every light as ue_intensity_scale for the Unreal side. LIGHT_SCALE = {"SciFi_INT_EX1": 0.4 / 0.125, "SciFi_Example_D_Ext": 1.0, "SciFi_COMM_EX1": 0.4 / 0.5} # Blender only: brightness of the converted lights in the preview (the Unreal values are not touched by it). PREVIEW_LIGHT_GAIN = 2.0 def T(x=0.0, y=0.0, z=0.0): return Matrix.Translation((x, y, z)) def Rz(deg): return Matrix.Rotation(math.radians(deg), 4, "Z") # --------------------------------------------------------------------------------------------------------------- # reading the demo maps # --------------------------------------------------------------------------------------------------------------- class Source(object): """One exported demo map; every piece gets its footprint (bounds of the placed mesh, Blender metres).""" def __init__(self, kit, name): hits = glob.glob(os.path.join(KIT_DIR, "maps", "*__%s.json" % name)) with open(hits[0], "r", encoding="utf-8") as f: data = json.load(f) self.name = name self.items = [] for a in data["actors"]: if a.get("hidden") or a.get("hidden_in_game"): continue for m in a.get("meshes", []): rec = kit.meshes.get(m["mesh"]) if rec is None or not m["mesh"].startswith(GAME) or not m.get("visible", True): continue for rows in (m.get("instances") or [m["matrix"]]): lo, hi = self._bounds(rec, rows) defaults = [s["material"] for s in rec["slots"]] over = {i: mp for i, mp in enumerate(m["materials"]) if i < len(defaults) and mp and mp != defaults[i]} self.items.append({"kind": "mesh", "mesh": m["mesh"], "name": m["mesh"].split("/")[-1], "rows": rows, "lo": lo, "hi": hi, "c": (lo + hi) * 0.5, "pivot": Vector((rows[3][0] / 100, -rows[3][1] / 100, rows[3][2] / 100)), "overrides": over, "label": a["label"]}) for l in a.get("lights", []): if not l.get("visible", True): continue p = Vector((l["matrix"][3][0] / 100, -l["matrix"][3][1] / 100, l["matrix"][3][2] / 100)) self.items.append({"kind": "light", "light": l, "rows": l["matrix"], "c": p, "lo": p, "hi": p, "pivot": p, "name": "LIGHT", "label": a["label"]}) for dcl in a.get("decals", []): p = Vector((dcl["matrix"][3][0] / 100, -dcl["matrix"][3][1] / 100, dcl["matrix"][3][2] / 100)) self.items.append({"kind": "decal", "decal": dcl, "rows": dcl["matrix"], "c": p, "lo": p, "hi": p, "pivot": p, "name": "DECAL " + (dcl["material"] or "").split("/")[-1], "label": a["label"]}) @staticmethod def _bounds(rec, rows): X, Y, Z, W = rows lo = Vector((1e9, 1e9, 1e9)) hi = Vector((-1e9, -1e9, -1e9)) for cx in (rec["min"][0], rec["max"][0]): for cy in (rec["min"][1], rec["max"][1]): for cz in (rec["min"][2], rec["max"][2]): p = Vector(((X[0] * cx + Y[0] * cy + Z[0] * cz + W[0]) / 100.0, -(X[1] * cx + Y[1] * cy + Z[1] * cz + W[1]) / 100.0, (X[2] * cx + Y[2] * cy + Z[2] * cz + W[2]) / 100.0)) lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))) hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))) return lo, hi def select(self, x=(-1e9, 1e9), y=(-1e9, 1e9), where=None): out = [] for it in self.items: c = it["c"] if x[0] <= c.x <= x[1] and y[0] <= c.y <= y[1] and (where is None or where(it)): out.append(it) return out # --------------------------------------------------------------------------------------------------------------- class Builder(object): def __init__(self): self.kit = Kit() self.sources = {} self.station = get_collection(STATION) self.counts = {} self.ZONES = ZONES # for dressing.py self.FRAME_OUT, self.DOCK_HEAD_Y, self.BRIDGE_END_Y = FRAME_OUT, DOCK_HEAD_Y, BRIDGE_END_Y self.SHIP_LOCATION, self.SEA_LEVEL_Z = SHIP_LOCATION, SEA_LEVEL_Z self.QUAY_START, self.GALLERY_PITCH, self.QUAY_UNITS = QUAY_START, GALLERY_PITCH, QUAY_UNITS self.QUAY_END_X = QUAY_START + QUAY_UNITS * GALLERY_PITCH def src(self, name): if name not in self.sources: self.sources[name] = Source(self.kit, name) return self.sources[name] def zone_cols(self, zone): zname = ZONES[zone] root = get_collection(zname, self.station) return root, get_collection(zname + " | lights", root), get_collection(zname + " | lettering", root) def place(self, items, xf, zone, module, shift=None, light_scale=1.0): """Places selected demo pieces under the transform `xf` (Blender matrix applied to their demo position). `light_scale` brings the lights of that demo map to the common exposure (see LIGHT_SCALE).""" root, c_light, c_decal = self.zone_cols(zone) n = 0 for it in items: m = ue_rows_to_matrix(it["rows"]) if shift is not None: d = shift(it) if d is not None: m = T(*d) @ m m = xf @ m if it["kind"] == "mesh": ob = self.kit.place(it["mesh"], m, root, name=it["label"], overrides=it["overrides"]) if ob is None: continue if it["name"] in ("SM_Hall_Door_L", "SM_Door_L", "SM_GlassDoor_A", "SM_GlassDoor_B"): ob["ue_role"] = "door_leaf" elif it["kind"] == "light": l = dict(it["light"]) l["matrix"] = None ob = demo.add_light(it["light"], it["label"], c_light) if ob is None: continue ob.matrix_world = m @ demo.LIGHT_FIX ob.data.energy *= light_scale * PREVIEW_LIGHT_GAIN ob["ue_light"] = json.dumps({k: v for k, v in l.items() if k != "matrix"}) ob["ue_intensity_scale"] = light_scale else: ob = demo.add_decal(self.kit, it["decal"], it["label"], c_decal) mm = m.copy() mm.translation = m.translation - m.to_3x3().col[0].normalized() * 0.004 ob.matrix_world = mm ob["anchorage_zone"] = ZONES[zone] ob["anchorage_module"] = module n += 1 self.counts[module] = self.counts.get(module, 0) + n print("[anchorage] %-28s %4d pieces -> %s" % (module, n, ZONES[zone])) return n # -- the plan ----------------------------------------------------------------------------------------------- def build_structure(self): hw = self.src("SciFi_Example_D_Ext") it = self.src("SciFi_INT_EX1") cc = self.src("SciFi_COMM_EX1") # 03 CENTRAL CROSSING: the hallway pack's four-way junction with its four doorway frames. # In the demo its north and south frames stand 0.3 m off the room's axis; they are put back on it here. # The glass pane and the hanging cables the demo puts into two of the frames are left out: the main axis # runs through those frames and stays clear and symmetrical. ls_hw, ls_it, ls_cc = LIGHT_SCALE[hw.name], LIGHT_SCALE[it.name], LIGHT_SCALE[cc.name] x_hw = T(-24.0, -14.0) self.place(hw.select(x=(18.87, 29.3), y=(8.8, 19.2), where=lambda i: i["name"] not in ("SM_DoorwayGlass", "SM_DoorwayWire_A")), x_hw, "XING", "crossing (4-way junction)", light_scale=ls_hw, shift=lambda i: (0.3, 0.0, 0.0) if (i["c"].y < 9.7 or i["c"].y > 18.3) and 19.0 < i["c"].x < 28.4 else None) # 04 west hall: two bays from the crossing to the relay room, with the doorway frame at its far end. self.place(hw.select(x=(8.57, 18.869), y=(9.5, 18.5)), x_hw, "WHALL", "west hall (2 bays) + relay door frame", light_scale=ls_hw) # 04 RELAY CONTROL: the command centre. The three wall panels that close its 8.8 m "big door" in the demo # come out (with the lettering that sat on them); the west hall's frame stands in the opening. def not_big_door(i): if i["kind"] == "mesh": if i["name"] == "SM_Wall_D" and abs(i["pivot"].x - 9.6) < 0.05 and abs(abs(i["pivot"].y) - 4.4) < 0.05: return False if i["name"] == "SM_Wall_C" and abs(i["pivot"].x - 10.3) < 0.05: return False if i["name"] == "SM_SkySphere": return False if i["kind"] == "decal" and i["c"].x > 9.0 and abs(i["c"].y) < 4.35 and i["c"].z < 4.3: return False return True self.place(cc.select(where=not_big_door), T(-25.0, 0.0), "RELAY", "relay control (command centre)", light_scale=ls_cc) # 05 CARGO hall: the three-bay hall with wall alcoves, turned to run east; its end door is the sealed # maintenance door. x_e = T(FRAME_OUT, 0.0) @ Rz(90.0) @ T(-24.0, -8.87) # (the two loose crates of the demo are left out: the cargo is set out again in dressing.py) self.place(hw.select(x=(18.4, 29.6), y=(-7.2, 8.799), where=lambda i: not i["name"].startswith("SM_Crate")), x_e, "CARGO", "cargo hall (3 bays) + maintenance door", light_scale=ls_hw) # 02 PROCESSING: the interiors pack's two halls around a central block, with the side rooms that lead # round it. Its south portal frame is dropped: the crossing's north frame stands there. x_n = T(-23.9, FRAME_OUT - 45.0) self.place(it.select(y=(45.0, 72.2)), x_n, "PROC", "processing halls + side rooms", light_scale=ls_it) # 01 ARRIVALS: one window bay behind the north door, and the portal frame into processing. self.place(it.select(y=(72.2001, 82.0)), x_n, "ARR", "arrivals lock + north door", light_scale=ls_it) # 06 MOORING GALLERY: three window bays from the crossing to the dock head. The demo's end door is left # out; the dock head's north frame stands exactly where its frame stood. x_s = T(-23.9, -FRAME_OUT - 44.3) self.place(it.select(y=(29.34, 44.3)), x_s, "GAL", "mooring gallery (3 bays)", light_scale=ls_it) # 07 DOCK. The vessel lies east-west along the station's south side, port side to the quay, and is # boarded on the station's main axis. # dock head: a second four-way junction, all four frames open door_set = ("SM_Hall_Door_L", "SM_Hall_DoorPipes", "SM_DoorwayGlass", "SM_DoorwayWire_A") centre_fix = lambda i: (0.3, 0.0, 0.0) if (i["c"].y < 9.7 or i["c"].y > 18.3) and 19.0 < i["c"].x < 28.4 else None # noqa: E731 self.place(hw.select(x=(18.87, 29.3), y=(8.8, 19.2), where=lambda i: i["name"] not in door_set), T(0.0, DOCK_HEAD_Y) @ x_hw, "DOCK", "dock head (4-way junction)", light_scale=ls_hw, shift=centre_fix) # boarding bridge: one hall bay from the dock head's south frame to the hull (the demo's one-bay hall, # without its crates and end door, turned to run south) x_b = T(0.0, DOCK_HEAD_Y - FRAME_OUT) @ Rz(180.0) @ T(-4.15, -19.13) self.place(hw.select(x=(-1.0, 9.4), y=(19.131, 24.269), where=lambda i: not i["name"].startswith("SM_Crate")), x_b, "DOCK", "boarding bridge (1 bay)", light_scale=ls_hw) # quay links: one hall bay each way between the dock head and the quays, then a portal frame. Without # them the quays' window bays started right at the dock head and their end posts ran into the end posts # of the mooring gallery at both inside corners (found 2026-10-13); the link moves the quays 5.84 m out. stub = hw.select(x=(-1.0, 9.4), y=(19.131, 24.269), where=lambda i: not i["name"].startswith("SM_Crate")) portal = it.select(y=(44.3001, 44.9999)) self.place(stub, T(-FRAME_OUT, DOCK_HEAD_Y) @ Rz(90.0) @ T(-4.15, -19.13), "DOCK", "west quay link (1 bay)", light_scale=ls_hw) self.place(stub, T(FRAME_OUT, DOCK_HEAD_Y) @ Rz(-90.0) @ T(-4.15, -19.13), "DOCK", "east quay link (1 bay)", light_scale=ls_hw) self.place(portal, T(-QUAY_START, DOCK_HEAD_Y) @ Rz(-90.0) @ T(-23.9, -44.3), "DOCK", "west quay portal") self.place(portal, T(QUAY_START, DOCK_HEAD_Y) @ Rz(90.0) @ T(-23.9, -44.3), "DOCK", "east quay portal") # observation quays: the three-bay window gallery again, two units each way along the hull. The end # door stays closed on the outer units only; between units its frame is an open portal. leaf = ("SM_Door_L", "SM_Pipes_Doorway") for k in range(QUAY_UNITS): last = k == QUAY_UNITS - 1 sel = it.select(y=(28.0, 44.3), where=(None if last else (lambda i: i["name"] not in leaf))) self.place(sel, T(-QUAY_START - k * GALLERY_PITCH, DOCK_HEAD_Y) @ Rz(-90.0) @ T(-23.9, -44.3), "DOCK", "west quay unit %d" % (k + 1), light_scale=ls_it) self.place(sel, T(QUAY_START + k * GALLERY_PITCH, DOCK_HEAD_Y) @ Rz(90.0) @ T(-23.9, -44.3), "DOCK", "east quay unit %d" % (k + 1), light_scale=ls_it) # -- kit source --------------------------------------------------------------------------------------------- def build_kit_source(self): """Every mesh of the four kit packs once, on a grid, in a collection that is excluded from the view layer: the parts bin for extending the station by hand.""" root = get_collection("KIT | Modular SciFi Season 1 (source, one of each)") packs = ("ModularScifiHallways", "ModularSci_Int", "ModularSci_Comm", "ModularScifiProps") y0 = 80.0 for pack in packs: col = get_collection("KIT | " + pack, root) paths = [p for p in self.kit.all_mesh_paths((pack,))] x = -60.0 row_h = 0.0 for p in paths: rec = self.kit.meshes[p] w = (rec["max"][0] - rec["min"][0]) / 100.0 d = (rec["max"][1] - rec["min"][1]) / 100.0 if x + w > 60.0: x = -60.0 y0 += row_h + 2.0 row_h = 0.0 # put the mesh's box, not its pivot, on the grid ox = x - rec["min"][0] / 100.0 oy = y0 + rec["max"][1] / 100.0 ob = self.kit.place(p, T(ox, oy, 0.0), col, name="KIT_" + p.split("/")[-1]) if ob is not None: ob["anchorage_kit_source"] = True x += w + 1.5 row_h = max(row_h, d) y0 += row_h + 6.0 exclude_collection(root.name, True) print("[anchorage] kit source: %d meshes" % sum(len(c.objects) for c in root.children)) def main(): bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene scene.name = "Anchorage" scene.unit_settings.system = "METRIC" scene.unit_settings.scale_length = 1.0 b = Builder() b.build_structure() import dressing # noqa: E402 dressing.dress(b) if "--exterior" in ARGS: import exterior # noqa: E402 import ice # noqa: E402 exterior.add_exterior(b) ice.add_ice(b) if "--no-kit-source" not in ARGS: b.build_kit_source() dressing.finish_scene(b) bpy.context.preferences.filepaths.save_version = 0 # no .blend1 from the second save below bpy.ops.wm.save_as_mainfile(filepath=OUT_BLEND, compress=True) bpy.ops.file.make_paths_relative() bpy.ops.wm.save_mainfile(compress=True) n_obj = sum(1 for o in bpy.data.objects if o.get("anchorage_zone")) print("[anchorage] saved %s: %d station objects, %d meshes, %d materials, %d images" % ( OUT_BLEND, n_obj, len(bpy.data.meshes), len(bpy.data.materials), len(bpy.data.images))) if __name__ == "__main__": main()