"""Surfaces for the Grouper, the Tiger's / Mako's / Spearfish's method (models/tender-tiger/build/tg_surfaces.py: UV layers, baked masks and bevel normals, node dressing from the user's own pack sets), with what the user's concept render asks for: the look is the CONCEPT's (dark gunmetal satin metal, darker brushed band, near-black frames, clear acrylic, cyan light strips in the grooves, cyan circuit traces, warm lamps, a blue faceted scanner lens, a dark cabin with cyan screens) and every glow has its own emissive map so Unreal can drive the brightness. Run on Tender_Grouper.blend after the build (a rebuild replaces the meshes, their UVs and the decal mesh): python build/gr_decals_draw.py (the drawn decal sheet, trace mask; plain Python) blender -b Tender_Grouper.blend --python build/gr_surfaces.py (saves the file) * Decals (mesh decals, the game way): SM_Grouper_Decals, copies of the hull's outer skin faces under each marking lifted 1.5 mm, UV0 into textures/T_Grouper_Decals_BaseColor.png (alpha = opacity mask) and _Emissive.png, the hull's shading normals. Placed where the sheet draws them (SIDE px boxes in gr_measure.py MARKINGS, both flanks, each reading from its own side; the small insignia on the upper stern where BACK points), projected the way the views see them (the flanks along X, the stern along its surface normal): lettering "Subtub 1", the generic striped insignia, the generic seal with its cyan circuit emblem, one small UI panel on the band forward of each stern fin. * Hard surfaces take tiling sets from the user's Season 2 packs read in place from models/vessel-interior/textures: ScratchedMetal under the gunmetal, BrushedMetal on the waist band, StainedSteel for the dark frames and bare steel, FloorPanel_A on the deck, the Glass set's dirt map on the acrylic, T_Scratch_Norm on steel; the seats take the CC0 leather scan of the captain's quarters (source_textures/leather_white). Nothing downloaded. * Baked per atlas in Cycles: Masks (R occlusion, G edges, B crevices and the seam lines from the hull's seam_chine / seam_belly fields), BevelNormal (hard edges rounded 12 mm, seams as grooves); exterior also Emissive (sRGB): the light strips (the groove floors, the collar's and duct's rings), the circuit traces (the sheet's own lines in the Designer's CIRCUIT_ZONES, projected onto each flank), the duct's inner glow (fading to the lip), the lamps (warm white) and the scanner lens (blue crystal cells, dark facet borders). Exterior atlas 4096 px, cabin atlas 2048 px. * Emission strength = map x per-material strength x Grouper_Root["lights"] (simple-expression drivers on a Value node in each glowing material; 0 = off, 1 = on). Unreal: one scalar parameter "Lights" does the same. Two UV layers on every mesh (the order matters for Unreal, which builds its tangents from the first): UVMap (UV0) a unique unwrap in the part's square of its atlas (the baked maps); the screens map onto T_Grouper_Screens; the decal mesh maps into the decal sheet. UV_Tile (UV1) metres (the builder's own where it wrote them, box-projected elsewhere) for the tiling sets. """ import bpy, bmesh, math, os, time, json import numpy as np from mathutils import Vector, Matrix from mathutils.bvhtree import BVHTree OUT = os.path.dirname(bpy.data.filepath) HERE = os.path.join(OUT, "build") TEX = os.path.join(OUT, "textures") os.makedirs(TEX, exist_ok=True) G = {} exec(open(os.path.join(HERE, "gr_measure.py"), encoding="utf-8").read(), G) S, X0, Z0 = G["S"], G["X0"], G["Z0"] DEC = json.load(open(os.path.join(HERE, "decals.json"))) VI = os.path.normpath(os.path.join(OUT, "..", "vessel-interior")) PACK = os.path.join(VI, "textures", "ModSci_Engineer", "Textures") 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") FLOOR = ("FloorPanel_A/T_FloorPanel_A_diff.png", "FloorPanel_A/T_FloorPanel_A_Rough.png", "FloorPanel_A/T_FloorPanel_A_normals.png") LEATHER = tuple(os.path.join(VI, "source_textures", "leather_white", f) for f in ("leather_white_diff_2k.jpg", "leather_white_rough_2k.jpg", "leather_white_nor_gl_2k.jpg")) DIRT = "Base/Glass/T_Glass_DirtRough.png" SCRATCH_N = "Base/Glass/T_Scratch_Norm.png" REPORT = {} t0 = time.time() D = bpy.data O = D.objects root = O["Grouper_Root"] for _im in list(D.images): if _im.name.startswith("T_Grouper_") and _im.name not in ("T_Grouper_Decals_BaseColor.png", "T_Grouper_Decals_Emissive.png"): D.images.remove(_im) for _f in os.listdir(TEX): if _f.startswith("T_Grouper_") and _f.count(".") > 1: os.remove(os.path.join(TEX, _f)) PARTS_EXT = ["SM_Grouper_Hull", "SM_Grouper_Glass", "SM_Grouper_Hatch", "SM_Grouper_Door_L", "SM_Grouper_Door_R", "SM_Grouper_Rotor", "SM_Grouper_Antenna"] # critique round 1 (m11): the hull has the whole 4096 exterior atlas (its skin as arc-length islands written by the build, # UV_Grid); the small moving parts and the glass share a 2048 "Parts" atlas; the cabin its own 2048. A material used on two # atlases gets a copy per atlas (M_Grouper_Part..., M_Grouper_Cabin...), the same look, its own baked maps. ATLASES = { "Exterior": dict(size=4096, rects={"SM_Grouper_Hull": (0.0, 0.0, 1.0)}), "Parts": dict(size=2048, rects={"SM_Grouper_Door_L": (0.0, 0.0, 0.5), "SM_Grouper_Door_R": (0.5, 0.0, 0.5), "SM_Grouper_Hatch": (0.0, 0.5, 0.5), "SM_Grouper_Glass": (0.5, 0.5, 0.25), "SM_Grouper_Rotor": (0.75, 0.5, 0.25), "SM_Grouper_Antenna": (0.5, 0.75, 0.25)}), "Cabin": dict(size=2048, rects={"SM_Grouper_Cabin": (0.0, 0.0, 1.0)}), } ATLAS_PREFIX = {"Parts": "Part", "Cabin": "Cabin"} STRIP_SOFT = 0.0025 # (round 2, m8) the lines' soft edge: +-2.5 mm STRIP_HW = 0.014 # the light strips: a 28 mm line in the middle of each groove floor (critique round 1 M5) BEVEL_R, EDGE_R = 0.012, 0.020 SEAM_W0, SEAM_W1, SEAM_DEPTH = 0.0100, 0.0035, 0.0040 CYAN = (0.0, 0.85, 1.0) WARM = (1.0, 0.95, 0.85) LENS = (0.08, 0.45, 1.0) _P = dict(dirt=0.03, dirt_tile=2.4) SURF = { # dark SATIN gunmetal (critique round 1 m1: it rendered mid-grey and glossy, clear coat 0.30 at roughness 0.08) "M_Grouper_Gunmetal": dict(set=SCR, tint=(0.078, 0.084, 0.092), metal=0.62, tile=1.20, shade=(0.92, 1.06), rough=(0.46, 0.38), normal=0.05, grime=0.16, wear=0.14, wear_col=(0.36, 0.37, 0.39), wear_rough=0.32, coat=0.06, coat_rough=0.32, coat_dirt=0.05, **_P), "M_Grouper_Band": dict(set=BRU, tint=(0.055, 0.06, 0.066), metal=0.50, tile=0.90, shade=(0.90, 1.08), rough=(0.58, 0.46), normal=0.15, grime=0.20, wear=0.12, wear_col=(0.36, 0.37, 0.39), wear_rough=0.32, **_P), # the StainedSteel set tiled 2.3x finer and its contrast nearly flattened (critique round 1 m2: it read as camouflage) "M_Grouper_FrameDark": dict(set=(STN[0], STN[1], SCR[2]), tint=(0.045, 0.05, 0.055), metal=0.90, tile=0.30, shade=(0.97, 1.03), rough=(0.38, 0.28), normal=0.08, grime=0.14, wear=0.16, wear_col=(0.33, 0.34, 0.35), wear_rough=0.30, coat=0.15, coat_rough=0.10, coat_dirt=0.05, **_P), "M_Grouper_Steel": dict(set=STN, tint=(0.55, 0.56, 0.58), metal=1.0, tile=0.60, shade=(0.93, 1.05), rough=(0.24, 0.32), normal=0.50, normal_map=SCRATCH_N, dirt=0.05, dirt_tile=0.9, grime=0.20, wear=0.0), "M_Grouper_Recess": dict(set=(None, SCR[1], None), tint=(0.012, 0.013, 0.015), metal=0.0, tile=0.40, shade=(1.0, 1.0), rough=(0.66, 0.55), normal=0.0, dirt=0.03, dirt_tile=0.6, grime=0.0, wear=0.0), # the groove floors: dark satin, only their middle line glows (the emissive map) "M_Grouper_Strip": dict(set=(None, SCR[1], None), tint=(0.03, 0.04, 0.045), metal=0.5, tile=0.40, shade=(1.0, 1.0), rough=(0.42, 0.34), normal=0.0, dirt=0.03, dirt_tile=0.6, grime=0.0, wear=0.0, coat=0.5, coat_rough=0.10, coat_dirt=0.03), "M_Grouper_DuctGlow": dict(set=STN, tint=(0.05, 0.06, 0.07), metal=0.6, tile=0.50, shade=(0.90, 1.08), rough=(0.45, 0.35), normal=0.0, dirt=0.03, dirt_tile=0.8, grime=0.20, wear=0.0), "M_Grouper_LampWhite": dict(set=(None, SCR[1], None), tint=(0.85, 0.85, 0.83), metal=0.0, tile=0.30, shade=(1.0, 1.0), rough=(0.26, 0.20), normal=0.0, dirt=0.04, dirt_tile=0.5, grime=0.0, wear=0.0, coat=1.0, coat_rough=0.02, coat_dirt=0.04), "M_Grouper_ScannerLens": dict(set=(None, SCR[1], None), tint=(0.10, 0.35, 0.75), metal=0.0, tile=0.30, shade=(1.0, 1.0), rough=(0.08, 0.05), normal=0.0, dirt=0.03, dirt_tile=0.5, grime=0.0, wear=0.0, coat=1.0, coat_rough=0.02, coat_dirt=0.03), "M_Grouper_Cabin": dict(set=SCR, tint=(0.12, 0.12, 0.13), metal=0.2, tile=0.90, shade=(0.90, 1.06), rough=(0.62, 0.45), normal=0.25, dirt=0.04, dirt_tile=2.0, grime=0.25, wear=0.12, wear_col=(0.40, 0.41, 0.42), wear_rough=0.40), "M_Grouper_CabinFloor": dict(set=FLOOR, tint=(0.08, 0.085, 0.09), metal=0.6, tile=1.00, shade=(0.80, 1.10), rough=(0.55, 0.35), normal=0.80, dirt=0.07, dirt_tile=1.5, grime=0.30, wear=0.0), "M_Grouper_CabinDark": dict(set=(STN[0], STN[1], SCR[2]), tint=(0.035, 0.04, 0.045), metal=0.9, tile=0.70, shade=(0.88, 1.10), rough=(0.40, 0.30), normal=0.08, dirt=0.04, dirt_tile=1.5, grime=0.20, wear=0.15, wear_col=(0.33, 0.34, 0.35), wear_rough=0.32), "M_Grouper_Seat": dict(set=LEATHER, tint=(0.08, 0.08, 0.085), metal=0.0, tile=0.55, shade=(0.80, 1.15), rough=(0.62, 0.40), normal=0.6, dirt=0.04, dirt_tile=1.2, grime=0.25, wear=0.0, abs_paths=True), } # emission strengths (x the emissive map x Grouper_Root["lights"]) GLOW_K = {"M_Grouper_Gunmetal": 2.5, "M_Grouper_Band": 2.5, "M_Grouper_FrameDark": 2.5, "M_Grouper_Strip": 3.0, "M_Grouper_DuctGlow": 1.5, "M_Grouper_LampWhite": 8.0, "M_Grouper_ScannerLens": 3.0} # critique round 2 (M3): the screens at 1.8 over a sheet whose brightest ink is 0.52 (peak 0.94: no channel clips in a Standard # render, the UI reads); the cabin's ceiling glow raised so the seats read through the windows SCREEN_K, DECAL_K, CABGLOW_K = 1.8, 2.5, 9.0 SCREEN_PEAK = 0.52 SCREEN_INK = (0.35, 0.90, 1.0) SCREEN_ALERT = (0.75, 1.0, 1.0) # cyan-dominant UI (critique round 1 M6): the alerts are a pale cyan-white, no amber def base_of(name): """the original material of an atlas copy (M_Grouper_PartFrameDark -> M_Grouper_FrameDark)""" for pre in ATLAS_PREFIX.values(): if name.startswith("M_Grouper_" + pre): b = "M_Grouper_" + name[len("M_Grouper_" + pre):] if b in SURF or b in GLOW_K: return b return name def refresh(): for ob in O: if ob.animation_data: for fc in ob.animation_data.drivers: fc.driver.expression = fc.driver.expression fc.driver.is_valid = True ob.update_tag() bpy.context.view_layer.update() refresh() # ================================================================== decals (mesh decals over the hull's outer skin) def side_centre(x, y): return ((X0 - x) * S, (Z0 - y) * S) hull = O["SM_Grouper_Hull"] HM = hull.matrix_world.copy() hme = hull.data hv = [HM @ v.co for v in hme.vertices] hpolys = [tuple(p.vertices) for p in hme.polygons] HBVH = BVHTree.FromPolygons(hv, hpolys) HN3 = HM.to_3x3().inverted().transposed() cn = np.empty(len(hme.loops) * 3, np.float32) hme.corner_normals.foreach_get("vector", cn) CN = cn.reshape(-1, 3) def decal_specs(): MK = G["MARKINGS"] specs = [] for side, sx in (("R", -1.0), ("L", 1.0)): # R = starboard (-X), L = port (+X) Dv = Vector((sx, 0.0, 0.0)) U = Vector((0.0, 0.0, 1.0)) for name, tile, (x, y), size in ( ("lettering", "lettering", ((MK["text_box"][0] + MK["text_box"][2]) / 2, (MK["text_box"][1] + MK["text_box"][3]) / 2), ((MK["text_box"][2] - MK["text_box"][0]) * S, (MK["text_box"][3] - MK["text_box"][1]) * S)), ("insignia", "insignia", ((MK["insignia_box"][0] + MK["insignia_box"][2]) / 2, (MK["insignia_box"][1] + MK["insignia_box"][3]) / 2), ((MK["insignia_box"][2] - MK["insignia_box"][0]) * S, (MK["insignia_box"][3] - MK["insignia_box"][1]) * S)), ("seal", "seal", MK["seal_c"], (2 * MK["seal_r"] * S, 2 * MK["seal_r"] * S)), ("ui_panel", "ui_panel", ((DEC["ui_box_side_px"][0] + DEC["ui_box_side_px"][2]) / 2, (DEC["ui_box_side_px"][1] + DEC["ui_box_side_px"][3]) / 2), ((DEC["ui_box_side_px"][2] - DEC["ui_box_side_px"][0]) * S, (DEC["ui_box_side_px"][3] - DEC["ui_box_side_px"][1]) * S))): Y, Z = side_centre(x, y) hit = HBVH.ray_cast(Vector((sx * 3.0, Y, Z)), -Dv) specs.append(dict(name="%s_%s" % (name, side), tile=tile, C=hit[0], D=Dv, U=U, size=size, sheet_px=[x, y])) SI = G["STERN_INSIGNIA"] x, y = (SI["x"][0] + SI["x"][1]) / 2, (SI["y"][0] + SI["y"][1]) / 2 Y, Z = side_centre(x, y) X = SI["lateral_px"] * S hit = HBVH.ray_cast(Vector((X, Y, 3.0)), Vector((0, 0, -1))) n = (HN3 @ hme.polygons[hit[2]].normal).normalized() U = (Vector((0, 0, 1)) - n * n.z).normalized() specs.append(dict(name="insignia_stern", tile="insignia_small", C=hit[0], D=n, U=U, size=((SI["x"][1] - SI["x"][0]) * S, (SI["y"][1] - SI["y"][0]) * S), sheet_px=[x, y, SI["lateral_px"]])) return specs def build_decals(): old = O.get("SM_Grouper_Decals") if old: me_old = old.data O.remove(old) if me_old.users == 0: D.meshes.remove(me_old) mat = D.materials.get("M_Grouper_Marking") or D.materials.new("M_Grouper_Marking") for n in ("M_Grouper_Trace",): # the traces are the emissive map's, not a material of their own if D.materials.get(n) and D.materials[n].users == 0: D.materials.remove(D.materials[n]) V, F, UV, NR, info = [], [], [], [], {} # (round 3) each marking is its own regular grid of quads over the marking's rectangle (cells of 25 mm at most), every # vertex cast onto the hull along the marking's normal and lifted 1.5 mm along the hull's shading normal there; its UVs map # the rectangle into the marking's tile. Round 2 copied the hull's faces under each rectangle: a staircase outline, and # quads split on their ridge diagonal; the denser round 3 grid would have made it a finer staircase still. from mathutils.interpolate import poly_3d_calc placed = [] n_quads = 0 for sp in decal_specs(): C, Dv, U = sp["C"], sp["D"], sp["U"] if not G.get("FLAGS", True) and sp["tile"] in ("insignia", "insignia_small"): # the user, 2026-10-22: "remove those flags": no patch; the place stays in the report (concept camera landmark) info[sp["name"]] = dict(faces=0, removed="the user, 2026-10-22: 'remove those flags'", centre=[round(c, 3) for c in C], normal=[round(c, 3) for c in Dv], sheet_px=sp["sheet_px"]) continue R = (-Dv).cross(U).normalized() w, h = sp["size"] tl = DEC["tiles"][sp["tile"]] tw, th = tl["world_m"] u0, v0, u1, v1 = tl["uv"] side = sp["name"].rsplit("_", 1)[-1] lift = 0.0015 # markings that overlap stand at 1.5, 3.0 ... mm while any(sd == side and abs(lf - lift) < 1e-6 and (C - c2).length < 0.5 * (max(w, h) + m2) for sd, lf, c2, m2 in placed): lift += 0.0015 placed.append((side, lift, C.copy(), max(w, h))) nu, nv = max(2, int(math.ceil(w / 0.025))), max(2, int(math.ceil(h / 0.025))) G_ = [[None] * (nv + 1) for _ in range(nu + 1)] miss = 0 for i in range(nu + 1): for j in range(nv + 1): uu, vv = (i / nu - 0.5) * w, (j / nv - 0.5) * h P = C + R * uu + U * vv hit = HBVH.ray_cast(P + Dv * 0.3, -Dv, 0.6) if hit[0] is None: miss += 1 continue poly = hme.polygons[hit[2]] cos = [HM @ hme.vertices[vi].co for vi in poly.vertices] wts = poly_3d_calc(cos, hit[0]) nn = Vector() for wk, li in zip(wts, range(poly.loop_start, poly.loop_start + poly.loop_total)): nn += (HN3 @ Vector(CN[li])) * wk nn = nn.normalized() if nn.length > 1e-9 else Vector(hit[1]) G_[i][j] = (len(V), uu, vv, tuple(nn)) V.append(hit[0] + nn * lift) # where the rectangle reaches a step of the hull (a groove's edge, the band's), the rays land on another level: a # quadric fitted to the hits' depths along the normal marks them (more than 3 mm off it), and the quads touching them # are left out (the marking's pad is transparent there; round 2's face copies followed the steps instead) pts_ = [(i, j, c_) for i in range(nu + 1) for j in range(nv + 1) for c_ in [G_[i][j]] if c_ is not None] off = set() if len(pts_) >= 12: A_ = np.array([[1.0, c_[1], c_[2], c_[1] ** 2, c_[1] * c_[2], c_[2] ** 2] for i, j, c_ in pts_]) d_ = np.array([(V[c_[0]] - C).dot(Dv) for i, j, c_ in pts_]) keep_ = np.ones(len(d_), bool) for _it in range(3): coef, *_r = np.linalg.lstsq(A_[keep_], d_[keep_], rcond=None) res_ = np.abs(A_ @ coef - d_) keep_ = res_ < 0.003 off = {(i, j) for (i, j, c_), r_ in zip(pts_, res_) if r_ >= 0.003} nf = 0 dropped = 0 for i in range(nu): for j in range(nv): q = (G_[i][j], G_[i + 1][j], G_[i + 1][j + 1], G_[i][j + 1]) if any(c_ is None for c_ in q): continue if {(i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)} & off: dropped += 1 continue F.append([c_[0] for c_ in q]) UV.append([(u0 + (c_[1] / tw + 0.5) * (u1 - u0), v0 + (c_[2] / th + 0.5) * (v1 - v0)) for c_ in q]) NR.append([c_[3] for c_ in q]) nf += 1 n_quads += nf info[sp["name"]] = dict(faces=nf, grid=[nu, nv], rays_missed=miss, quads_left_out_at_steps=dropped, lift_mm=round(lift * 1000, 1), centre=[round(c, 3) for c in C], normal=[round(c, 3) for c in Dv], size_m=[round(w, 3), round(h, 3)], sheet_px=sp["sheet_px"], reach_in_tile=[round((w / 2) / (tw / 2), 3), round((h / 2) / (th / 2), 3)]) assert w <= tw + 1e-6 and h <= th + 1e-6, ("a marking larger than its tile", sp["name"], w, h, tw, th) info["_quads"] = n_quads me = D.meshes.new("SM_Grouper_Decals") me.from_pydata([tuple(v) for v in V], [], F) me.materials.append(mat) uvl = me.uv_layers.new(name="UVMap") tile = me.uv_layers.new(name="UV_Tile") li = 0 flat_n = [] for p, uvf, nrf in zip(me.polygons, UV, NR): for j in range(p.loop_total): uvl.data[p.loop_start + j].uv = uvf[j] co = V[me.loops[p.loop_start + j].vertex_index] tile.data[p.loop_start + j].uv = (co.y, co.z) flat_n.append(nrf[j]) me.update() for p_ in me.polygons: p_.use_smooth = True me.normals_split_custom_set(flat_n) ob = D.objects.new("SM_Grouper_Decals", me) for c in hull.users_collection: c.objects.link(ob) ob.parent = root ob.matrix_parent_inverse = root.matrix_world.inverted() ob.visible_shadow = False return ob, info decals, REPORT["decals"] = build_decals() print("DECALS", json.dumps(REPORT["decals"]), flush=True) # ================================================================== UV layers (the Tiger's) def tile_uv(co, n): ax = 0 if abs(n.x) >= abs(n.y) and abs(n.x) >= abs(n.z) else (1 if abs(n.y) >= abs(n.z) else 2) return (co.y, co.z) if ax == 0 else ((co.x, co.z) if ax == 1 else (co.x, co.y)) def uv_layers(o, rect, size): me = o.data nl = len(me.loops) tile = np.zeros(nl * 2, np.float32) if "UV_Tile" in me.uv_layers: me.uv_layers["UV_Tile"].data.foreach_get("uv", tile) grid = gflag = None # the hull skin's arc-length chart from the build (critique round 1 m11) if "UV_Grid" in me.uv_layers and me.attributes.get("uv_grid") is not None: grid = np.zeros(nl * 2, np.float32) me.uv_layers["UV_Grid"].data.foreach_get("uv", grid) grid = grid.reshape(-1, 2) gflag = np.array([d.value for d in me.attributes["uv_grid"].data], bool) while len(me.uv_layers): me.uv_layers.remove(me.uv_layers[0]) me.uv_layers.new(name="UVMap") me.uv_layers.new(name="UV_Tile") me.uv_layers["UV_Tile"].data.foreach_set("uv", tile) me.uv_layers.active = me.uv_layers["UVMap"] for o2 in bpy.context.view_layer.objects: o2.select_set(False) o.hide_set(False) o.select_set(True) bpy.context.view_layer.objects.active = o bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') x0, y0, s = rect bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=6.0 / (s * size), area_weight=0.0, correct_aspect=True, scale_to_bounds=False) if grid is not None: # the skin's faces take the build's chart (one big, nearly isometric island) bpy.ops.object.mode_set(mode='OBJECT') uvw = np.zeros(nl * 2, np.float32) me.uv_layers["UVMap"].data.foreach_get("uv", uvw) uvw = uvw.reshape(-1, 2) for p in me.polygons: if gflag[p.index]: uvw[p.loop_start:p.loop_start + p.loop_total] = grid[p.loop_start:p.loop_start + p.loop_total] me.uv_layers["UVMap"].data.foreach_set("uv", uvw.ravel()) bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') try: bpy.ops.uv.select_all(action='SELECT') bpy.ops.uv.average_islands_scale() bpy.ops.uv.pack_islands(rotate=True, rotate_method='ANY', scale=True, margin_method='FRACTION', margin=4.5 / (s * size), shape_method='CONCAVE') # 4.5 px between islands, bakes spill 2 px except Exception as ex: print("pack skipped", o.name, ex) bpy.ops.object.mode_set(mode='OBJECT') me = o.data if grid is not None: # kept as a third layer for a rerun of this pass (gr_export drops it) lg = me.uv_layers.new(name="UV_Grid") lg.data.foreach_set("uv", grid.ravel()) me.uv_layers.active = me.uv_layers["UVMap"] me = o.data uv = np.zeros(nl * 2, np.float32) me.uv_layers["UVMap"].data.foreach_get("uv", uv) uv = uv.reshape(-1, 2) pad = 4.0 / (s * size) uv = np.array([x0, y0], np.float32) + (pad + uv * (1 - 2 * pad)) * s me.uv_layers["UVMap"].data.foreach_set("uv", uv.ravel()) tile = tile.reshape(-1, 2) vco = [v.co.copy() for v in me.vertices] lv = [l.vertex_index for l in me.loops] for p in me.polygons: ls, lt = p.loop_start, p.loop_total if np.any(tile[ls:ls + lt] != 0.0): continue for li in range(ls, ls + lt): tile[li] = tile_uv(vco[lv[li]], p.normal) me.uv_layers["UV_Tile"].data.foreach_set("uv", tile.ravel()) me.uv_layers["UVMap"].active_render = True a3 = sum(p.area for p in me.polygons) a2 = 0.0 for p in me.polygons: q = uv[p.loop_start:p.loop_start + p.loop_total] a2 += 0.5 * abs(float(np.sum(q[:, 0] * np.roll(q[:, 1], -1) - np.roll(q[:, 0], -1) * q[:, 1]))) return dict(atlas_px_per_m=round(math.sqrt(a2 * size ** 2 / a3), 1), fill=round(a2 / (s * s), 3)) # ================================================================== the screens: a drawn instrument sheet and its UVs def make_screens(path, n=512): """three panels side by side (sonar, heading and depth, systems), cyan on a lit cyan-blue ground, pale alerts; own drawing""" Wd = 3 * n yy, xx = np.mgrid[0:n, 0:Wd].astype(np.float32) img = np.zeros((n, Wd, 3), np.float32) # a lit cyan-blue ground (critique round 1 M6: the screens must read as glowing panels through the windows), a soft # vignette, filled header bars and blocks vy, vx = np.mgrid[0:n, 0:Wd].astype(np.float32) lx = (vx % n) / n vig = 1.0 - 0.35 * (((lx - 0.5) * 2) ** 2 + ((vy / n - 0.5) * 2) ** 2) img[:] = np.array((0.024, 0.15, 0.21), np.float32) # (round 2: brighter; the whole sheet is scaled below) img *= vig[..., None] cyan = np.array(SCREEN_INK, np.float32) hdr = (vy / n < 0.07) | (vy / n > 0.93) img[hdr] = np.array((0.08, 0.45, 0.58), np.float32) def put(mask, col, a=1.0): img[mask] = img[mask] * (1 - a) + col * a u = xx / n v = yy / n cx, cy = 0.5, 0.5 r = np.hypot(u - cx, v - cy) for rr in (0.12, 0.24, 0.36, 0.46): put((np.abs(r - rr) < 0.004) & (u < 1), cyan * 0.55) ang = np.arctan2(v - cy, u - cx) sweep = ((ang - 0.6) % (2 * math.pi)) put((r < 0.46) & (sweep < 0.9) & (u < 1), cyan * 0.30, 0.35) put((np.abs(u - cx) < 0.002) & (r < 0.46), cyan * 0.4) put((np.abs(v - cy) < 0.002) & (r < 0.46) & (u < 1), cyan * 0.4) for bx, by, br in ((0.62, 0.36, 0.014), (0.41, 0.70, 0.010), (0.70, 0.62, 0.012)): put(np.hypot(u - bx, v - by) < br, np.array(SCREEN_ALERT)) m2 = (u >= 1) & (u < 2) for k in range(-6, 7): x = 1.5 + k * 0.06 put(m2 & (np.abs(u - x) < 0.003) & (v > 0.12) & (v < (0.20 if k % 2 else 0.24)), cyan * 0.8) put(m2 & (np.abs(u - 1.5) < 0.006) & (v > 0.07) & (v < 0.27), np.array(SCREEN_ALERT)) for k in range(9): y = 0.36 + k * 0.065 put(m2 & (np.abs(v - y) < 0.003) & (u > 1.15) & (u < (1.30 if k % 2 else 1.36)), cyan * 0.7) put(m2 & (u > 1.42) & (u < 1.86) & (v > 0.40) & (v < 0.86) & ((np.abs(u - 1.64) < 0.002) | (np.abs(v - 0.63) < 0.002)), cyan * 0.4) put(m2 & (np.abs(np.hypot(u - 1.64, v - 0.63) - 0.18) < 0.004), cyan * 0.7) m3 = u >= 2 for k in range(8): x0 = 2.10 + k * 0.10 fill = (0.85, 0.62, 0.40, 0.95, 0.20, 0.55, 0.70, 0.10)[k] frame = m3 & (u > x0) & (u < x0 + 0.07) & (v > 0.15) & (v < 0.85) inner = m3 & (u > x0 + 0.008) & (u < x0 + 0.062) & (v > 0.85 - 0.692 * fill) & (v < 0.842) put(frame & ~(m3 & (u > x0 + 0.004) & (u < x0 + 0.066) & (v > 0.154) & (v < 0.846)), cyan * 0.6) put(inner, (np.array(SCREEN_ALERT) if fill > 0.9 else cyan) * 0.75) for k_ in range(3): # filled status blocks along each panel's bottom for q_ in range(4): x0_, x1_ = k_ + 0.08 + q_ * 0.22, k_ + 0.08 + q_ * 0.22 + 0.16 img[((vx / n) > x0_) & ((vx / n) < x1_) & (vy / n > 0.86) & (vy / n < 0.91)] = cyan * (0.35 + 0.15 * q_) img = np.clip(img, 0, 1) * SCREEN_PEAK # the brightest ink x SCREEN_K stays under 1 srgb = np.where(img <= 0.0031308, img * 12.92, 1.055 * np.power(img, 1 / 2.4) - 0.055) rgba = np.concatenate([srgb, np.ones((n, Wd, 1), np.float32)], -1)[::-1] im = D.images.new("T_Grouper_Screens", Wd, n, alpha=False) im.pixels.foreach_set(rgba.astype(np.float32).ravel()) im.filepath_raw = path im.file_format = 'PNG' im.save() im.colorspace_settings.name = 'sRGB' return im def screen_uvs(o, mname="M_Grouper_Screen"): """every screen plate: the faces looking the way its display looks get one panel of the sheet (cycling through the three), mapped upright by their own extent; its other faces a dark corner""" me = o.data names = [m.name for m in me.materials] si = names.index(mname) uvl = me.uv_layers["UVMap"].data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() todo = {f.index for f in bm.faces if f.material_index == si} islands = [] while todo: seed = todo.pop() isl = [seed] stack = [seed] while stack: f = bm.faces[stack.pop()] for e in f.edges: for g in e.link_faces: if g.index in todo: todo.discard(g.index) isl.append(g.index) stack.append(g.index) islands.append(isl) bm.free() islands.sort(key=lambda isl: tuple(round(c, 2) for c in sum((me.polygons[i].center for i in isl), Vector()) / len(isl))) n_disp = 0 for k, isl in enumerate(islands): # (round 2) the display looks the way its LARGEST face looks: a closed bevelled box's normals sum to nothing (the # console column's screens took one flat colour) N0 = max((me.polygons[i] for i in isl), key=lambda q: q.area).normal.copy() third = k % 3 mapped = set() for N in (N0, -N0): # both broad faces of a thin screen box carry the display, each upright up_ref = Vector((0, 0, 1)) if abs(N.z) < 0.8 else Vector((0, -1, 0)) up = (up_ref - N * up_ref.dot(N)).normalized() right = up.cross(N).normalized() disp = [i for i in isl if me.polygons[i].normal.dot(N) > 0.6] cos_ = [me.vertices[vi].co for i in disp for vi in me.polygons[i].vertices] if not cos_: continue us = [q.dot(right) for q in cos_] vs = [q.dot(up) for q in cos_] for i in disp: p = me.polygons[i] for li in range(p.loop_start, p.loop_start + p.loop_total): q = me.vertices[me.loops[li].vertex_index].co uvl[li].uv = ((third + 0.04 + 0.92 * (q.dot(right) - min(us)) / max(1e-6, max(us) - min(us))) / 3.0, 0.04 + 0.92 * (q.dot(up) - min(vs)) / max(1e-6, max(vs) - min(vs))) mapped.add(i) for i in isl: if i in mapped: continue p = me.polygons[i] for li in range(p.loop_start, p.loop_start + p.loop_total): uvl[li].uv = (0.004, 0.004) n_disp += 1 return n_disp # ================================================================== bakes def use_gpu(): sc = bpy.context.scene sc.render.engine = 'CYCLES' try: prefs = bpy.context.preferences.addons['cycles'].preferences for kind in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'): try: prefs.compute_device_type = kind except Exception: continue prefs.refresh_devices() if any(d.type == kind for d in prefs.devices): for d in prefs.devices: d.use = d.type == kind sc.cycles.device = 'GPU' return kind except Exception as ex: print("GPU not set:", ex) sc.cycles.device = 'CPU' return 'CPU' class NB: """a tiny node builder for the bake materials""" def __init__(self, nt): self.nt, self.N, self.L = nt, nt.nodes, nt.links def math(self, op, a, b=None, clamp=False): n = self.N.new('ShaderNodeMath') n.operation = op n.use_clamp = clamp for i, v in enumerate((a, b)): if v is None: continue if isinstance(v, (int, float)): n.inputs[i].default_value = v else: self.L.new(v, n.inputs[i]) return n.outputs[0] def rng(self, v, f0, f1, t0=0.0, t1=1.0, smooth=True): n = self.N.new('ShaderNodeMapRange') if smooth: n.interpolation_type = 'SMOOTHSTEP' n.inputs["From Min"].default_value = f0 n.inputs["From Max"].default_value = f1 n.inputs["To Min"].default_value = t0 n.inputs["To Max"].default_value = t1 self.L.new(v, n.inputs["Value"]) return n.outputs["Result"] def sep_pos(self): geo = self.N.new('ShaderNodeNewGeometry') sp = self.N.new('ShaderNodeSeparateXYZ') self.L.new(geo.outputs["Position"], sp.inputs[0]) sn = self.N.new('ShaderNodeSeparateXYZ') self.L.new(geo.outputs["Normal"], sn.inputs[0]) return sp, sn, geo def emit(self, col, strength=None): em = self.N.new('ShaderNodeEmission') if isinstance(col, (tuple, list)): em.inputs["Color"].default_value = tuple(col) + (1,) else: self.L.new(col, em.inputs["Color"]) if strength is not None: if isinstance(strength, (int, float)): em.inputs["Strength"].default_value = strength else: self.L.new(strength, em.inputs["Strength"]) om = self.N.new('ShaderNodeOutputMaterial') self.L.new(em.outputs[0], om.inputs["Surface"]) return em def seam_value(b): """0..1: how much of a seam line this point is (the hull's seam_chine / seam_belly fields: 5 + signed distance in m)""" out = None for name in ("seam_chine", "seam_belly"): at = b.N.new('ShaderNodeAttribute') at.attribute_name = name a = at.outputs["Fac"] valid = b.math('GREATER_THAN', a, 0.5) d = b.math('ABSOLUTE', b.math('SUBTRACT', a, 5.0)) s = b.math('MULTIPLY', b.rng(d, SEAM_W0, SEAM_W1), valid) out = s if out is None else b.math('MAXIMUM', out, s) return out def mat_masks(): mm = D.materials.new("_bake_masks") mm.use_nodes = True nt = mm.node_tree nt.nodes.clear() b = NB(nt) ao = b.N.new('ShaderNodeAmbientOcclusion'); ao.samples = 16; ao.only_local = True; ao.inputs["Distance"].default_value = 0.55 cv = b.N.new('ShaderNodeAmbientOcclusion'); cv.samples = 16; cv.only_local = True; cv.inputs["Distance"].default_value = 0.06 bv = b.N.new('ShaderNodeBevel'); bv.samples = 8; bv.inputs["Radius"].default_value = EDGE_R geo = b.N.new('ShaderNodeNewGeometry') dot = b.N.new('ShaderNodeVectorMath'); dot.operation = 'DOT_PRODUCT' b.L.new(bv.outputs["Normal"], dot.inputs[0]); b.L.new(geo.outputs["Normal"], dot.inputs[1]) edg = b.math('MULTIPLY', b.math('SUBTRACT', 1.0, dot.outputs["Value"]), 3.5, clamp=True) crev = b.math('MULTIPLY', cv.outputs["AO"], b.math('ADD', b.math('MULTIPLY', seam_value(b), -0.85), 1.0)) cc = b.N.new('ShaderNodeCombineColor') b.L.new(ao.outputs["AO"], cc.inputs[0]); b.L.new(edg, cc.inputs[1]); b.L.new(crev, cc.inputs[2]) b.emit(cc.outputs[0]) return mm def mat_bevel(): mn = D.materials.new("_bake_bevel") mn.use_nodes = True nt = mn.node_tree b = NB(nt) p = nt.nodes["Principled BSDF"] bv = b.N.new('ShaderNodeBevel'); bv.samples = 8; bv.inputs["Radius"].default_value = BEVEL_R bump = b.N.new('ShaderNodeBump'); bump.invert = True bump.inputs["Strength"].default_value = 1.0; bump.inputs["Distance"].default_value = SEAM_DEPTH b.L.new(seam_value(b), bump.inputs["Height"]) b.L.new(bv.outputs["Normal"], bump.inputs["Normal"]) b.L.new(bump.outputs["Normal"], p.inputs["Normal"]) return mn TRACE_IM = None def mat_emit(kind): """the exterior emissive map's colour per material kind (linear; the image is sRGB)""" global TRACE_IM m = D.materials.new("_bake_emit_" + kind) m.use_nodes = True nt = m.node_tree nt.nodes.clear() b = NB(nt) if kind == "strip": # a STRIP_HW-wide line along the middle of the floor: strip_v (0 .. 1 across the floor) and strip_w (its width, m) are # the build's vertex fields; a strip face without them stays dark av = b.N.new('ShaderNodeAttribute'); av.attribute_name = "strip_v" aw = b.N.new('ShaderNodeAttribute'); aw.attribute_name = "strip_w" d = b.math('MULTIPLY', b.math('ABSOLUTE', b.math('SUBTRACT', av.outputs["Fac"], 0.5)), aw.outputs["Fac"]) # critique round 2 (m8): the line's half width never reaches its face's edge (3 mm short of it: where a narrow floor # glowed edge to edge, its edge was the texel staircase of the face boundary), and its edges are a 5 mm smoothstep # baked at twice the atlas's resolution and box-filtered (bake_atlas): anti-aliased lines hw = b.math('MINIMUM', b.math('SUBTRACT', b.math('MULTIPLY', aw.outputs["Fac"], 0.5), 0.003), STRIP_HW) x = b.math('MULTIPLY', b.math('SUBTRACT', b.math('ADD', hw, STRIP_SOFT), d), 0.5 / STRIP_SOFT, clamp=True) line = b.rng(x, 0.0, 1.0, 0.0, 1.0) has = b.math('GREATER_THAN', aw.outputs["Fac"], 0.001) b.emit(CYAN, b.math('MULTIPLY', line, has)) elif kind == "lamp": b.emit(WARM) elif kind == "duct": sp, sn, geo = b.sep_pos() fade = b.rng(sp.outputs["Y"], 3.86, 3.62, 0.15, 1.0) b.emit(CYAN, fade) elif kind == "lens": sp, sn, geo = b.sep_pos() tc = b.N.new('ShaderNodeTexCoord') vo = b.N.new('ShaderNodeTexVoronoi'); vo.feature = 'F1'; vo.inputs["Scale"].default_value = 14.0 b.L.new(geo.outputs["Position"], vo.inputs["Vector"]) ve = b.N.new('ShaderNodeTexVoronoi'); ve.feature = 'DISTANCE_TO_EDGE'; ve.inputs["Scale"].default_value = 14.0 b.L.new(geo.outputs["Position"], ve.inputs["Vector"]) cell = b.rng(ve.outputs["Distance"], 0.0, 0.05, 0.45, 1.0) sepc = b.N.new('ShaderNodeSeparateColor'); b.L.new(vo.outputs["Color"], sepc.inputs[0]) bright = b.math('ADD', 0.55, b.math('MULTIPLY', sepc.outputs[0], 0.45)) bv = b.N.new('ShaderNodeBevel'); bv.samples = 8; bv.inputs["Radius"].default_value = 0.010 dot = b.N.new('ShaderNodeVectorMath'); dot.operation = 'DOT_PRODUCT' b.L.new(bv.outputs["Normal"], dot.inputs[0]); b.L.new(geo.outputs["Normal"], dot.inputs[1]) facet = b.rng(b.math('SUBTRACT', 1.0, dot.outputs["Value"]), 0.0, 0.08, 1.0, 0.25) r = b.math('SQRT', b.math('ADD', b.math('POWER', sp.outputs["X"], 2.0), b.math('POWER', b.math('ADD', sp.outputs["Z"], 0.865), 2.0))) core = b.rng(r, 0.10, 0.36, 1.0, 0.0) mix = b.N.new('ShaderNodeMix'); mix.data_type = 'RGBA' b.L.new(core, mix.inputs[0]) mix.inputs[6].default_value = LENS + (1,) mix.inputs[7].default_value = (0.45, 0.80, 1.0, 1) k = b.math('MULTIPLY', b.math('MULTIPLY', cell, bright), facet) b.emit(mix.outputs[2], k) else: # "trace": the circuit traces, projected from the side sp, sn, geo = b.sep_pos() cx0, cy0, cx1, cy1 = DEC["trace_mask"]["crop_sheet_px"] sx = b.math('SUBTRACT', X0, b.math('DIVIDE', sp.outputs["Y"], S)) sy = b.math('SUBTRACT', Z0, b.math('DIVIDE', sp.outputs["Z"], S)) u = b.math('DIVIDE', b.math('SUBTRACT', sx, cx0), cx1 - cx0) v = b.math('SUBTRACT', 1.0, b.math('DIVIDE', b.math('SUBTRACT', sy, cy0), cy1 - cy0)) cm = b.N.new('ShaderNodeCombineXYZ'); b.L.new(u, cm.inputs[0]); b.L.new(v, cm.inputs[1]) if TRACE_IM is None: TRACE_IM = D.images.load(os.path.join(OUT, DEC["trace_mask"]["file"])) TRACE_IM.colorspace_settings.name = 'Non-Color' ti = b.N.new('ShaderNodeTexImage'); ti.image = TRACE_IM; ti.extension = 'CLIP'; ti.interpolation = 'Cubic' b.L.new(cm.outputs[0], ti.inputs["Vector"]) sgn = b.math('SIGN', sp.outputs["X"]) facing = b.rng(b.math('MULTIPLY', sn.outputs["X"], sgn), 0.25, 0.50) b.emit(CYAN, b.math('MULTIPLY', ti.outputs["Color"], facing)) return m EMIT_KIND = {"M_Grouper_Strip": "strip", "M_Grouper_LampWhite": "lamp", "M_Grouper_DuctGlow": "duct", "M_Grouper_ScannerLens": "lens"} def bake_atlas(name, parts, size, skip_mats=("M_Grouper_Screen",), emissive=False): t1 = time.time() kind = use_gpu() sc = bpy.context.scene N = size def blank(nm, rgba, cs='Non-Color'): im = D.images.new(nm, N, N, alpha=False) im.colorspace_settings.name = cs im.pixels.foreach_set(np.tile(np.array(rgba, np.float32), N * N)) return im imgs = {"masks": blank("T_Grouper_%s_Masks" % name, (1.0, 0.0, 1.0, 1.0)), "bevel": blank("T_Grouper_%s_BevelNormal" % name, (0.5, 0.5, 1.0, 1.0))} if emissive: imgs["emit"] = blank("T_Grouper_%s_Emissive" % name, (0.0, 0.0, 0.0, 1.0), 'sRGB') # (critique round 2, m8) the emissive map is baked at twice the size and box-filtered down: Cycles bakes one sample # per texel centre, so a line's edge was a texel staircase ss = D.images.new("_ss_emit_" + name, 2 * N, 2 * N, alpha=False) ss.colorspace_settings.name = 'sRGB' ss.pixels.foreach_set(np.tile(np.array((0.0, 0.0, 0.0, 1.0), np.float32), 4 * N * N)) mm, mn = mat_masks(), mat_bevel() em = {k: mat_emit(k) for k in ("strip", "lamp", "duct", "lens", "trace")} if emissive else {} for m, key in [(mm, "masks"), (mn, "bevel")] + [(m, "emit") for m in em.values()]: ti = m.node_tree.nodes.new('ShaderNodeTexImage') ti.image = imgs[key] if key != "emit" else ss m.node_tree.nodes.active = ti sc.cycles.samples = 16 sc.cycles.use_denoising = False sc.render.bake.margin = 2 sc.render.bake.use_clear = False def run(): for p in parts: me = p.data.copy() names = [m.name if m else "" for m in me.materials] bm = bmesh.new() bm.from_mesh(me) dead = [names.index(n) for n in skip_mats if n in names] if dead: bmesh.ops.delete(bm, geom=[f for f in bm.faces if f.material_index in dead], context='FACES') bm.to_mesh(me) bm.free() me.uv_layers.active = me.uv_layers["UVMap"] me.uv_layers["UVMap"].active_render = True tmp = D.objects.new("_bake_" + p.name, me) sc.collection.objects.link(tmp) tmp.matrix_world = p.matrix_world for o2 in sc.objects: o2.select_set(False) tmp.select_set(True) bpy.context.view_layer.objects.active = tmp try: for i in range(len(me.materials)): me.materials[i] = mm bpy.ops.object.bake(type='EMIT', margin=2, use_clear=False) for i in range(len(me.materials)): me.materials[i] = mn bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', margin=2, use_clear=False) if emissive: for i, nm in enumerate(names): me.materials[i] = em[EMIT_KIND.get(base_of(nm), "trace")] sc.cycles.samples = 8 bpy.ops.object.bake(type='EMIT', margin=4, use_clear=False) sc.cycles.samples = 16 finally: D.objects.remove(tmp) D.meshes.remove(me) try: run() except Exception as ex: if kind == 'CPU': raise print("bake on %s failed (%s), again on the CPU" % (kind, ex)) kind = 'CPU' sc.cycles.device = 'CPU' run() if emissive: # 2 x 2 box filter into the atlas's emissive map a = np.empty(4 * N * N * 4, np.float32) ss.pixels.foreach_get(a) a = a.reshape(2 * N, 2 * N, 4) a = 0.25 * (a[0::2, 0::2] + a[1::2, 0::2] + a[0::2, 1::2] + a[1::2, 1::2]) a[..., 3] = 1.0 imgs["emit"].pixels.foreach_set(a.ravel()) del a D.images.remove(ss) for im in imgs.values(): im.filepath_raw = os.path.join(TEX, im.name + ".png") im.file_format = 'PNG' im.save() for m in [mm, mn] + list(em.values()): D.materials.remove(m) return imgs, dict(device=kind, seconds=round(time.time() - t1, 1), size=N) # ================================================================== node helpers and dressing (the Tiger's) def tex_image(path, colorspace="sRGB"): path = os.path.normpath(path) for im in D.images: if im.filepath and os.path.normpath(bpy.path.abspath(im.filepath)) == path: im.colorspace_settings.name = colorspace return im im = D.images.load(path) im.colorspace_settings.name = colorspace return im _RANGE = {} def tex_range(im, lo=3, hi=97): if im.name not in _RANGE: n, ch = im.size[0] * im.size[1], im.channels px = np.empty(n * ch, np.float32) im.pixels.foreach_get(px) px = px.reshape(n, ch) if ch >= 3: c = px[:, :3] if im.colorspace_settings.name == 'sRGB': c = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) v = c @ np.array([0.2126, 0.7152, 0.0722], np.float32) else: v = px[:, 0] _RANGE[im.name] = (round(float(np.percentile(v, lo)), 4), round(float(np.percentile(v, hi)), 4)) return _RANGE[im.name] class Tree: def __init__(self, mat): mat.use_nodes = True self.nt = mat.node_tree if self.nt.animation_data: # a rerun: the old tree's light driver goes with the old tree self.nt.animation_data_clear() self.nt.nodes.clear() def node(self, kind, label=None, **props): n = self.nt.nodes.new(kind) if label: n.label = label for k, v in props.items(): setattr(n, k, v) i = len(self.nt.nodes) - 1 n.location = (-2000 + 240 * (i // 4), 500 - 320 * (i % 4)) return n def link(self, a, b): self.nt.links.new(a, b) def put(self, sock, v): if isinstance(v, bpy.types.NodeSocket): self.nt.links.new(v, sock) else: sock.default_value = v def math(self, op, a, b=None, clamp=False, label=None): n = self.node('ShaderNodeMath', label, operation=op, use_clamp=clamp) self.put(n.inputs[0], a) if b is not None: self.put(n.inputs[1], b) return n.outputs[0] def ranged(self, v, lo, hi, label=None, f0=0.0, f1=1.0, smooth=False): n = self.node('ShaderNodeMapRange', label) if smooth: n.interpolation_type = 'SMOOTHSTEP' self.put(n.inputs["Value"], v) n.inputs["From Min"].default_value = f0 n.inputs["From Max"].default_value = f1 n.inputs["To Min"].default_value = lo n.inputs["To Max"].default_value = hi return n.outputs["Result"] def mixc(self, fac, a, b, label=None): n = self.node('ShaderNodeMix', label, data_type='RGBA') self.put(n.inputs[0], fac) self.put(n.inputs[6], a if isinstance(a, bpy.types.NodeSocket) else tuple(a) + (1.0,)) self.put(n.inputs[7], b if isinstance(b, bpy.types.NodeSocket) else tuple(b) + (1.0,)) return n.outputs[2] def mixf(self, fac, a, b, label=None): n = self.node('ShaderNodeMix', label, data_type='FLOAT') self.put(n.inputs[0], fac) self.put(n.inputs[2], a) self.put(n.inputs[3], b) return n.outputs[0] def uv(self, name): return self.node('ShaderNodeUVMap', uv_map=name).outputs["UV"] def mapped(self, src, scale, label=None): n = self.node('ShaderNodeMapping', label) self.nt.links.new(src, n.inputs["Vector"]) n.inputs["Scale"].default_value = (scale, scale, scale) return n.outputs["Vector"] def tex(self, im, vec, label=None): n = self.node('ShaderNodeTexImage', label, image=im) self.nt.links.new(vec, n.inputs["Vector"]) return n def lights(self, k): """a Value node = Grouper_Root["lights"] x k (a simple-expression driver)""" n = self.node('ShaderNodeValue', "lights x %.1f (driver)" % k) n.name = "LIGHTS" n.outputs[0].default_value = k return n.outputs[0] def add_light_driver(mat, k): if not (mat.node_tree and "LIGHTS" in mat.node_tree.nodes): return None fc = mat.node_tree.driver_add('nodes["LIGHTS"].outputs[0].default_value') d = fc.driver d.type = 'SCRIPTED' while d.variables: d.variables.remove(d.variables[0]) v = d.variables.new() v.name = "lights" v.type = 'SINGLE_PROP' v.targets[0].id_type = 'OBJECT' v.targets[0].id = root v.targets[0].data_path = '["lights"]' d.expression = "lights * %.2f" % k d.expression = d.expression d.is_valid = True return d.expression def path_of(rel, r): return rel if r.get("abs_paths") else os.path.join(PACK, rel) def dress(mat, r, masks=None, bevel=None, emit=None, emit_k=0.0): t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u0 = t.uv("UV_Tile") mp = t.mapped(u0, 1.0 / r["tile"], "one repeat = %.2f m" % r["tile"]) diff, rough, normal = r["set"] dirt = t.tex(tex_image(os.path.join(PACK, DIRT), "Non-Color"), t.mapped(u0, 1.0 / r["dirt_tile"]), "smudges and dust").outputs["Color"] ao = edge = crev = n_bevel = None if masks is not None: u1 = t.uv("UVMap") sep = t.node('ShaderNodeSeparateColor') t.link(t.tex(masks, u1, "baked: R occlusion, G edges, B crevices and seams").outputs["Color"], sep.inputs[0]) ao, edge, crev = sep.outputs[0], sep.outputs[1], sep.outputs[2] nb = t.node('ShaderNodeNormalMap', "rounded edges and seams", uv_map="UVMap") t.link(t.tex(bevel, u1, "baked: rounded edges, seam grooves").outputs["Color"], nb.inputs["Color"]) n_bevel = nb.outputs["Normal"] col = tuple(r["tint"]) if diff: im = tex_image(path_of(diff, r)) lo, hi = tex_range(im) bw = t.node('ShaderNodeRGBToBW') t.link(t.tex(im, mp, "surface, diffuse").outputs["Color"], bw.inputs[0]) vm = t.node('ShaderNodeVectorMath', "tint x shade", operation='SCALE') vm.inputs[0].default_value = tuple(r["tint"]) t.link(t.ranged(bw.outputs[0], r["shade"][0], r["shade"][1], "shade the tint", lo, hi), vm.inputs["Scale"]) col = vm.outputs["Vector"] grime = None if crev is not None and r["grime"] > 0: grime = t.math('MULTIPLY', t.math('POWER', t.math('SUBTRACT', 1.0, crev), 1.4), r["grime"], clamp=True, label="shade in crevices and seams") col = t.mixc(grime, col, (0.004, 0.004, 0.004), "darken crevices") rim = tex_image(path_of(rough, r), "Non-Color") rlo, rhi = tex_range(rim) ro = t.tex(rim, mp, "surface, roughness").outputs["Color"] rr = t.ranged(ro, r["rough"][0], r["rough"][1], "roughness range", rlo, rhi) rr = t.math('ADD', rr, t.math('MULTIPLY', dirt, r["dirt"]), clamp=True, label="smudges dull the sheen") if grime is not None: rr = t.math('ADD', rr, t.math('MULTIPLY', grime, 0.35), clamp=True, label="crevices are matte") metal = r["metal"] if edge is not None and r["wear"] > 0: breakup = t.ranged(ro, 1.25, 0.55, "scratches break the wear up", rlo, rhi) exposed = t.ranged(ao, 0.0, 1.0, "only where the part is exposed", 0.72, 0.93, smooth=True) w = t.math('MULTIPLY', t.math('MULTIPLY', t.math('MULTIPLY', edge, breakup), exposed), r["wear"]) w = t.ranged(w, 0.0, 1.0, "worn edge, bare titanium", 0.38, 0.72, smooth=True) col = t.mixc(w, col, tuple(r["wear_col"]), "under the coating") rr = t.mixf(w, rr, r["wear_rough"]) metal = t.mixf(w, r["metal"], 1.0) t.put(b.inputs["Base Color"], tuple(col) + (1.0,) if isinstance(col, tuple) else col) t.put(b.inputs["Roughness"], rr) t.put(b.inputs["Metallic"], metal) if r.get("coat"): b.inputs["Coat Weight"].default_value = r["coat"] t.put(b.inputs["Coat Roughness"], t.math('ADD', r["coat_rough"], t.math('MULTIPLY', dirt, r["coat_dirt"]), clamp=True, label="clear coat, smudged")) nm_file = r.get("normal_map") or normal n_fine = None if nm_file and r["normal"] > 0: nd = t.node('ShaderNodeNormalMap', "fine relief", uv_map="UV_Tile") nd.inputs["Strength"].default_value = r["normal"] t.link(t.tex(tex_image(path_of(nm_file, r), "Non-Color"), mp, "surface, normal").outputs["Color"], nd.inputs["Color"]) n_fine = nd.outputs["Normal"] if n_bevel is not None and n_fine is not None: geo = t.node('ShaderNodeNewGeometry') a1 = t.node('ShaderNodeVectorMath', operation='ADD'); t.link(n_bevel, a1.inputs[0]); t.link(n_fine, a1.inputs[1]) s1 = t.node('ShaderNodeVectorMath', operation='SUBTRACT'); t.link(a1.outputs[0], s1.inputs[0]); t.link(geo.outputs["Normal"], s1.inputs[1]) nz = t.node('ShaderNodeVectorMath', "both normals", operation='NORMALIZE'); t.link(s1.outputs[0], nz.inputs[0]) t.link(nz.outputs[0], b.inputs["Normal"]) if r.get("coat"): t.link(n_bevel, b.inputs["Coat Normal"]) elif n_bevel is not None or n_fine is not None: t.link(n_bevel if n_bevel is not None else n_fine, b.inputs["Normal"]) if emit is not None and emit_k > 0: t.link(t.tex(emit, t.uv("UVMap"), "baked: emissive (strips, traces, lamps, lens)").outputs["Color"], b.inputs["Emission Color"]) t.link(t.lights(emit_k), b.inputs["Emission Strength"]) mat.diffuse_color = tuple(r["tint"]) + (1.0,) mat.roughness = 0.4 mat.metallic = r["metal"] def dress_screen(mat, image, k): t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) b.inputs["Base Color"].default_value = (0.02, 0.025, 0.03, 1) b.inputs["Roughness"].default_value = 0.12 b.inputs["Coat Weight"].default_value = 0.6 t.link(t.tex(image, t.uv("UVMap"), "instrument sheet").outputs["Color"], b.inputs["Emission Color"]) t.link(t.lights(k), b.inputs["Emission Strength"]) mat.diffuse_color = SCREEN_INK + (1.0,) def dress_glow(mat, k): """the cabin's ceiling strips: a dim cyan emitter in a dark satin housing (critique round 1 M6)""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) b.inputs["Base Color"].default_value = (0.03, 0.05, 0.055, 1) b.inputs["Roughness"].default_value = 0.3 b.inputs["Emission Color"].default_value = (0.05, 0.75, 1.0, 1) t.link(t.lights(k), b.inputs["Emission Strength"]) mat.diffuse_color = (0.05, 0.75, 1.0, 1.0) def dress_glass(mat, tint, rough=0.035, dirt_map=True): """clear thick acrylic (IOR 1.49), a faint blue-green tint, dust and fingerprints in the sheen (the Glass set's dirt); the door's porthole the same acrylic smoked dark (critique round 1 m3: through it the side render looked out the far side)""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) dirt = t.tex(tex_image(os.path.join(PACK, DIRT), "Non-Color"), t.mapped(t.uv("UV_Tile"), 1.0 / 0.7), "smudges and dust").outputs["Color"] b.inputs["Base Color"].default_value = tuple(tint) + (1,) b.inputs["Transmission Weight"].default_value = 1.0 b.inputs["IOR"].default_value = 1.49 if dirt_map: t.put(b.inputs["Roughness"], t.ranged(dirt, 0.0, rough, "smudged", 0.0, 1.0)) else: # (critique round 2, M3) the clear windows: one low roughness, no blotches b.inputs["Roughness"].default_value = rough mat.diffuse_color = tuple(tint) + (0.30,) try: mat.surface_render_method = 'DITHERED' except Exception: pass def dress_decal(mat, k): """the markings: the decal sheet's colour, its alpha as the opacity mask, painted under the hull's clear coat; the seal's emblem and the UI panel glow from the decal emissive sheet""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) base = tex_image(os.path.join(TEX, "T_Grouper_Decals_BaseColor.png")) base.alpha_mode = 'STRAIGHT' em = tex_image(os.path.join(TEX, "T_Grouper_Decals_Emissive.png")) u = t.uv("UVMap") tb = t.tex(base, u, "decal sheet (RGB paint, A coverage)") t.link(tb.outputs["Color"], b.inputs["Base Color"]) t.link(t.ranged(tb.outputs["Alpha"], 0.0, 1.0, "masked at 0.5", 0.45, 0.55), b.inputs["Alpha"]) b.inputs["Roughness"].default_value = 0.42 b.inputs["Metallic"].default_value = 0.0 b.inputs["Coat Weight"].default_value = 0.25 b.inputs["Coat Roughness"].default_value = 0.08 t.link(t.tex(em, u, "decal glow").outputs["Color"], b.inputs["Emission Color"]) t.link(t.lights(k), b.inputs["Emission Strength"]) mat.diffuse_color = (0.6, 0.62, 0.64, 1.0) try: mat.surface_render_method = 'DITHERED' except Exception: pass # ================================================================== run parts = {o.name: o for o in O if o.type == 'MESH' and o.name.startswith("SM_Grouper") and o.name != "SM_Grouper_Decals"} REPORT["uv"] = {} for atlas, spec in ATLASES.items(): for nm, rect in spec["rects"].items(): t1 = time.time() REPORT["uv"][nm] = uv_layers(parts[nm], rect, spec["size"]) REPORT["uv"][nm]["seconds"] = round(time.time() - t1, 1) print("UV", nm, REPORT["uv"][nm], flush=True) screens = make_screens(os.path.join(TEX, "T_Grouper_Screens.png")) REPORT["screen_islands"] = screen_uvs(parts["SM_Grouper_Cabin"]) baked = {} for atlas, spec in ATLASES.items(): plist = [parts[n] for n in spec["rects"]] imgs, info = bake_atlas(atlas, plist, spec["size"], emissive=(atlas in ("Exterior", "Parts"))) baked[atlas] = imgs REPORT["bake_" + atlas] = info print("BAKE", atlas, info, flush=True) # which atlas each material reads: by the faces that use it (a slot without faces does not count); a kind used both # outside and in the cabin gets a cabin copy (the cabin reads its own atlas) OBJ_ATLAS = {nm: atlas for atlas, spec in ATLASES.items() for nm in spec["rects"]} def used(o): idx = {p.material_index for p in o.data.polygons} return {o.data.materials[i].name for i in idx if i < len(o.data.materials) and o.data.materials[i]} for o in list(parts.values()) + [decals]: # material slots no face uses (the cabin's trailing FrameDark): dropped me_ = o.data idx = {p.material_index for p in me_.polygons} while len(me_.materials) and (len(me_.materials) - 1) not in idx: me_.materials.pop(index=len(me_.materials) - 1) # a material used on two atlases: the Parts and the Cabin objects get a copy of their own (the exterior keeps the original) for atlas, pre in ATLAS_PREFIX.items(): objs = [parts[n] for n in ATLASES[atlas]["rects"]] others = set().union(*[used(o) for o in parts.values() if OBJ_ATLAS[o.name] != atlas]) for o in objs: for sl in o.material_slots: m = sl.material if m and m.name in others and m.name in SURF and m.name in used(o): nm = m.name.replace("M_Grouper_", "M_Grouper_" + pre) cm = D.materials.get(nm) or D.materials.new(nm) sl.material = cm for m in list(D.materials): # the copies' looks (also on a rerun) b = base_of(m.name) if b != m.name and b in SURF: SURF[m.name] = SURF[b] for m in list(D.materials): if m.name.startswith("M_Grouper_") and m.users == 0: D.materials.remove(m) ATL = {} for o in parts.values(): for n in used(o): ATL.setdefault(n, set()).add(OBJ_ATLAS[o.name]) assert all(len(v) == 1 for v in ATL.values()), ATL ATL = {k: next(iter(v)) for k, v in ATL.items()} drivers = {} for m in list(D.materials): if m.name in SURF and m.name in ATL: imgs = baked[ATL[m.name]] k = GLOW_K.get(base_of(m.name), 0.0) dress(m, SURF[m.name], imgs["masks"], imgs["bevel"], imgs.get("emit"), k if imgs.get("emit") is not None else 0.0) if k and imgs.get("emit") is not None: drivers[m.name] = add_light_driver(m, k) elif m.name == "M_Grouper_Screen": dress_screen(m, screens, SCREEN_K) drivers[m.name] = add_light_driver(m, SCREEN_K) elif m.name == "M_Grouper_CabinGlow": dress_glow(m, CABGLOW_K) drivers[m.name] = add_light_driver(m, CABGLOW_K) elif m.name == "M_Grouper_Acrylic": dress_glass(m, (0.88, 0.96, 0.96), rough=0.02, dirt_map=False) elif m.name == "M_Grouper_AcrylicDark": dress_glass(m, (0.10, 0.14, 0.15), rough=0.06) elif m.name == "M_Grouper_Marking": dress_decal(m, DECAL_K) drivers[m.name] = add_light_driver(m, DECAL_K) REPORT["material_atlas"] = ATL REPORT["light_drivers"] = drivers REPORT["materials"] = sorted(m.name for m in D.materials if m.users and m.name.startswith("M_Grouper")) REPORT["seconds"] = round(time.time() - t0, 1) bpy.context.scene.render.engine = 'CYCLES' bpy.ops.file.make_paths_relative() bpy.ops.wm.save_mainfile() json.dump(REPORT, open(os.path.join(HERE, "surfaces_report.json"), "w"), indent=1) print("SURFACES OK", json.dumps(REPORT))