"""Grouper: the drawn textures of the surfaces pass (plain Python 3.14 + Pillow + numpy, no Blender): textures/T_Grouper_Decals_BaseColor.png (2048 x 2048, 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 2048, 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, drawn as vector polylines (critique round 1: the first version projected the sheet's own strokes, which came out broken and stair-stepped): lines 10 mm, end dots 20 mm, in the concept's zones (TRACES below). 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, 2048 # (critique round 1: square, three rows; the re-lofted hull's faces are larger, so is the pad) PPM = 800.0 # sheet pixels per metre PAD = 0.26 # 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", "insignia_small"], ["seal", "insignia"], ["ui_panel"]] 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))) if G.get("FLAGS", True): # the user, 2026-10-22: "remove those flags" (gr_measure.FLAGS) 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 (critique round 1, M4): VECTOR polylines in # the concept's style (thin continuous lines, right-angle and 45 deg bends, round end dots), laid out in the concept's zones on # SIDE's flank (sheet px): in front of the door (on the trace panel and the band under it, down toward the pod), round the # vents and the small panel aft of the door, and down the cheek under the side window's aft corner. Nothing of the sheet's # pixels is used (they were the drawing's anti-aliased strokes, many of them outlines of other parts). Drawn at 12 image px per # sheet px (1.4 mm per px) with 3x supersampling; lines 10 mm wide, end dots 20 mm across. The surfaces pass projects the mask # onto both flanks when it bakes the exterior emissive map. K = 12 SS = 3 CROP = (640, 330, 880, 460) LINE_M, DOT_M = 0.010, 0.020 TRACES = [ # in front of the door (the trace panel x 803-838, y 350-408, and the band below it) ([(801.5, 352.0), (801.5, 382.0), (806.0, 386.5), (806.0, 404.0)], "both"), ([(805.5, 352.0), (832.0, 352.0), (836.0, 356.0), (836.0, 370.0)], "both"), ([(809.0, 357.0), (821.0, 357.0)], "both"), ([(810.0, 375.0), (826.0, 375.0), (831.0, 380.0), (831.0, 399.0), (826.0, 404.0), (816.0, 404.0)], "both"), ([(812.0, 381.0), (812.0, 394.0), (817.0, 399.0)], "both"), ([(836.0, 384.0), (836.0, 417.0), (830.5, 422.5), (830.5, 437.0)], "both"), ([(803.5, 411.0), (803.5, 424.0), (811.5, 432.0), (822.0, 432.0)], "both"), ([(816.0, 412.0), (826.0, 412.0)], "both"), # round the vents and the panel aft of the door ([(649.5, 367.0), (659.0, 367.0)], "both"), ([(660.0, 365.5), (708.0, 365.5), (712.5, 370.0), (712.5, 385.5), (716.5, 389.5), (727.0, 389.5)], "both"), ([(727.5, 371.0), (733.0, 371.0), (733.0, 384.0)], "both"), ([(705.5, 380.5), (709.5, 380.5)], "both"), # down the cheek, under the side window's aft corner ([(843.0, 391.0), (843.0, 403.0), (848.0, 408.0), (848.0, 423.0)], "both"), ([(851.5, 396.0), (856.0, 400.5), (863.0, 400.5)], "both"), ([(839.0, 411.0), (839.0, 425.0), (844.0, 430.0), (856.0, 430.0)], "both"), ] # critique round 2 (m7): the two empty outlined rectangles on the trace panel (0.29 and 0.19 m) are gone; short traces with # end dots run where they were (the concept has only thin traces there) TRACES += [([(812.0, 363.0), (823.0, 363.0), (827.5, 367.5)], "both"), ([(815.5, 389.0), (823.5, 389.0)], "both")] UI_RECTS = [] cx0, cy0, cx1, cy1 = CROP SCALE = K * SS big = Image.new("L", ((cx1 - cx0) * SCALE, (cy1 - cy0) * SCALE), 0) dr = ImageDraw.Draw(big) lw = max(1, int(round(LINE_M / S * SCALE))) rd = DOT_M / S * SCALE * 0.5 def P(q): return ((q[0] - cx0) * SCALE, (q[1] - cy0) * SCALE) for pts, dots in TRACES: xy = [P(q) for q in pts] dr.line(xy, fill=255, width=lw, joint="curve") for q in xy: # round the joints dr.ellipse([q[0] - lw / 2, q[1] - lw / 2, q[0] + lw / 2, q[1] + lw / 2], fill=255) ends = [xy[-1]] if dots == "end" else [xy[0], xy[-1]] for q in ends: dr.ellipse([q[0] - rd, q[1] - rd, q[0] + rd, q[1] + rd], fill=255) for x0, y0, x1, y1 in UI_RECTS: dr.rectangle([P((x0, y0)), P((x1, y1))], outline=255, width=max(1, int(lw * 0.8))) trace = big.resize(((cx1 - cx0) * K, (cy1 - cy0) * K), Image.LANCZOS) trace.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, "kind": "vector polylines (critique round 1 M4)", "line_m": LINE_M, "dot_m": DOT_M, "polylines_sheet_px": [p for p, d in TRACES], "ui_rects_sheet_px": UI_RECTS}, "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"])