""" The captain's cabin of models/captains-quarters, set into the starboard room beside the control room corridor. The user, 2026-10-13, after the two quarters had been built (quarters.py) and a second session had modelled a captain's cabin from a reference picture as a file of its own: "Port Captains quarters design from my other session into the room make it fit. delete anything inside, you can lower down floor "Deck | captain's quarters" to make it fit" What is done here, in the open file, in place (port()): * everything that stood in the captain's quarters is taken out: the lining, the floor panels, the bed, the furniture, the ceiling fittings, the lamps, the two markers; and what the corridor's side door carried on its room face (boxes, braces, a lamp), because the cabin's own door wall stands there now. The deck under the room stays and is set to the cabin's floor; * the cabin's parts (static ones, moving ones, bedding) are set in as they are: the same meshes as in the scene "Captains Quarters", turned half a turn about Z, so its door wall faces the corridor and its bunk wall the hull. Nothing of the design is cut, scaled or moved against the rest. The box hulls stay with the model's FBX files; * it stands with its own door behind the corridor's side door (same axis), its sliding leaf just clear of that door's frame; * its floor is DROP below the door sill: at sill level the top back corner of the bunk alcove meets the hull's skin (4 mm through it at the fore end). One step down inside the corridor's door; * a threshold step fills the gap between the corridor door's sill plate and the cabin's floor; * lamps where the model has glowing surfaces (the window, four ceiling fixtures, the bunk's cove, the desk nook's strip), since those do not light the room by themselves outside Cycles; * the markers "Captain's bed" and "Captain's desk" stand at the cabin's bunk and work station. The model itself is not touched: models/captains-quarters is the other session's folder, and the scene "Captains Quarters" in this file is its copy of it. If that scene is not in the file it is appended from models/captains-quarters/CaptainsQuarters.blend. import captains_cabin captains_cabin.port() # refuses if the cabin is already in the room captains_cabin.remove() # takes the cabin out again (the room is then empty) """ import math import os import bpy from mathutils import Matrix, Vector import quarters HERE = os.path.dirname(os.path.abspath(__file__)) CABIN_FILE = os.path.join(os.path.dirname(os.path.dirname(HERE)), "captains-quarters", "CaptainsQuarters.blend") SCENE = "Captains Quarters" ROOM = "10.5 | Command deck | Captain's quarters" DECK = "10.5 | Command deck" ROOT = quarters.ROOT SILL = quarters.Z_F # the corridor's door sills DOOR_X = 24.86 # the corridor's starboard side door: its axis ... FRAME_FACE = 2.998 # ... and how far its frame's room face is from the centre line BAY = (23.06, 26.66) # that door's bay along the corridor DROP = 0.20 # the cabin's floor below the sill (one step) GAP = 0.005 # between the door frame's face and the cabin's sliding leaf TAG = "captains_cabin" NOT_PARTS = ("UCX_",) # box hulls: with the model's FBX files, not in the ship scene LEAF = "SM_CQ_Door_Leaf" # lamps for the model's glowing surfaces: (object, material) -> lamp (watts, colour); positions are read from the faces LAMPS = {"window": 160.0, "ceiling": 22.0, "cove": 10.0, "nook": 5.0} # watts; a dark cabin, as the model's own renders show it COOL, WARM, DAY = (0.86, 0.93, 1.0), (1.0, 0.80, 0.58), (0.92, 0.96, 1.0) def log(msg): print("[captains_cabin] " + str(msg)) # ---- the model --------------------------------------------------------------------------------------------------- def source_scene(): sc = bpy.data.scenes.get(SCENE) if sc is None: if not os.path.isfile(CABIN_FILE): raise RuntimeError("no scene %r in this file and no %s" % (SCENE, CABIN_FILE)) with bpy.data.libraries.load(CABIN_FILE, link=False) as (src, dst): if SCENE not in src.scenes: raise RuntimeError("%s has no scene %r" % (CABIN_FILE, SCENE)) dst.scenes = [SCENE] sc = bpy.data.scenes[SCENE] return sc def source_parts(sc=None): """The model's parts in the order parents first: static, moving, bedding (no hulls, no studio lights).""" sc = sc or source_scene() parts = [o for o in sc.objects if o.type == "MESH" and not o.name.startswith(NOT_PARTS)] def depth(o): d = 0 while o.parent is not None: o, d = o.parent, d + 1 return d return sorted(parts, key=lambda o: (depth(o), o.name)) def src_world(o): """A model object's matrix in the model's own frame, from its own transforms. (The scene "Captains Quarters" is not the one on screen, so Blender has not evaluated it: matrix_world of its objects is still the identity, also for the door leaf, the locker doors and the chair, which do not stand at the origin.)""" m = o.matrix_basis.copy() if o.parent is not None: m = src_world(o.parent) @ o.matrix_parent_inverse @ m return m def _wbounds(obs, m=None, model=False): """Bounds of mesh objects. model=True: the objects are the model's own (src_world), taken through m if given.""" lo, hi = Vector((1e9, 1e9, 1e9)), Vector((-1e9, -1e9, -1e9)) for o in obs: own = src_world(o) if model else o.matrix_world mw = (m @ own) if m is not None else own for v in o.data.vertices: c = mw @ v.co for i in range(3): lo[i], hi[i] = min(lo[i], c[i]), max(hi[i], c[i]) return lo, hi def placement(parts=None, drop=DROP): """The matrix that takes the model's frame into the ship: half a turn about Z, its door on the corridor's side door's axis, its outermost point on the door side (the sliding leaf's track) just clear of that door's frame, its floor `drop` below the sill.""" parts = parts or source_parts() leaf = next(o for o in parts if o.data.name == LEAF or o.name == LEAF) llo, lhi = _wbounds([leaf], model=True) door_x = (llo.x + lhi.x) / 2.0 out_y = _wbounds(parts, model=True)[0].y x0 = DOOR_X + door_x # ship x = x0 - model x y0 = -(FRAME_FACE + GAP) + out_y # ship y = y0 - model y m = Matrix.Translation((x0, y0, SILL - drop)) @ Matrix.Rotation(math.pi, 4, "Z") return m, {"model_door_x": round(door_x, 3), "model_outermost_y_on_the_door_side": round(out_y, 3), "origin_in_ship": [round(x0, 3), round(y0, 3), round(SILL - drop, 3)], "floor_below_the_sill_m": drop} # ---- the room ---------------------------------------------------------------------------------------------------- def cabin_objects(): return [o for o in bpy.data.objects if o.get("made_by") == TAG] def exists(): return any(o.type == "MESH" for o in cabin_objects()) def _remove(ob, tally, key=None): key = key or (ob.data.name if ob.type == "MESH" else ob.type) tally[key] = tally.get(key, 0) + 1 data = ob.data bpy.data.objects.remove(ob) if isinstance(data, bpy.types.Light) and data.users == 0: bpy.data.lights.remove(data) def door_face_fittings(): """What the corridor's starboard side door carries on its room face (and the lamp over it): they stood in the room, and the cabin's door wall takes their place.""" out = [] names = [DECK, DECK + " | lights", DECK + " | lettering"] for n in names: c = bpy.data.collections.get(n) if c is None: continue for ob in c.objects: if ob.type == "MESH": mw = ob.matrix_world cs = [mw @ Vector(b) for b in ob.bound_box] x = sum(p.x for p in cs) / 8.0 z = sum(p.z for p in cs) / 8.0 ymax, ymin = max(p.y for p in cs), min(p.y for p in cs) if BAY[0] - 0.05 < x < BAY[1] + 0.05 and ymax < -(FRAME_FACE - 0.04) and ymin > -(FRAME_FACE + 0.35) and SILL < z < SILL + 3.9: out.append(ob) elif ob.type == "LIGHT": t = ob.matrix_world.translation if BAY[0] < t.x < BAY[1] and -(FRAME_FACE + 0.35) < t.y < -(FRAME_FACE - 0.04) and SILL < t.z < SILL + 3.9: out.append(ob) return out def clear_room(): """Take out everything that stands in the captain's quarters, except the deck under it.""" tally = {} for sub in (ROOM + " | lights", ROOM): c = bpy.data.collections.get(sub) if c is None: continue for ob in list(c.objects): if ob.type == "MESH" and ob.data.name.startswith("Deck |"): continue _remove(ob, tally) for ob in [o for o in bpy.data.objects if o.name.startswith("MARKER | Captain's") and o.get("made_by") in ("quarters", TAG)]: _remove(ob, tally, "marker") fit = {} for ob in door_face_fittings(): _remove(ob, fit) for me in [m for m in bpy.data.meshes if m.name.startswith(quarters.NAME) and m.users == 0]: bpy.data.meshes.remove(me) return {"taken_out_of_the_room": tally, "taken_off_the_door_frame's_room_face": fit} def _islands(ob, mat_name, m): """The connected groups of faces of one material: (centre, low corner, high corner) in ship coordinates, sorted by x then y.""" me = ob.data idx = [i for i, mt in enumerate(me.materials) if mt is not None and mt.name == mat_name] if not idx: return [] mw = m @ src_world(ob) faces = [p for p in me.polygons if p.material_index in idx] # vertices that lie at the same place count as one (the model's plates are separate pieces) key = {} parent = {} def find(a): while parent[a] != a: parent[a] = parent[parent[a]] a = parent[a] return a for p in faces: ks = [] for v in p.vertices: c = me.vertices[v].co k = (round(c.x, 4), round(c.y, 4), round(c.z, 4)) key[v] = k parent.setdefault(k, k) ks.append(k) for k in ks[1:]: ra, rb = find(ks[0]), find(k) if ra != rb: parent[rb] = ra groups = {} for p in faces: groups.setdefault(find(key[p.vertices[0]]), []).extend(mw @ me.vertices[v].co for v in p.vertices) out = [] for pts in groups.values(): lo = Vector((min(q.x for q in pts), min(q.y for q in pts), min(q.z for q in pts))) hi = Vector((max(q.x for q in pts), max(q.y for q in pts), max(q.z for q in pts))) out.append(((lo + hi) / 2.0, lo, hi)) return sorted(out, key=lambda r: (round(r[0].x, 2), round(r[0].y, 2))) def _merged(islands): """All islands as one box (a strip made of several pieces gets one lamp).""" if not islands: return [] lo = Vector((min(r[1].x for r in islands), min(r[1].y for r in islands), min(r[1].z for r in islands))) hi = Vector((max(r[2].x for r in islands), max(r[2].y for r in islands), max(r[2].z for r in islands))) return [((lo + hi) / 2.0, lo, hi)] def _lamp(coll, root, name, kind, loc, watts, color, size=None, rot=None, shadow=True): d = bpy.data.lights.new(name, kind) d.energy = watts d.color = color d.use_shadow = shadow if kind == "AREA": d.shape = "RECTANGLE" d.size, d.size_y = size else: d.shadow_soft_size = 0.05 try: d.shadow_maximum_resolution = 0.02 except Exception: # noqa: BLE001 pass ob = bpy.data.objects.new(name, d) coll.objects.link(ob) ob.parent = root ob.matrix_world = Matrix.Translation(loc) @ (rot if rot is not None else Matrix.Identity(4)) ob["zone"] = ROOM ob["made_by"] = TAG ob["ue_light_class"] = "RectLightComponent" if kind == "AREA" else "PointLightComponent" return ob def port(drop=DROP): """Set the cabin into the room. Returns a report.""" if exists(): raise RuntimeError("the cabin is already in the room (captains_cabin.remove() takes it out)") root = bpy.data.objects[ROOT] deck = bpy.data.collections[DECK] coll = quarters.get_collection(ROOM, deck) lights = quarters.get_collection(ROOM + " | lights", coll) sc = source_scene() parts = source_parts(sc) m, where = placement(parts, drop) rep = {"model": {"scene": SCENE, "parts": len(parts), "file": CABIN_FILE}, "placement": where} rep.update(clear_room()) zf = SILL - drop # the parts, as instances of the model's own meshes twin = {} for src in parts: ob = bpy.data.objects.new("Captain's cabin | " + src.name, src.data) coll.objects.link(ob) if src.parent is not None and src.parent in twin: ob.parent = twin[src.parent] ob.matrix_parent_inverse = src.matrix_parent_inverse.copy() ob.matrix_basis = src.matrix_basis.copy() else: ob.parent = root ob.matrix_world = m @ src_world(src) for k in src.keys(): if not k.startswith("_"): ob[k] = src[k] ob["zone"] = ROOM ob["made_by"] = TAG ob["model_part"] = src.name twin[src] = ob bpy.context.view_layer.update() # the deck under the room: its top just under the cabin's floor slab = next((o for o in coll.objects if o.type == "MESH" and o.data.name.startswith("Deck |")), None) if slab is not None: top = max((slab.matrix_world @ Vector(c)).z for c in slab.bound_box) dz = (zf - 0.005) - top mw = slab.matrix_world.copy() mw.translation.z += dz slab.matrix_world = mw rep["deck"] = {"object": slab.name, "top_was_z": round(top, 3), "top_is_z": round(zf - 0.005, 3), "moved_m": round(dz, 3)} # the threshold: from the corridor door's sill plate (top at SILL, 10 cm thick) down to the cabin's floor leaf = twin[next(s for s in parts if s.name == LEAF or s.data.name == LEAF)] llo, lhi = _wbounds([leaf]) wall_y = (m @ Vector((0.0, -2.2, 0.0))).y # the cabin's door wall plane (its floor begins there) mats = {mt.name: mt for mt in bpy.data.materials} mb = quarters.MB([mats["M_CQ_Gunmetal"], mats["M_CQ_Backing"]]) xa, xb = BAY # the whole bay: the leaf slides over it when it opens y_plate = -(FRAME_FACE - 0.048) # the sill plate's edge on the room side (y -2.95) G, K = mats["M_CQ_Gunmetal"], mats["M_CQ_Backing"] # the tread: level with the leaf's foot, so the leaf slides over it; from under the plate's edge to the floor # (its ends stop 1 mm above the deck slab under the room, whose top is 5 mm below the floor: nothing crosses it) mb.box((xa, wall_y, zf - 0.004), (xb, y_plate + 0.05, zf - 0.002), G, faces="xXZ", mat_of={"x": K, "X": K}) # the riser under the sill plate's edge mb.box((xa, y_plate, zf - 0.002), (xb, y_plate + 0.05, SILL - 0.102), G, faces="xXy", mat_of={"x": K, "X": K}) me = mb.finish("Captain's cabin | threshold step", "fills between the corridor door's sill plate and the cabin's floor; made in the ship, not part of the model") step = bpy.data.objects.new("Captain's cabin | threshold step", me) coll.objects.link(step) step.parent = root step["zone"] = ROOM step["made_by"] = TAG rep["threshold"] = {"x": [round(xa, 3), round(xb, 3)], "y": [round(wall_y, 3), round(y_plate + 0.05, 3)], "step_down_m": round(SILL - zf, 3)} # lamps for the glowing surfaces src_by = {s.name: s for s in parts} n = 0 down = Matrix.Identity(4) for c, lo, hi in _islands(src_by["SM_CQ_Ceiling"], "M_CQ_LightCool", m): n += 1 _lamp(lights, root, "Captain's cabin | ceiling fixture %d" % n, "AREA", (c.x, c.y, lo.z - 0.02), LAMPS["ceiling"], COOL, size=(max(0.1, hi.x - lo.x), max(0.1, hi.y - lo.y)), rot=down) rep["ceiling_fixtures_lit"] = n for c, lo, hi in _merged(_islands(src_by["SM_CQ_Bunk_Interior"], "M_CQ_LightWarm", m)): _lamp(lights, root, "Captain's cabin | bunk cove", "AREA", (c.x, c.y + 0.10, lo.z - 0.03), LAMPS["cove"], WARM, size=(max(0.1, hi.x - lo.x), 0.06), rot=down, shadow=False) for c, lo, hi in _merged(_islands(src_by["SM_CQ_Wall_Bunk"], "M_CQ_LightWarm", m)): _lamp(lights, root, "Captain's cabin | work station strip", "AREA", (c.x, c.y + 0.10, lo.z - 0.03), LAMPS["nook"], WARM, size=(max(0.1, hi.x - lo.x), 0.05), rot=down, shadow=False) win = _merged(_islands(src_by["SM_CQ_Wall_Window"], "M_CQ_Glass", m)) if win: c, lo, hi = win[0] # the window is on the cabin's fore end wall: its light falls aft, into the room rot = Matrix.Rotation(math.radians(90.0), 4, "Y") # an area lamp shines down its -Z: turn that to -X _lamp(lights, root, "Captain's cabin | window", "AREA", (lo.x - 0.03, c.y, c.z), LAMPS["window"], DAY, size=(max(0.1, hi.z - lo.z), max(0.1, hi.y - lo.y)), rot=rot) rep["lamps"] = len(lights.objects) # markers for name, role, src_name, dz in (("Captain's bed", "bed", "SM_CQ_Mattress", 0.0), ("Captain's desk", "desk", "SM_CQ_Props", 0.0)): lo, hi = _wbounds([src_by[src_name]], m, model=True) e = quarters.marker(root, name, ((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0, hi.z + dz), role) e["made_by"] = TAG e["zone"] = ROOM # texture paths of the model: relative, so the file can move with its folder fixed = 0 if bpy.data.filepath: for img in bpy.data.images: if img.name.startswith("T_CQ_") and img.filepath and not img.filepath.startswith("//") and img.packed_file is None: try: img.filepath = bpy.path.relpath(img.filepath) fixed += 1 except ValueError: pass rep["texture_paths_made_relative"] = fixed bpy.context.view_layer.update() lo, hi = _wbounds([o for o in cabin_objects() if o.type == "MESH"]) rep["cabin_in_ship"] = {"x": [round(lo.x, 3), round(hi.x, 3)], "y": [round(lo.y, 3), round(hi.y, 3)], "z": [round(lo.z, 3), round(hi.z, 3)], "objects": sum(1 for o in cabin_objects() if o.type == "MESH")} return rep def remove(): n = 0 for ob in cabin_objects(): data = ob.data bpy.data.objects.remove(ob) n += 1 if isinstance(data, bpy.types.Light) and data.users == 0: bpy.data.lights.remove(data) return n def check(): """For check_interior.py: is the cabin in the room, how far inside the hull is everything of it, does it stand on the door's axis, and does anything of it cross the corridor's door, its sill or the corridor's wall.""" from mathutils.bvhtree import BVHTree out = {"present": exists()} if not out["present"]: return out shell = quarters.Shell() obs = [o for o in cabin_objects() if o.type == "MESH"] worst, per = (1e9, None), {} n = 0 for o in obs: mw = o.matrix_world d = 1e9 for v in o.data.vertices: d = min(d, shell.depth(mw @ v.co)) n += len(o.data.vertices) per[o.name] = round(d, 3) if d < worst[0]: worst = (d, o.name) out["mesh_objects"] = len(obs) out["vertices_measured"] = n out["least_depth_inside_the_hull_m"] = [round(worst[0], 4), worst[1]] out["parts_closer_than_10_cm_to_the_hull"] = {k: v for k, v in per.items() if v < 0.10} lo, hi = _wbounds(obs) out["in_ship"] = {"x": [round(lo.x, 3), round(hi.x, 3)], "y": [round(lo.y, 3), round(hi.y, 3)], "z": [round(lo.z, 3), round(hi.z, 3)]} leaf = next((o for o in obs if o.data.name == LEAF), None) if leaf is not None: llo, lhi = _wbounds([leaf]) out["door"] = {"axis_x": round((llo.x + lhi.x) / 2.0, 3), "corridor_door_axis_x": DOOR_X, "leaf_y": [round(llo.y, 3), round(lhi.y, 3)], "clear_of_the_corridor_door_frame_m": round(-FRAME_FACE - lhi.y, 4), "floor_below_the_sill_m": round(SILL - llo.z, 3)} def tree(o): me = o.data me.calc_loop_triangles() mw = o.matrix_world return BVHTree.FromPolygons([tuple(mw @ v.co) for v in me.vertices], [tuple(t.vertices) for t in me.loop_triangles], epsilon=0.0) near = [] for c in (DECK,): for o in bpy.data.collections[c].objects: if o.type != "MESH" or o.data.name.startswith("DECAL"): continue cs = [o.matrix_world @ Vector(b) for b in o.bound_box] if max(p.x for p in cs) < lo.x - 0.1 or min(p.x for p in cs) > hi.x + 0.1 or min(p.y for p in cs) > -2.0: continue near.append(o) crossing = [] trees = {o.name: tree(o) for o in obs} for o in near: t = tree(o) for name, ct in trees.items(): k = len(t.overlap(ct)) if k: crossing.append([o.name, o.data.name, name, k]) out["corridor_pieces_near_the_cabin"] = len(near) out["cabin_parts_that_cross_a_corridor_piece"] = crossing lights = bpy.data.collections.get(ROOM + " | lights") out["lamps"] = len(lights.objects) if lights else 0 return out