""" The captain's quarters with real surfaces: texture sets instead of flat colours. The user, 2026-10-13, on the pictures of the room (black panels in one flat colour with a faint generated roughness map, generated quilt and fabric normals, a camouflage print on the blanket): "do it more realistic with good textures" What the room's materials are made of now (the 13 materials M_CQ_* that the cabin model and the bays made round it share; they are this file's own copies, the model's folder is not touched): from the user's two packs, already on disk in models/vessel-interior/textures (1K, read in place): ScratchedMetal (diff, roughness, normal) painted panels, satin and matte: fine scratches and rubbed patches in the sheen StainedSteel (diff, roughness) bare steel (chair bases, table feet, tie-downs): mottled stains BrushedMetal (diff, roughness, normal) gunmetal fittings: brushed streaks FloorPanel_A (diff, roughness, normal) floor plates: a dimpled anti-slip plate Glass_DirtRough a large smudge and dust mask over all of them, and on the glass scanned, from Poly Haven (polyhaven.com, licence CC0), downloaded with the user's yes into models/vessel-interior/source_textures (2K JPG: diffuse, normal, roughness): leather_white the padded walls and bench backs (tinted black; its grain and creases stay) denim_fabric_05 seat cushions and the mattress (a dark twill) poly_wool_herringbone the blanket (grey wool in place of the camouflage print) cotton_jersey the pillow The colours stay those of the reference picture (a black cabin); what changes is how the surfaces answer light. Panels, metal and floor use the meshes' own UVs, which are metres (box-projected). Leather and cloth are laid on by position (object coordinates, box projection), because the quilt's UVs count its cushions, not metres. import captains_look captains_look.apply() # builds the node trees; the flat versions stay in the file as M_CQ_*__flat captains_look.revert() # the flat versions back captains_look.check() For Unreal: TABLE below is what each material needs (texture set, size of one repeat in metres, tint, roughness range). The pack's textures are in the project already (Content/ModSciInteriors/Textures/...); the four scanned sets are not. """ import os import bpy HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) # models/vessel-interior PACK = os.path.join(ROOT, "textures", "ModSciInteriors", "Textures") ENGI = os.path.join(ROOT, "textures", "ModSci_Engineer", "Textures") SCAN = os.path.join(ROOT, "source_textures") FLAT = "__flat" ROOM = "10.5 | Command deck | Captain's quarters" SCR = ("Base/ScratchedMetal/T_ScratchedMetal_Diff.png", "Base/ScratchedMetal/T_ScratchedMetal_Rough.png", "Base/ScratchedMetal/T_ScratchedMetal_Normal.png") STN = ("Base/StainedSteel/T_StainSteeled_Diff.png", "Base/StainedSteel/T_StainSteeled_Rough.png", None) BRU = ("Base/BrushedMetal/T_BrushedMetal_Diff.png", "Base/BrushedMetal/T_BrushedMetal_Rough.png", "Base/BrushedMetal/T_BrushedMetal_Normal.png") DIRT = "Base/Glass/T_Glass_DirtRough.png" SCRATCH_N = "Base/Glass/T_Scratch_Norm.png" FLOOR = ("FloorPanel_A/T_FloorPanel_A_diff.png", "FloorPanel_A/T_FloorPanel_A_Rough.png", "FloorPanel_A/T_FloorPanel_A_normals.png") # material -> recipe. "tile": metres one repeat of the set covers. "shade": the set's diffuse map only shades the # colour, between these two factors. "rough": what the roughness map's black and white become. "dirt": how much # the smudge mask adds to the roughness and how far it greys the colour. TABLE = { "M_CQ_Satin": {"kind": "hard", "set": SCR, "pack": PACK, "color": (0.020, 0.021, 0.024), "metal": 0.0, "tile": 1.10, "shade": (0.80, 1.10), "rough": (0.54, 0.23), "normal": 0.45, "dirt": (0.035, 0.0), "dirt_tile": 2.7}, "M_CQ_Matte": {"kind": "hard", "set": SCR, "pack": PACK, "color": (0.011, 0.0115, 0.013), "metal": 0.0, "tile": 0.85, "shade": (0.85, 1.08), "rough": (0.76, 0.52), "normal": 0.3, "dirt": (0.03, 0.0), "dirt_tile": 2.1}, "M_CQ_Gunmetal": {"kind": "hard", "set": BRU, "pack": PACK, "color": (0.150, 0.158, 0.172), "metal": 1.0, "tile": 0.45, "shade": (0.60, 1.45), "rough": (0.22, 0.46), "normal": 0.6, "dirt": (0.04, 0.0), "dirt_tile": 1.3, "aniso": 0.35}, "M_CQ_Steel": {"kind": "hard", "set": STN, "pack": PACK, "color": (0.62, 0.63, 0.65), "metal": 1.0, "tile": 0.60, "shade": (0.60, 1.30), "rough": (0.18, 0.48), "normal": 0.5, "normal_map": SCRATCH_N, "dirt": (0.05, 0.0), "dirt_tile": 0.9}, "M_CQ_Floor": {"kind": "hard", "set": FLOOR, "pack": ENGI, "color": (0.026, 0.027, 0.030), "metal": 0.0, "tile": 1.00, "shade": (0.55, 1.45), "rough": (0.24, 0.60), "normal": 1.0, "dirt": (0.07, 0.006), "dirt_tile": 3.3}, "M_CQ_Quilt": {"kind": "soft", "scan": "leather_white", "tint": (0.030, 0.030, 0.032), "tile": 0.42, "rough": (0.40, 0.74), "normal": 1.3, "sheen": 0.08, "keep_normal": "T_CQ_Quilt_N.png"}, "M_CQ_Mattress": {"kind": "soft", "scan": "denim_fabric_05", "tint": (0.070, 0.072, 0.078), "tile": 1.10, "rough": (0.74, 0.98), "normal": 1.6, "sheen": 0.35}, "M_CQ_Sheet": {"kind": "soft", "scan": "poly_wool_herringbone", "tint": (0.125, 0.127, 0.135), "tile": 1.00, "rough": (0.82, 1.0), "normal": 1.6, "sheen": 0.55}, "M_CQ_Pillow": {"kind": "soft", "scan": "cotton_jersey", "tint": (0.34, 0.35, 0.37), "tile": 0.55, "rough": (0.78, 1.0), "normal": 1.2, "sheen": 0.4, "new_for": "SM_CQ_Pillow"}, "M_CQ_Glass": {"kind": "glass", "pack": PACK, "dirt_tile": 1.4, "rough": (0.02, 0.30)}, } def image(path, colorspace="sRGB"): if not os.path.isfile(path): raise RuntimeError("texture missing: " + path) # this room's own image block: the kit's blocks of the same pack files (and their colour spaces) are left alone name = "CQ | " + os.path.basename(path) im = bpy.data.images.get(name) if im is None: im = bpy.data.images.load(path, check_existing=False) im.name = name im.colorspace_settings.name = colorspace if bpy.data.filepath and not im.filepath.startswith("//"): try: im.filepath = bpy.path.relpath(im.filepath) # the file can move with its folder except ValueError: pass return im class Tree: """A material's node tree, written from left to right.""" def __init__(self, mat): self.nt = mat.node_tree self.nt.nodes.clear() self.x = {} self.bsdf = self.node("ShaderNodeBsdfPrincipled", 4) out = self.node("ShaderNodeOutputMaterial", 5) self.link(self.bsdf.outputs["BSDF"], out.inputs["Surface"]) self.coord = self.node("ShaderNodeTexCoord", 0) def node(self, kind, col, label=None): n = self.nt.nodes.new(kind) row = self.x.get(col, 0) n.location = (col * 260 - 900, 300 - row * 290) self.x[col] = row + 1 if label: n.label = label return n def link(self, a, b): self.nt.links.new(a, b) def mapped(self, source, scale, rotate=0.0, label=None): mp = self.node("ShaderNodeMapping", 1, label) mp.inputs["Scale"].default_value = (scale, scale, scale) mp.inputs["Rotation"].default_value = (0.0, 0.0, rotate) self.link(self.coord.outputs[source], mp.inputs["Vector"]) return mp def tex(self, im, mp, box=False, label=None): n = self.node("ShaderNodeTexImage", 2, label) n.image = im if box: n.projection = "BOX" n.projection_blend = 0.25 self.link(mp.outputs["Vector"], n.inputs["Vector"]) return n def ranged(self, value, lo, hi, label=None): mr = self.node("ShaderNodeMapRange", 3, label) mr.inputs["To Min"].default_value = lo mr.inputs["To Max"].default_value = hi self.link(value, mr.inputs["Value"]) return mr.outputs["Result"] def math(self, op, a, b, label=None, clamp=False): m = self.node("ShaderNodeMath", 3, label) m.operation = op m.use_clamp = clamp for i, v in enumerate((a, b)): if isinstance(v, (int, float)): m.inputs[i].default_value = v else: self.link(v, m.inputs[i]) return m.outputs["Value"] def mix(self, kind, fac, a, b, label=None): m = self.node("ShaderNodeMix", 3, label) m.data_type = "RGBA" m.blend_type = kind for sock, v in ((m.inputs[0], fac), (m.inputs[6], a), (m.inputs[7], b)): if isinstance(v, (int, float)): sock.default_value = v elif isinstance(v, tuple): sock.default_value = (*v, 1.0) else: self.link(v, sock) return m.outputs[2] def _hard(mat, r): """Panels, metal, floor: a tiling set from the packs on the mesh's own UVs (metres), and the smudge mask.""" t = Tree(mat) b = t.bsdf.inputs diff, rough, normal = r["set"] mp = t.mapped("UV", 1.0 / r["tile"], label="one repeat = %.2f m" % r["tile"]) md = t.mapped("UV", 1.0 / r["dirt_tile"], rotate=0.61, label="smudges, one repeat = %.1f m" % r["dirt_tile"]) dirt = t.tex(image(os.path.join(PACK, DIRT), "Non-Color"), md, label="smudges and dust") d = t.tex(image(os.path.join(r["pack"], diff)), mp, label="shading") shade = t.ranged(d.outputs["Color"], r["shade"][0], r["shade"][1], "how far the map shades the colour") col = t.mix("MULTIPLY", 1.0, r["color"], shade, "colour x shading") grey = tuple(min(1.0, c + 0.05) for c in r["color"]) if r["metal"] < 0.5 else r["color"] dust = t.math("MULTIPLY", dirt.outputs["Color"], r["dirt"][1] * 10.0, "dust on the colour", clamp=True) col = t.mix("MIX", dust, col, grey, "a little dust") t.link(col, b["Base Color"]) b["Metallic"].default_value = r["metal"] ro = t.tex(image(os.path.join(r["pack"], rough), "Non-Color"), mp, label="roughness") rr = t.ranged(ro.outputs["Color"], r["rough"][0], r["rough"][1], "roughness range") rr = t.math("ADD", rr, t.math("MULTIPLY", dirt.outputs["Color"], r["dirt"][0], "smudges dull the sheen"), "roughness", clamp=True) t.link(rr, b["Roughness"]) nm_file = r.get("normal_map") or normal if nm_file and r["normal"] > 0.0: pack = PACK if r.get("normal_map") else r["pack"] n = t.tex(image(os.path.join(pack, nm_file), "Non-Color"), mp, label="normal") nm = t.node("ShaderNodeNormalMap", 3) nm.inputs["Strength"].default_value = r["normal"] t.link(n.outputs["Color"], nm.inputs["Color"]) t.link(nm.outputs["Normal"], b["Normal"]) if r.get("aniso") and "Anisotropic" in b: b["Anisotropic"].default_value = r["aniso"] mat.use_backface_culling = False def _soft(mat, r): """Leather and cloth: a scanned set laid on by position (object coordinates, box projection).""" t = Tree(mat) b = t.bsdf.inputs base = os.path.join(SCAN, r["scan"], r["scan"]) mp = t.mapped("Object", 1.0 / r["tile"], label="one repeat = %.2f m" % r["tile"]) d = t.tex(image(base + "_diff_2k.jpg"), mp, box=True, label=r["scan"] + ", diffuse") col = t.mix("MULTIPLY", 1.0, d.outputs["Color"], r["tint"], "scan x tint") t.link(col, b["Base Color"]) ro = t.tex(image(base + "_rough_2k.jpg", "Non-Color"), mp, box=True, label="roughness") t.link(t.ranged(ro.outputs["Color"], r["rough"][0], r["rough"][1], "roughness range"), b["Roughness"]) n = t.tex(image(base + "_nor_gl_2k.jpg", "Non-Color"), mp, box=True, label="normal") color = n.outputs["Color"] keep = r.get("keep_normal") and bpy.data.images.get(r["keep_normal"]) if keep: # the model's own stitch-and-crease normal map stays (on the quilt's UVs); the scan's grain is added to it mq = t.mapped("UV", 1.0, label="the model's quilt map") q = t.tex(keep, mq, label="stitches and creases (the model's)") sub = t.node("ShaderNodeVectorMath", 3, "grain only") sub.operation = "SUBTRACT" t.link(n.outputs["Color"], sub.inputs[0]) sub.inputs[1].default_value = (0.5, 0.5, 1.0) add = t.node("ShaderNodeVectorMath", 3, "creases + grain") add.operation = "ADD" t.link(q.outputs["Color"], add.inputs[0]) t.link(sub.outputs["Vector"], add.inputs[1]) color = add.outputs["Vector"] nm = t.node("ShaderNodeNormalMap", 3) nm.inputs["Strength"].default_value = r["normal"] t.link(color, nm.inputs["Color"]) t.link(nm.outputs["Normal"], b["Normal"]) if "Sheen Weight" in b: b["Sheen Weight"].default_value = r["sheen"] if "Sheen Roughness" in b: b["Sheen Roughness"].default_value = 0.45 mat.use_backface_culling = False def _glass(mat, r): """The window's pane: as it was, with smudges in its sheen.""" old = next((n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED"), None) keep = {k: (old.inputs[k].default_value[:] if hasattr(old.inputs[k].default_value, "__len__") else old.inputs[k].default_value) for k in ("Base Color", "Alpha", "Metallic") if old is not None} t = Tree(mat) b = t.bsdf.inputs for k, v in keep.items(): b[k].default_value = v md = t.mapped("UV", 1.0 / r["dirt_tile"], rotate=0.4, label="smudges") dirt = t.tex(image(os.path.join(PACK, DIRT), "Non-Color"), md, label="smudges and dust") t.link(t.ranged(dirt.outputs["Color"], r["rough"][0], r["rough"][1], "roughness range"), b["Roughness"]) try: mat.surface_render_method = "BLENDED" except Exception: # noqa: BLE001 pass mat.use_backface_culling = False BUILD = {"hard": _hard, "soft": _soft, "glass": _glass} def applied(): return any(m.name.endswith(FLAT) for m in bpy.data.materials if m.name.startswith("M_CQ_")) def apply(): """Give the room's materials their texture sets. The flat versions are kept as M_CQ_*__flat. Returns a report.""" if applied(): raise RuntimeError("the room already has its texture sets (captains_look.revert() puts the flat colours back)") rep = {"materials": {}, "images": []} before = set(bpy.data.images) for name, r in TABLE.items(): mat = bpy.data.materials.get(name) if mat is None: if not r.get("new_for"): raise RuntimeError("material missing: " + name) mat = bpy.data.materials.new(name) mat.use_nodes = True else: flat = mat.copy() flat.name = name + FLAT flat.use_fake_user = True BUILD[r["kind"]](mat, r) mat["captains_look"] = "texture sets, build/captains_look.py" rep["materials"][name] = {"nodes": len(mat.node_tree.nodes), "images": sorted({n.image.name for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image})} # the pillow: its own cloth, set on the object (the model's mesh keeps the blanket's material in its slot) n = 0 for name, r in TABLE.items(): part = r.get("new_for") if not part: continue sheet = bpy.data.materials.get("M_CQ_Sheet") for ob in bpy.data.objects: if ob.type == "MESH" and ob.get("model_part") == part and ob.get("made_by"): for slot in ob.material_slots: if slot.material is sheet or (slot.material and slot.material.name == "M_CQ_Sheet"): slot.link = "OBJECT" slot.material = bpy.data.materials[name] n += 1 rep["object_slots_set"] = n rep["images"] = sorted(i.name for i in set(bpy.data.images) - before) return rep def revert(): """The flat colours back (the texture versions are removed).""" n = 0 for ob in bpy.data.objects: if ob.type == "MESH": for slot in ob.material_slots: if slot.link == "OBJECT" and slot.material is not None and TABLE.get(slot.material.name, {}).get("new_for"): slot.material = None slot.link = "DATA" for name, r in TABLE.items(): mat = bpy.data.materials.get(name) flat = bpy.data.materials.get(name + FLAT) if r.get("new_for"): if mat is not None and mat.users == 0: bpy.data.materials.remove(mat) continue if mat is None or flat is None: continue mat.user_remap(flat) bpy.data.materials.remove(mat) flat.name = name flat.use_fake_user = False n += 1 return n def check(): """For check_interior.py: are the texture sets in, does every image node find its file, how big are they.""" out = {"applied": applied(), "materials": {}, "missing_files": [], "images": {}} for name in TABLE: mat = bpy.data.materials.get(name) if mat is None: continue nodes = [n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE"] out["materials"][name] = {"image_nodes": len(nodes), "users": mat.users} for n in nodes: im = n.image if im is None: out["missing_files"].append(name + ": an image node without an image") continue path = bpy.path.abspath(im.filepath) if not os.path.isfile(path): out["missing_files"].append(name + ": " + im.filepath) out["images"][im.name] = {"size": list(im.size), "colorspace": im.colorspace_settings.name, "relative": im.filepath.startswith("//")} room = bpy.data.collections.get(ROOM) if room is not None: used = {} for ob in room.objects: if ob.type == "MESH": for slot in ob.material_slots: if slot.material is not None: used[slot.material.name] = used.get(slot.material.name, 0) + 1 out["materials_in_the_room"] = used return out