"""Grouper: the drawn textures of the surfaces pass (plain Python 3.14 + Pillow + numpy, no Blender): textures/T_Grouper_Decals_BaseColor.png (2048 x 1024, RGBA, sRGB): the markings as the sheet places them and the concept styles them, one tile each: the lettering "Subtub 1" (the concept's title case, italic sans, light grey paint), a generic striped insignia (two-tone stripes, a navy canton with a white chevron: no national flag), a generic round seal (a ring with gear teeth, a navy disc; no text, no official mark) and a small UI panel (the concept's cyan panel decals near the stern). Alpha = coverage (Unreal: Masked, opacity mask = A). Worn a little (speckled chips). textures/T_Grouper_Decals_Emissive.png (2048 x 1024, RGB, sRGB): what glows on them: the seal's cyan circuit emblem and the UI panel's lines (Unreal: emissive = this x strength x Lights). textures/_src/T_Grouper_TraceMask_side.png the concept's circuit traces: the sheet's own drawn lines (reference/ lines_side.png) inside the Designer's CIRCUIT_ZONES (gr_measure.py; the seal's zone left to the seal decal), the waist rims that cross them taken out, smoothed at 4x and thinned to about 1.5 sheet px. The surfaces pass projects it onto both flanks (side projection) when it bakes the exterior emissive map. build/decals.json each tile's rectangle in the sheet (UV, v from the bottom) and the world size it stands for (with its transparent pad), the trace mask's crop box in sheet pixels. python build/gr_decals_draw.py""" import json import math import os import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) TEX = os.path.join(ROOT, "textures") SRC = os.path.join(TEX, "_src") os.makedirs(SRC, exist_ok=True) G = {} exec(open(os.path.join(HERE, "gr_measure.py"), encoding="utf-8").read(), G) S, MK = G["S"], G["MARKINGS"] rng = np.random.default_rng(20261022) W, H = 2048, 1024 PPM = 850.0 # sheet pixels per metre PAD = 0.12 # transparent pad round each decal (m): wider than the hull's faces that overlap a decal's edge def srgb(c): c = np.clip(np.asarray(c, np.float64), 0, 1) return tuple(int(round(255 * (v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055))) for v in c) PAINT = srgb((0.60, 0.62, 0.64)) # light grey paint (the concept's lettering), a shade under the measure's 0.75 RED = srgb((0.45, 0.08, 0.06)) NAVY = srgb((0.04, 0.06, 0.14)) WHITE = srgb((0.80, 0.82, 0.83)) CYAN = srgb((0.0, 0.85, 1.0)) CYAN_DARK = srgb((0.01, 0.10, 0.12)) def box_m(b): """a SIDE pixel box (x0, y0, x1, y1) -> (width, height) in metres""" return (b[2] - b[0]) * S, (b[3] - b[1]) * S # ------------------------------------------------------------------ the tiles (world size incl. the pad, placed in rows) text_w, text_h = box_m(MK["text_box"]) ins_w, ins_h = box_m(MK["insignia_box"]) seal_d = 2 * MK["seal_r"] * S UI_BOX = (612.0, 360.0, 648.0, 374.0) # the UI panel: on the band forward of the stern fin, aft of the vents ui_w, ui_h = box_m(UI_BOX) SI = G["STERN_INSIGNIA"] sins_w, sins_h = (SI["x"][1] - SI["x"][0]) * S, (SI["y"][1] - SI["y"][0]) * S TILES = { "insignia_small": (sins_w + 2 * PAD, sins_h + 2 * PAD), "lettering": (text_w + 2 * PAD, text_h + 2 * PAD), "insignia": (ins_w + 2 * PAD, ins_h + 2 * PAD), "seal": (seal_d + 2 * PAD, seal_d + 2 * PAD), "ui_panel": (ui_w + 2 * PAD, ui_h + 2 * PAD), } ORDER = [["lettering", "seal"], ["insignia", "ui_panel", "insignia_small"]] rects_px = {} y = 0 for row in ORDER: x = 0 hmax = 0 for n in row: w, h = (int(math.ceil(v * PPM)) for v in TILES[n]) rects_px[n] = (x, y, x + w, y + h) x += w + 16 hmax = max(hmax, h) assert x <= W + 16, n y += hmax + 16 assert y <= H + 16 base = Image.new("RGBA", (W, H), PAINT + (0,)) emis = Image.new("RGB", (W, H), (0, 0, 0)) db = ImageDraw.Draw(base) de = ImageDraw.Draw(emis) def inner(n): """the decal's own rectangle inside its tile (pixels)""" x0, y0, x1, y1 = rects_px[n] p = int(round(PAD * PPM)) return x0 + p, y0 + p, x1 - p, y1 - p # ------------------------------------------------------------------ lettering: "Subtub 1", italic sans, fitted to the box FONT = None for f in ("seguisbi.ttf", "segoeuiz.ttf", "arialbi.ttf"): p = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", f) if os.path.exists(p): FONT = p break x0, y0, x1, y1 = inner("lettering") bw, bh = x1 - x0, y1 - y0 size = bh for _ in range(60): font = ImageFont.truetype(FONT, size) l, t, r, b = db.textbbox((0, 0), MK["text"], font=font) if r - l <= bw * 0.98 and b - t <= bh * 0.98: break size = int(size * 0.97) l, t, r, b = db.textbbox((0, 0), MK["text"], font=font) tx = x0 + (bw - (r - l)) / 2 - l ty = y0 + (bh - (b - t)) / 2 - t db.text((tx, ty), MK["text"], font=font, fill=PAINT + (255,)) # ------------------------------------------------------------------ insignia: a generic striped patch (no national flag): # diagonal two-tone bands (muted red, light grey) under a navy canton with a white chevron, a thin grey border. Twice: the # flanks' (SIDE x 684-716) and the small one on the upper stern (BACK's "Insignia (Flag)"). def draw_insignia(name): x0, y0, x1, y1 = inner(name) fw, fh = int(x1 - x0), int(y1 - y0) ins = Image.new("RGBA", (fw, fh), WHITE + (255,)) di = ImageDraw.Draw(ins) step = fh / 3.2 k = -6 while k * step < fw + fh: xa = k * step di.polygon([(xa, fh), (xa + step / 2, fh), (xa + step / 2 + fh * 0.58, 0), (xa + fh * 0.58, 0)], fill=RED + (255,)) k += 1 cb = MK["insignia_canton"] ib = MK["insignia_box"] cw = (cb[2] - cb[0]) / (ib[2] - ib[0]) * fw ch = (cb[3] - cb[1]) / (ib[3] - ib[1]) * fh di.rectangle([0, 0, cw, ch], fill=NAVY + (255,)) m = 0.20 * ch di.line([(0.25 * cw, m), (0.62 * cw, ch / 2), (0.25 * cw, ch - m)], fill=WHITE + (255,), width=max(3, int(0.12 * ch)), joint="curve") di.rectangle([0, 0, fw - 1, fh - 1], outline=PAINT + (255,), width=max(2, int(0.03 * fh))) base.paste(ins, (int(x0), int(y0))) draw_insignia("insignia") draw_insignia("insignia_small") # ------------------------------------------------------------------ seal: a geared ring, a navy disc, a cyan circuit emblem x0, y0, x1, y1 = inner("seal") cx, cy, R = (x0 + x1) / 2, (y0 + y1) / 2, (x1 - x0) / 2 teeth = 28 pts = [] for k in range(teeth * 2): a = 2 * math.pi * k / (teeth * 2) rr = R if k % 2 == 0 else R * 0.93 for da in (-0.35, 0.35): aa = a + da * 2 * math.pi / (teeth * 4) pts.append((cx + rr * math.cos(aa), cy + rr * math.sin(aa))) db.polygon(pts, fill=PAINT + (255,)) db.ellipse([cx - 0.86 * R, cy - 0.86 * R, cx + 0.86 * R, cy + 0.86 * R], fill=NAVY + (255,)) db.ellipse([cx - 0.80 * R, cy - 0.80 * R, cx + 0.80 * R, cy + 0.80 * R], outline=PAINT + (255,), width=max(2, int(0.025 * R))) # the emblem: a circuit tree (a trunk, right-angled branches, round pads), drawn the same on the base (paint-cyan) and glow lw = max(3, int(0.045 * R)) segs = [((0, 0.55), (0, -0.50)), ((0, 0.25), (-0.30, 0.25)), ((-0.30, 0.25), (-0.30, -0.05)), ((0, 0.05), (0.32, 0.05)), ((0.32, 0.05), (0.32, -0.30)), ((0, -0.20), (-0.42, -0.20)), ((0, 0.40), (0.25, 0.40)), ((-0.30, -0.05), (-0.50, -0.05))] pads = [(0, -0.50), (-0.30, 0.25), (0.32, -0.30), (-0.42, -0.20), (0.25, 0.40), (-0.50, -0.05), (0, 0.55), (0.32, 0.05)] for d, col in ((db, CYAN + (255,)), (de, CYAN)): for (a, b) in segs: d.line([(cx + a[0] * R, cy - a[1] * R), (cx + b[0] * R, cy - b[1] * R)], fill=col, width=lw) for p in pads: rr = 0.06 * R d.ellipse([cx + p[0] * R - rr, cy - p[1] * R - rr, cx + p[0] * R + rr, cy - p[1] * R + rr], fill=col) d.ellipse([cx - 0.66 * R, cy - 0.66 * R, cx + 0.66 * R, cy + 0.66 * R], outline=col, width=max(2, lw // 2)) # ------------------------------------------------------------------ UI panel: a cyan outlined panel with bars and squares x0, y0, x1, y1 = inner("ui_panel") pw, ph = x1 - x0, y1 - y0 db.rectangle([x0, y0, x1, y1], fill=CYAN_DARK + (230,)) lw = max(3, int(0.03 * ph)) for d, col, a in ((db, CYAN + (255,), True), (de, CYAN, False)): d.rectangle([x0, y0, x1, y1], outline=col, width=lw) for k, fr in enumerate((0.78, 0.55, 0.90)): yy = y0 + ph * (0.22 + 0.24 * k) d.rectangle([x0 + 0.30 * pw, yy, x0 + (0.30 + 0.62 * fr) * pw, yy + 0.09 * ph], fill=col) d.rectangle([x0 + 0.06 * pw, y0 + 0.20 * ph, x0 + 0.22 * pw, y0 + 0.62 * ph], outline=col, width=lw) for k in range(3): xx = x0 + 0.06 * pw + k * 0.06 * pw d.rectangle([xx, y0 + 0.74 * ph, xx + 0.035 * pw, y0 + 0.74 * ph + 0.035 * pw], fill=col) # ------------------------------------------------------------------ wear: a few chips in the paint (alpha speckle) a = np.array(base) noise = rng.random((H, W)) blur = np.array(Image.fromarray((noise * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(2.2)), np.float32) / 255.0 chips = blur > 0.572 a[..., 3] = np.where(chips & (a[..., 3] > 0), (a[..., 3] * 0.15).astype(np.uint8), a[..., 3]) base = Image.fromarray(a) base.save(os.path.join(TEX, "T_Grouper_Decals_BaseColor.png")) emis.filter(ImageFilter.GaussianBlur(0.8)).save(os.path.join(TEX, "T_Grouper_Decals_Emissive.png")) # ------------------------------------------------------------------ the circuit traces mask (sheet px, cropped, 4x) K = 4 CROP = (640, 330, 880, 460) lines = Image.open(os.path.join(ROOT, "reference", "lines_side.png")).convert("L") L = np.array(lines, np.float32) / 255.0 keep = np.zeros_like(L) for name, (bx0, by0, bx1, by1) in G["CIRCUIT_ZONES"].items(): if name == "seal": continue keep[int(by0):int(by1) + 1, int(bx0):int(bx1) + 1] = 1.0 yy, xx = np.mgrid[0:L.shape[0], 0:L.shape[1]] for rim in (G["WAIST_UPPER_RIM"], G["WAIST_LOWER_RIM"]): xs = np.array([r[0] for r in rim], np.float32) ya = np.array([r[1] for r in rim], np.float32) yb = np.array([r[2] for r in rim], np.float32) lo = np.interp(xx, xs, ya, left=np.nan, right=np.nan) - 2.5 hi = np.interp(xx, xs, yb, left=np.nan, right=np.nan) + 2.5 on = (yy >= lo) & (yy <= hi) keep[on] = 0.0 M = (L > 0.5).astype(np.float32) * keep cx0, cy0, cx1, cy1 = CROP Mc = Image.fromarray((M[cy0:cy1, cx0:cx1] * 255).astype(np.uint8)) big = Mc.resize(((cx1 - cx0) * K, (cy1 - cy0) * K), Image.BICUBIC).filter(ImageFilter.GaussianBlur(K * 0.25)) B = np.array(big, np.float32) / 255.0 B = (B > 0.5).astype(np.uint8) * 255 thin = Image.fromarray(B).filter(ImageFilter.MinFilter(3)) # thin the 2 px drawn lines to about 1.5 px thin = thin.filter(ImageFilter.GaussianBlur(0.9)) thin.save(os.path.join(SRC, "T_Grouper_TraceMask_side.png")) out = {"sheet_px": [W, H], "px_per_m": PPM, "pad_m": PAD, "font": os.path.basename(FONT) if FONT else None, "tiles": {}, "trace_mask": {"file": "textures/_src/T_Grouper_TraceMask_side.png", "crop_sheet_px": list(CROP), "scale": K, "zones": {k: v for k, v in G["CIRCUIT_ZONES"].items() if k != "seal"}}, "ui_box_side_px": list(UI_BOX)} for n, (x0, y0, x1, y1) in rects_px.items(): out["tiles"][n] = {"uv": [x0 / W, 1 - y1 / H, x1 / W, 1 - y0 / H], "world_m": [round(TILES[n][0], 4), round(TILES[n][1], 4)], "inner_m": [round(TILES[n][0] - 2 * PAD, 4), round(TILES[n][1] - 2 * PAD, 4)], "px": [x0, y0, x1, y1]} json.dump(out, open(os.path.join(HERE, "decals.json"), "w"), indent=1) prev = Image.new("RGB", (W, H), (40, 44, 48)) prev.paste(base, (0, 0), base) prev = Image.blend(prev, Image.fromarray(np.maximum(np.array(prev), np.array(emis))), 0.5) prev.save(os.path.join(SRC, "decals_preview.png")) print("DECALS", json.dumps(out["tiles"]), "font", out["font"])