""" Anchorage's own dressing on top of the module structure (called by build_anchorage.py): lettering and arrows, cargo, the few fittings of an unmanned station, the preview surroundings (sea and sky) and markers for game use. Everything placed here is again an instance of a kit mesh or one of the pack's own decal materials, so it travels to Unreal with the rest. The only things that are NOT kit are in the collection "CONTEXT | preview only": a sea plane, the sky and a camera; they are left out of the layout export. The composition rules (so that additions stay in the same hand): LETTERING * One type size on the route: every word is a 3.2 x 0.8 m decal (letters about 0.33 m), centred on the axis of the opening it belongs to. Side doors use 2.0 x 0.5 m. * A word lies on the floor 1.7 m before the doorway it names and is read walking towards that doorway. Rooms at the end of a route carry their own name just inside the door instead. * White on dark floors, black on the white floor of the supply hall. Only variants the pack ships are used. * Orange is the one accent colour and means "something moves here": three chevrons after every word point at the doorway, and a hazard band crosses the floor in front of the arrivals door, the maintenance door and the hull at the end of the boarding bridge. CARGO * The cargo hall's alcoves each have two floor pads either side of a wall lamp: one stack of two crates per pad, the same in all four alcoves, mirrored about the hall's axis. Nothing stands in the aisle. FITTINGS * Nobody lives here, so there is little: a counter with the convict's file where processing begins and bare racks in the supply hall (in pairs, mirrored). There is no detention (the user, 2026-10-21; they had taken its sign out of the station by hand): the room beyond the processing hall's north-east door is the north end of the wing the MEDICAL sign leads into, and it gets no word of its own. """ import math import os import bpy import numpy as np from mathutils import Matrix, Vector import bl_demo_map as demo from bl_kit import GAME, ROOT, get_collection, matrix_to_ue_rows # noqa: F401 MATS = GAME + "ModularSciFiMats/Materials/Decals/" COMM = GAME + "ModularSci_Comm/Materials/Decals/" HW = GAME + "ModularScifiHallways/Meshes/" PR = GAME + "ModularScifiProps/Meshes/" WORDS = { # white "ARRIVALS": MATS + "Words/MI_Arrivals2", "PROCESSING": MATS + "Words/MI_Processing", "SECURITY": MATS + "Words/MI_Security", "MEDICAL": MATS + "Words/MI_Medical", "DETENTION": MATS + "Words/MI_Detention", "CARGO": MATS + "Words/MI_Cargo", "DOCK": MATS + "Words/MI_Dock", "DEPARTURES": MATS + "Words/MI_Departures", "STAND CLEAR": MATS + "Words/MI_StandClear", "RESTRICTED": MATS + "Words/MI_RestrictedArea", "CONTROL": COMM + "Words/MI_Control", "MAINTENANCE": COMM + "Words/MI_Maintenance", "STATION": MATS + "Words/MI_Station", "OBSERVATION": COMM + "Words/MI_Observation", # black "SUPPLIES/black": MATS + "Words/MI_Supplies", "MAINTENANCE/black": MATS + "Words/MI_Maintenance", "RESTRICTED/black": MATS + "Words/MI_RestrictedArea2", } CHEVRON = MATS + "Arrows/MI_Arrow_A" # orange chevron, points along +U HAZARD = MATS + "Stripes/MI_Stripe_H" # orange diagonal hazard band; falls back to Stripe_D HAZARD_FALLBACK = MATS + "Stripes/MI_Stripe_D" MAIN = (3.2, 0.8) SIDE = (2.0, 0.5) LIFT = 0.004 def dir_vec(heading_deg): """Unit vector for a heading: 0 = +X (east), 90 = +Y (north).""" a = math.radians(heading_deg) return Vector((math.cos(a), math.sin(a), 0.0)) class Dresser(object): def __init__(self, b): self.b = b self.kit = b.kit self.scene = bpy.context.scene self.refresh() self._extent = {} self.pending = [] self._group = None def refresh(self): bpy.context.view_layer.update() self.dg = bpy.context.evaluated_depsgraph_get() # -- measuring the built station ---------------------------------------------------------------------------- def floor_z(self, x, y, z_from=1.0): hit, loc, nrm, idx, ob, mtx = self.scene.ray_cast(self.dg, Vector((x, y, z_from)), Vector((0, 0, -1)), distance=3.0) return loc.z if hit else 0.0 def floor_top(self, cx, cy, d, r, w, h): """Highest floor point under a w x h rectangle (d = its up direction, r = its right direction).""" zs = [] for a in (-0.45, 0.0, 0.45): for bb in (-0.45, 0.0, 0.45): p = Vector((cx, cy, 0)) + r * (a * w) + d * (bb * h) zs.append(self.floor_z(p.x, p.y)) return max(zs) def wall(self, origin, heading_deg, max_dist=12.0): """First surface hit from `origin` along a horizontal heading: (distance, point, normal) or None.""" hit, loc, nrm, idx, ob, mtx = self.scene.ray_cast(self.dg, Vector(origin), dir_vec(heading_deg), distance=max_dist) return ((loc - Vector(origin)).length, loc, nrm) if hit else None # -- lettering ---------------------------------------------------------------------------------------------- def text_extent(self, mat): """Where the lettering sits inside its texture: (u0, u1, v0, v1), v measured from the bottom.""" if mat.name in self._extent: return self._extent[mat.name] ext = (0.0, 1.0, 0.0, 1.0) for node in mat.node_tree.nodes: if node.type == "TEX_IMAGE" and node.image is not None and node.image.size[0] > 0: img = node.image w, h = img.size px = np.empty(w * h * 4, dtype=np.float32) img.pixels.foreach_get(px) a = px.reshape(h, w, 4)[:, :, 0] cols = np.where(a.max(axis=0) > 0.35)[0] rows = np.where(a.max(axis=1) > 0.35)[0] if len(cols) and len(rows): ext = (cols[0] / w, (cols[-1] + 1) / w, rows[0] / h, (rows[-1] + 1) / h) break self._extent[mat.name] = ext return ext def floor_mark(self, mat_path, x, y, heading, size, zone, name, centre_text=True): """A floor decal read by someone walking along `heading`; (x, y) is where the visible mark is centred.""" self.pending.append(("floor", mat_path, x, y, heading, size, zone, name, centre_text, self._group)) def word(self, key, x, y, heading, zone, size=MAIN, chevrons=True, gap=0.55): self.floor_mark(WORDS[key], x, y, heading, size, zone, "WORD " + key.split("/")[0]) if chevrons: d = dir_vec(heading) s = size[1] * 0.62 # chevron size follows the type size start = size[1] * 0.5 + gap for i in range(3): p = Vector((x, y, 0)) + d * (start + i * s * 0.62) # the chevron texture points along +U (the reader's right): turn it to point ahead self.floor_mark(CHEVRON, p.x, p.y, heading + 90.0, (s, s), zone, "CHEVRON", centre_text=False) def hazard_band(self, x, y, heading, zone, width=8.0, piece=2.0): """A hazard band across the floor, `width` long, centred on (x, y), read walking along `heading`.""" mat = HAZARD if HAZARD in self.kit.materials else HAZARD_FALLBACK r = Vector((dir_vec(heading).y, -dir_vec(heading).x, 0.0)) # reader's right n = int(round(width / piece)) # one band = one height: its pieces share the highest floor point under any of them, so the band is not # broken where the floor steps (in Unreal the decal simply follows the floor) self._group = ("band", x, y, heading) for i in range(n): p = Vector((x, y, 0)) + r * ((i - (n - 1) / 2.0) * piece) self.floor_mark(mat, p.x, p.y, heading, (piece, piece / 2.0), zone, "HAZARD BAND", centre_text=True) self._group = None def flush_marks(self): """Measures the floor under every pending mark first, then creates them (so marks never measure marks).""" specs = [] for kind, mat_path, x, y, heading, size, zone, name, centre_text, group in self.pending: mat = self.kit.material(mat_path) d = dir_vec(heading) # text "up" on the floor = direction of travel r = Vector((d.y, -d.x, 0.0)) # text right = the reader's right w, h = size cx, cy = x, y if centre_text: u0, u1, v0, v1 = self.text_extent(mat) off = r * ((0.5 - (u0 + u1) / 2.0) * w) + d * ((0.5 - (v0 + v1) / 2.0) * h) cx, cy = x + off.x, y + off.y vis_w, vis_h = (u1 - u0) * w, (v1 - v0) * h z = self.floor_top(x, y, d, r, vis_w, vis_h) else: z = self.floor_top(x, y, d, r, w, h) specs.append([mat_path, mat, cx, cy, z, d, r, w, h, zone, name, group]) tops = {} for s in specs: if s[11] is not None: tops[s[11]] = max(tops.get(s[11], -1e9), s[4]) for s in specs: if s[11] is not None: s[4] = tops[s[11]] for mat_path, mat, cx, cy, z, d, r, w, h, zone, name, group in specs: root, c_light, c_decal = self.b.zone_cols(zone) # decal frame: X = projection (down), Y = text up, Z = text right; scale = (depth, half height, half width) cm depth = 12.0 m = Matrix(( (0.0, d.x * h * 50.0, r.x * w * 50.0, cx), (0.0, d.y * h * 50.0, r.y * w * 50.0, cy), (-depth, 0.0, 0.0, z), (0.0, 0.0, 0.0, 1.0), )) dcl = {"material": mat_path, "size": [1.0, 1.0, 1.0], "matrix": matrix_to_ue_rows(m), "sort_order": 0} ob = demo.add_decal(self.kit, dcl, name, c_decal, lift=LIFT) ob.matrix_world = Matrix.Translation((0, 0, LIFT)) @ m ob["anchorage_zone"] = zone if zone not in self.b_zones() else self.b_zones()[zone] ob["anchorage_module"] = "dressing: lettering" n = len(specs) self.pending = [] self.refresh() return n def b_zones(self): return self.b.ZONES # -- props -------------------------------------------------------------------------------------------------- def prop(self, mesh_path, cx, cy, yaw, zone, name=None, z=None, scale=1.0, module="dressing: fittings"): """A kit mesh standing on the floor with its bounding box centred on (cx, cy); yaw in degrees (CCW).""" rec = self.kit.meshes[mesh_path] # Blender-local bounding box centre: (x, -y) of the Unreal bounds lx = (rec["min"][0] + rec["max"][0]) / 200.0 * scale ly = -(rec["min"][1] + rec["max"][1]) / 200.0 * scale rot = Matrix.Rotation(math.radians(yaw), 4, "Z") off = rot @ Vector((lx, ly, 0.0)) if z is None: z = self.floor_z(cx, cy) z0 = z - rec["min"][2] / 100.0 * scale m = Matrix.Translation((cx - off.x, cy - off.y, z0)) @ rot @ Matrix.Scale(scale, 4) root, c_light, c_decal = self.b.zone_cols(zone) ob = self.kit.place(mesh_path, m, root, name=name) ob["anchorage_zone"] = self.b_zones()[zone] ob["anchorage_module"] = module return ob def size(self, mesh_path, scale=1.0): rec = self.kit.meshes[mesh_path] return [(rec["max"][i] - rec["min"][i]) / 100.0 * scale for i in range(3)] # --------------------------------------------------------------------------------------------------------------- def dress(b): d = Dresser(b) report = {} # ---- CARGO: two crates stacked on each of the eight alcove pads -------------------------------------------- crate = HW + "SM_Crate_A" cw, cd, ch = d.size(crate) # 0.80 wide (x), 1.10 deep (y), 0.82 high n = 0 for bay_x in (7.80, 17.00): # centres of the two alcove bays for side in (1.0, -1.0): # north and south alcove # back wall of the alcove at crate height, measured in the finished station hit = d.wall((bay_x - 1.1, side * 2.2, 0.45), 90.0 * side, max_dist=4.0) back = (side * 2.2 + side * hit[0]) if hit else side * 3.75 cy = back - side * (cd / 2.0 + 0.08) for dx in (-1.10, 1.10): # the two pads either side of the wall lamp z = d.floor_z(bay_x + dx, cy) # lids to the aisle on both sides of the hall: the south stacks are turned round yaw = 0.0 if side > 0 else 180.0 d.prop(crate, bay_x + dx, cy, yaw, "CARGO", name="Cargo crate", z=z, module="dressing: cargo") d.prop(crate, bay_x + dx, cy, yaw, "CARGO", name="Cargo crate (upper)", z=z + ch + 0.005, module="dressing: cargo") n += 2 report["cargo crates"] = n # ---- PROCESSING: the counter where the route begins, with the convict's file -------------------------------- desk = PR + "SM_Desk_B_v1" dw, dd, dh = d.size(desk) hit = d.wall((0.0, 26.5, 0.6), -90.0, max_dist=6.0) # the central block's north face face = 26.5 - hit[0] if hit else 23.4 desk_y = face + 0.90 + dd / 2.0 d.prop(desk, 0.0, desk_y, 0.0, "PROC", name="Processing counter") d.refresh() top = d.floor_z(0.45, desk_y + 0.12, z_from=1.6) d.prop(HW + "SM_Datapad", 0.45, desk_y + 0.12, 12.0, "PROC", name="Case file (datapad)", z=top + 0.003) d.prop(PR + "SM_Laptop_Closed", -0.55, desk_y + 0.05, 180.0, "PROC", name="Terminal (closed)", z=top + 0.003) report["processing counter"] = 3 # ---- SUPPLY hall: bare racks in mirrored pairs along both walls --------------------------------------------- shelf = PR + "SM_Shelf_A_v1" sw, sd, sh = d.size(shelf) n = 0 for side in (1.0, -1.0): hit = d.wall((0.0, 8.1, 0.9), 0.0 if side > 0 else 180.0, max_dist=7.0) wall_x = side * hit[0] if hit else side * 4.4 cx = wall_x - side * (sd / 2.0 + 0.10) for cy in (7.15, 9.15): d.prop(shelf, cx, cy, 90.0, "PROC", name="Supply rack") n += 1 report["supply racks"] = n # ---- the medical wing's north end stays bare (there is no detention: see the docstring) ----------------------- d.refresh() # ---- LETTERING ------------------------------------------------------------------------------------------------ S, N, E, W = -90.0, 90.0, 0.0, 180.0 # 01 arrivals lock: its own name inside the outer door, the next room's name before the portal d.word("ARRIVALS", 0.0, 38.35, S, "ARR", chevrons=False) d.word("PROCESSING", 0.0, 35.75, S, "ARR") d.hazard_band(0.0, 39.55, N, "ARR") # 02 processing: the side doors of the north hall, the supply hall's own name (no DETENTION: there is none) d.word("SECURITY", -2.95, 25.35, W, "PROC", size=SIDE) d.word("MEDICAL", 2.95, 25.35, E, "PROC", size=SIDE) d.word("SUPPLIES/black", 0.0, 16.85, S, "PROC", chevrons=False) # 03 crossing: every way out named on the dark centre panel, read walking out through that frame d.word("PROCESSING", 0.0, 1.7, N, "XING", size=(2.4, 0.6), gap=0.4) d.word("DOCK", 0.0, -1.7, S, "XING", size=(2.4, 0.6), gap=0.4) d.word("CONTROL", -1.7, 0.0, W, "XING", size=(2.4, 0.6), gap=0.4) d.word("CARGO", 1.7, 0.0, E, "XING", size=(2.4, 0.6), gap=0.4) # 05 cargo hall: the sealed door at its end (the floor of that bay is light, so the black variants) d.word("MAINTENANCE/black", 16.2, 0.0, E, "CARGO", chevrons=False) d.word("RESTRICTED/black", 17.35, 0.0, E, "CARGO", chevrons=False) d.hazard_band(18.35, 0.0, E, "CARGO", width=6.0) # 07 dock head: the way back, the two quays and the vessel, named like the crossing's four ways hy = b.DOCK_HEAD_Y d.word("STATION", 0.0, hy + 1.7, N, "DOCK", size=(2.4, 0.6), gap=0.4) d.word("DEPARTURES", 0.0, hy - 1.7, S, "DOCK", size=(2.4, 0.6), gap=0.4) d.word("OBSERVATION", -1.7, hy, W, "DOCK", size=(2.4, 0.6), gap=0.4) d.word("OBSERVATION", 1.7, hy, E, "DOCK", size=(2.4, 0.6), gap=0.4) # boarding bridge: the hull is the door by = b.BRIDGE_END_Y d.word("STAND CLEAR", 0.0, by + 2.45, S, "DOCK", chevrons=False) d.hazard_band(0.0, by + 1.15, S, "DOCK", width=6.0) report["lettering marks"] = d.flush_marks() print("[anchorage] dressing: %s" % report) def finish_scene(b): scene = bpy.context.scene # ---- preview surroundings: an overcast sky and a dark sea 9 m below the deck -------------------------------- ctx = get_collection("CONTEXT | preview only (not kit, not exported)") world = bpy.data.worlds.new("Hadra overcast") scene.world = world world.use_nodes = True nt = world.node_tree nt.nodes.clear() out = nt.nodes.new("ShaderNodeOutputWorld") bg = nt.nodes.new("ShaderNodeBackground") bg.name = "Sky" ramp = nt.nodes.new("ShaderNodeValToRGB") sep = nt.nodes.new("ShaderNodeSeparateXYZ") tc = nt.nodes.new("ShaderNodeTexCoord") nt.links.new(tc.outputs["Generated"], sep.inputs[0]) nt.links.new(sep.outputs["Z"], ramp.inputs[0]) nt.links.new(ramp.outputs[0], bg.inputs[0]) nt.links.new(bg.outputs[0], out.inputs[0]) # a permanent overcast under a dim red dwarf: grey with a warm band at the horizon ramp.color_ramp.elements[0].position = 0.46 ramp.color_ramp.elements[0].color = (0.10, 0.11, 0.13, 1.0) ramp.color_ramp.elements[1].position = 0.60 ramp.color_ramp.elements[1].color = (0.42, 0.45, 0.50, 1.0) mid = ramp.color_ramp.elements.new(0.505) mid.color = (0.66, 0.50, 0.42, 1.0) bg.inputs["Strength"].default_value = 1.6 sea_me = bpy.data.meshes.new("CONTEXT_sea") s = 1500.0 sea_me.from_pydata([(-s, -s, 0), (s, -s, 0), (s, s, 0), (-s, s, 0)], [], [(0, 1, 2, 3)]) sea = bpy.data.objects.new("CONTEXT_sea (preview only)", sea_me) # the sea is a plain plane: it must stay under the lowest piece of the station's rooms or it shows inside one. # Only the kit's rooms count: the outer shell's footings and the ice go below the waterline on purpose. bpy.context.view_layer.update() lowest = min((ob.matrix_world @ Vector(c)).z for ob in b.station.all_objects if ob.type == "MESH" and not ob.get("ue_decal_material") and ob.data.get("ue_mesh") and not ob.get("anchorage_surroundings") for c in ob.bound_box) sea_z = min(b.SEA_LEVEL_Z, lowest - 0.15) if sea_z < b.SEA_LEVEL_Z: print("[anchorage] sea lowered to %.2f m: station geometry reaches %.2f m" % (sea_z, lowest)) sea.location = (0.0, 0.0, sea_z) mat = bpy.data.materials.new("CONTEXT_sea") mat.use_nodes = True bsdf = mat.node_tree.nodes.get("Principled BSDF") bsdf.inputs["Base Color"].default_value = (0.012, 0.03, 0.045, 1.0) bsdf.inputs["Roughness"].default_value = 0.12 bsdf.inputs["Metallic"].default_value = 0.0 noise = mat.node_tree.nodes.new("ShaderNodeTexNoise") noise.inputs["Scale"].default_value = 0.6 noise.inputs["Detail"].default_value = 6.0 bump = mat.node_tree.nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = 0.35 mat.node_tree.links.new(noise.outputs[0], bump.inputs["Height"]) mat.node_tree.links.new(bump.outputs[0], bsdf.inputs["Normal"]) sea_me.materials.append(mat) ctx.objects.link(sea) # ---- the vessel at its berth: the user's own model, LINKED from models/M_Ship_v2.blend (not copied) ----------- ship_blend = os.path.join(os.path.dirname(ROOT), "M_Ship_v2.blend") ship_cols = ("01 ASSET | M-Ship v2", "02 MECHANISM | Starboard pocket doors") if os.path.isfile(ship_blend): with bpy.data.libraries.load(ship_blend, link=True) as (src, dst): dst.collections = [c for c in src.collections if c in ship_cols] for col in dst.collections: if col is None: continue inst = bpy.data.objects.new("CANTICLE | %s (linked M_Ship_v2, preview only)" % col.name.split("|")[0].strip(), None) inst.instance_type = "COLLECTION" inst.instance_collection = col inst.location = b.SHIP_LOCATION inst.empty_display_size = 2.0 ctx.objects.link(inst) print("[anchorage] vessel linked from %s at %s" % (ship_blend, tuple(round(v, 2) for v in b.SHIP_LOCATION))) else: print("[anchorage] vessel model not found: " + ship_blend) # ---- markers for later game use --------------------------------------------------------------------------- mk = get_collection("MARKERS | game hooks", get_collection("ANCHORAGE STATION")) sx, sy, sz = b.SHIP_LOCATION markers = list(MARKERS) + [ ("MARKER | CANTICLE berth (ship origin, bow east)", (sx, sy, sz), 0.0, "where the carrier's own origin sits when docked: bow towards +X (east), port side to the quay, its lower deck level with the station floor"), ("MARKER | Boarding point (end of the bridge)", (0.0, b.BRIDGE_END_Y, 0.05), -90.0, "the bridge meets the hull here, around the port service hatch; a collar or bellows closes the last 5 to 35 cm"), ] for name, loc, yaw, note in markers: e = bpy.data.objects.new(name, None) e.empty_display_type = "ARROWS" e.empty_display_size = 0.8 e.location = loc e.rotation_euler = (0.0, 0.0, math.radians(yaw)) e["anchorage_marker"] = note mk.objects.link(e) # ---- render defaults, so F12 in the file gives a usable picture --------------------------------------------- for eng in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"): try: scene.render.engine = eng break except TypeError: continue for prop, val in (("taa_render_samples", 64), ("use_raytracing", True), ("shadow_pool_size", "1024")): try: setattr(scene.eevee, prop, val) except Exception: # noqa: BLE001 pass scene.view_settings.view_transform = "AgX" scene.view_settings.exposure = 0.5 scene.render.resolution_x, scene.render.resolution_y = 1920, 1080 cam_data = bpy.data.cameras.new("Arrival view") cam_data.lens = 16.0 cam = bpy.data.objects.new("CAM | arrival view", cam_data) cam.location = (0.0, 39.3, 1.72) cam.rotation_euler = (math.radians(88.0), 0.0, math.radians(180.0)) ctx.objects.link(cam) scene.camera = cam # name, location (m), heading (deg, 0 = +X, 90 = north), what it is for MARKERS = [ ("MARKER | PlayerStart (arrivals lock)", (0.0, 38.4, 0.12), -90.0, "where the convict stands when the game starts, facing south"), ("MARKER | Drop capsule berth (outside north door)", (0.0, 43.5, 0.0), -90.0, "the vehicle from orbit docks here, outside the north door"), ("MARKER | Relay terminal (uplink to orbit)", (-25.0, 0.0, 0.2), 0.0, "the central terminal: reports to the Tethys ship in orbit"), ("MARKER | Maintenance door (robots, sealed)", (19.9, 0.0, 0.0), 0.0, "robots come and go through here; closed to the player"), ]