# Textures for the captain's quarters, made from nothing with numpy + PIL (system Python, not Blender's). # python make_textures.py # Writes ../textures/*.png and atlas.json (UV cells of the decal sheet and of the screen sheet). import os, json, math import numpy as np from PIL import Image, ImageDraw, ImageFont HERE = os.path.dirname(os.path.abspath(__file__)) TEX = os.path.join(os.path.dirname(HERE), 'textures') os.makedirs(TEX, exist_ok=True) rng = np.random.default_rng(20261013) FONT = r'C:\Windows\Fonts\bahnschrift.ttf' def noise(n, fx, fy=None, seed=None, m=None): """Tileable noise: white noise low-passed in the frequency domain. fx, fy = feature count across the tile.""" fy = fy or fx m = m or n r = np.random.default_rng(seed) if seed is not None else rng w = r.standard_normal((m, n)) ky = np.fft.fftfreq(m)[:, None] * m kx = np.fft.fftfreq(n)[None, :] * n filt = np.exp(-0.5 * ((kx / fx) ** 2 + (ky / fy) ** 2)) out = np.real(np.fft.ifft2(np.fft.fft2(w) * filt)) out -= out.mean() return out / (out.std() + 1e-9) def save_gray(a, name): Image.fromarray(np.clip(a * 255, 0, 255).astype(np.uint8), 'L').save(os.path.join(TEX, name)) def normal_from_height(h, strength, aspect=1.0): """h: rows go up (row 0 = v 0). Returns an OpenGL normal map, rows flipped for saving.""" n = h.shape[1]; m = h.shape[0] dx = (np.roll(h, -1, 1) - np.roll(h, 1, 1)) * n / 2.0 dy = (np.roll(h, -1, 0) - np.roll(h, 1, 0)) * m / 2.0 * aspect nx = -dx * strength; ny = -dy * strength; nz = np.ones_like(h) l = np.sqrt(nx * nx + ny * ny + nz * nz) rgb = np.stack([nx / l, ny / l, nz / l], -1) * 0.5 + 0.5 return Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8)[::-1], 'RGB') # ---------------------------------------------------------------- panel roughness (tileable) def panel_rough(): n = 1024 a = 0.5 + 0.14 * noise(n, 3) + 0.10 * noise(n, 9) + 0.05 * noise(n, 30) a += 0.05 * noise(n, 40, 3) # faint wipe marks save_gray(np.clip(a, 0, 1), 'T_CQ_Panel_R.png') # ---------------------------------------------------------------- quilt wrinkles, 3 x 3 tufts per tile def quilt_normal(): n = 1024 ys, xs = np.mgrid[0:n, 0:n] x = xs / n * 3.0; y = ys / n * 3.0 fx = x % 1.0; fy = y % 1.0 dx = np.minimum(fx, 1 - fx); dy = np.minimum(fy, 1 - fy) h = np.zeros((n, n)) d = np.minimum(dx, dy) # soft folds pulled out of the seams, strongest next to them (kept low-frequency: they must not alias) h += 0.0035 * np.exp(-dx / 0.16) * noise(n, 3, 12, seed=1) h += 0.0035 * np.exp(-dy / 0.16) * noise(n, 12, 3, seed=2) # a few creases fanning out of each corner where four tufts meet cx = np.round(x); cy = np.round(y) r = np.hypot(x - cx, y - cy) th = np.arctan2(y - cy, x - cx) idx = (cx % 3 + 3 * (cy % 3)).astype(int) kk = np.array([4, 6, 4, 6, 4, 6, 4, 6, 4])[idx] ph = np.array([0.3, 1.9, 4.2, 0.9, 2.7, 5.1, 3.3, 1.2, 4.8])[idx] h += 0.0030 * np.exp(-r / 0.18) * np.sin(kk * th + ph) * np.clip(r / 0.08, 0, 1) # uneven filling h += 0.0035 * noise(n, 6, seed=3) * np.clip(d * 5, 0, 1) # stitched seam h -= 0.008 * np.exp(-(d / 0.03) ** 2) normal_from_height(h, 1.0).save(os.path.join(TEX, 'T_CQ_Quilt_N.png')) # ---------------------------------------------------------------- fabric weave (tileable) def fabric_normal(): n = 512 ys, xs = np.mgrid[0:n, 0:n] h = 0.006 * noise(n, 4, seed=5) + 0.002 * noise(n, 14, seed=6) # soft creases only: no weave, it aliases normal_from_height(h, 1.0).save(os.path.join(TEX, 'T_CQ_Fabric_N.png')) # ---------------------------------------------------------------- blanket pattern: blocky three-tone, multiplies the colour def camo(): c = 64 a = noise(c, 5, seed=7) + 0.6 * noise(c, 12, seed=8) lv = np.full((c, c), 1.0) lv[a > 0.35] = 0.80 lv[a < -0.55] = 0.62 img = Image.fromarray((lv * 255).astype(np.uint8), 'L').resize((1024, 1024), Image.NEAREST) img.convert('RGB').save(os.path.join(TEX, 'T_CQ_Camo_BC.png')) # ---------------------------------------------------------------- perforated grille, 4 staggered rows per tile def perf(): n = 256 ys, xs = np.mgrid[0:n, 0:n] u = xs / n * 4; v = ys / n * 4 row = np.floor(v) uu = (u + 0.5 * (row % 2)) % 1.0 - 0.5 vv = v % 1.0 - 0.5 r = np.hypot(uu, vv) hole = np.clip((0.30 - r) / 0.04, 0, 1) bc = 0.20 * (1 - hole) + 0.01 * hole Image.fromarray((np.stack([bc] * 3, -1) * 255).astype(np.uint8), 'RGB').save(os.path.join(TEX, 'T_CQ_Perf_BC.png')) normal_from_height(-hole * 0.02, 1.0).save(os.path.join(TEX, 'T_CQ_Perf_N.png')) # ---------------------------------------------------------------- decal sheet def decals(atlas): W, Hh = 2048, 1024 sheet = Image.new('RGBA', (W, Hh), (255, 255, 255, 0)) cur = [8, 8, 0] def place(tile, name): w, h = tile.size if cur[0] + w + 8 > W: cur[0] = 8; cur[1] += cur[2] + 8; cur[2] = 0 sheet.paste(tile, (cur[0], cur[1])) atlas[name] = [cur[0] / W, 1 - (cur[1] + h) / Hh, (cur[0] + w) / W, 1 - cur[1] / Hh] atlas[name + '_aspect'] = w / h cur[0] += w + 8; cur[2] = max(cur[2], h) def text(name, s, px, col=(235, 238, 242), spacing=0): f = ImageFont.truetype(FONT, px) try: f.set_variation_by_name('Bold') except Exception: pass l, t, r, b = f.getbbox(s) tw = r - l + spacing * (len(s) - 1) tile = Image.new('RGBA', (tw + 12, b - t + 12), (*col, 0)) d = ImageDraw.Draw(tile) if spacing: x = 6 - l for ch in s: d.text((x, 6 - t), ch, font=f, fill=(*col, 255)) x += f.getlength(ch) + spacing else: d.text((6 - l, 6 - t), s, font=f, fill=(*col, 255)) place(tile, name) red = (225, 38, 28) t = Image.new('RGBA', (300, 264), (*red, 0)); d = ImageDraw.Draw(t) d.polygon([(150, 14), (286, 250), (14, 250)], outline=(*red, 255), width=22) d.polygon([(150, 14), (286, 250), (14, 250)], outline=(*red, 255)) d.rounded_rectangle((139, 100, 161, 182), 8, fill=(*red, 255)); d.ellipse((137, 196, 163, 222), fill=(*red, 255)) place(t, 'warn_tri') t = Image.new('RGBA', (120, 104), (235, 238, 242, 0)); d = ImageDraw.Draw(t) d.polygon([(6, 6), (114, 6), (60, 98)], fill=(235, 238, 242, 255)); place(t, 'tri_down') t = Image.new('RGBA', (260, 140), (235, 238, 242, 0)); d = ImageDraw.Draw(t) d.polygon([(10, 70), (90, 10), (90, 48), (250, 48), (250, 92), (90, 92), (90, 130)], fill=(235, 238, 242, 255)); place(t, 'arrow_left') t = Image.new('RGBA', (200, 200), (*red, 0)); d = ImageDraw.Draw(t) d.rounded_rectangle((6, 6, 194, 194), 30, outline=(*red, 255), width=14) d.rectangle((82, 44, 118, 156), fill=(*red, 255)); d.rectangle((44, 82, 156, 118), fill=(*red, 255)); place(t, 'cross') text('txt_cq01', 'CQ-01', 300) text('txt_quarters', 'COMMAND QUARTERS', 64, spacing=10) text('txt_caution', 'CAUTION LATCH', 44, col=red, spacing=4) text('txt_berth', 'BERTH 01 VENT A / B', 48, spacing=6) text('txt_station', 'WORK STATION', 52, spacing=8) text('txt_exit', 'EXIT', 84, spacing=14) text('txt_medkit', 'AID KIT', 44, spacing=6) text('txt_viewport', 'VIEWPORT V-1', 44, spacing=6) text('txt_tie', 'TIE-DOWN 500 KG', 44, spacing=4) sheet.save(os.path.join(TEX, 'T_CQ_Decals.png')) # ---------------------------------------------------------------- screens (emissive) def screens(atlas): W, Hh = 2048, 2048 S = Image.new('RGB', (W, Hh), (2, 3, 4)) teal = (70, 220, 215); dim = (26, 82, 84); amber = (255, 168, 60); white = (215, 230, 235) def font(px, bold=False): f = ImageFont.truetype(FONT, px) try: f.set_variation_by_name('Bold' if bold else 'Regular') except Exception: pass return f def cell(name, x, y, w, h): atlas[name] = [x / W, 1 - (y + h) / Hh, (x + w) / W, 1 - y / Hh] # --- chart over the bench: depth contours of the Throat, route and position (2140 x 680 mm) cw, ch = 2048, 650 f = noise(cw, 4.5, 2.2, seed=21, m=ch) + 0.35 * noise(cw, 14, 7, seed=22, m=ch) xs = np.linspace(-1, 1, cw)[None, :] f = f - 1.6 * np.exp(-(xs * 1.4) ** 2) # a trench down the middle lines = np.abs(((f * 3.2) % 1.0) - 0.5) g = np.gradient(f * 3.2) wd = np.hypot(g[0], g[1]) + 1e-6 k = np.clip(1.0 - (0.5 - lines) / (wd * 1.3), 0, 1) img = np.zeros((ch, cw, 3)) img += k[..., None] * np.array(dim) * 1.5 deep = np.clip(-f / 3.0, 0, 1)[..., None] img += deep * np.array((6, 30, 40)) chart = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), 'RGB') d = ImageDraw.Draw(chart) for gx in range(0, cw, 128): d.line((gx, 0, gx, ch), fill=(10, 30, 32), width=1) for gy in range(0, ch, 130): d.line((0, gy, cw, gy), fill=(10, 30, 32), width=1) route = [(140, 520), (430, 410), (700, 430), (960, 300), (1040, 330), (1320, 220), (1650, 250)] d.line(route, fill=amber, width=4, joint='curve') for (x, y) in route[1:-1]: d.ellipse((x - 8, y - 8, x + 8, y + 8), outline=amber, width=3) x, y = route[3] d.ellipse((x - 22, y - 22, x + 22, y + 22), outline=white, width=4) d.line((x - 36, y, x + 36, y), fill=white, width=2); d.line((x, y - 36, x, y + 36), fill=white, width=2) d.text((x + 34, y - 58), 'CANTICLE', font=font(34, True), fill=white) d.text((x + 34, y - 22), 'DEPTH 1 840 M HDG 214', font=font(22), fill=teal) d.text((40, 28), 'THE THROAT / SURVEY CHART 07', font=font(34, True), fill=teal) d.text((40, 72), 'CONTOUR 50 M GRID 500 M', font=font(22), fill=dim) d.text((cw - 420, ch - 54), 'ANCHORAGE \u2191 212 KM', font=font(24), fill=dim) d.rectangle((2, 2, cw - 3, ch - 3), outline=dim, width=2) S.paste(chart, (0, 0)); cell('screen_chart', 0, 0, cw, ch) # --- desk display (1180 x 580 mm) dw, dh = 1400, 688 T_ = Image.new('RGB', (dw, dh), (3, 5, 6)); d = ImageDraw.Draw(T_) d.rectangle((0, 0, dw, 58), fill=(8, 28, 30)) d.text((24, 12), 'CANTICLE / SHIP STATUS', font=font(30, True), fill=teal) d.text((dw - 250, 16), 'TETHYS ONLINE', font=font(24), fill=amber) rows = [('HULL', 0.97), ('REACTOR', 0.74), ('BALLAST', 0.52), ('CARGO BAY', 0.31), ('REFINERY', 0.12), ('TENDER 01', 0.88), ('TENDER 02', 0.64)] for i, (nm, v) in enumerate(rows): y = 96 + i * 62 d.text((30, y), nm, font=font(26), fill=white) d.rectangle((250, y + 6, 720, y + 26), outline=dim, width=2) d.rectangle((252, y + 8, 252 + int(466 * v), y + 24), fill=teal if v > 0.3 else amber) d.text((740, y), '%3d %%' % int(v * 100), font=font(26), fill=teal) gx0, gy0, gx1, gy1 = 880, 100, dw - 30, 420 d.rectangle((gx0, gy0, gx1, gy1), outline=dim, width=2) tr = np.cumsum(np.random.default_rng(5).standard_normal(120)) * 9 tr = (tr - tr.min()) / (tr.max() - tr.min()) pts = [(gx0 + 8 + i * (gx1 - gx0 - 16) / 119, gy1 - 16 - t * (gy1 - gy0 - 32)) for i, t in enumerate(tr)] d.line(pts, fill=teal, width=3) d.text((gx0 + 10, gy0 + 8), 'PRESSURE / 24 H', font=font(20), fill=dim) d.text((gx0, 450), 'CABIN 26.0 \u00b0C', font=font(34, True), fill=white) d.text((gx0, 500), 'O2 20.9 % CO2 0.04 %', font=font(22), fill=teal) d.text((30, dh - 70), 'PENAL CONTRACT / DAY 0412', font=font(24), fill=dim) d.rectangle((2, 2, dw - 3, dh - 3), outline=dim, width=2) S.paste(T_, (0, 660)); cell('screen_desk', 0, 660, dw, dh) # --- bunk panel (350 x 230 mm) bw, bh = 608, 400 T_ = Image.new('RGB', (bw, bh), (3, 5, 6)); d = ImageDraw.Draw(T_) d.text((28, 18), '04:12', font=font(120, True), fill=white) d.text((32, 160), 'SHIP TIME', font=font(22), fill=dim) d.text((340, 160), '26.0 \u00b0C', font=font(40, True), fill=teal) for i, (nm, v) in enumerate((('LIGHT', 0.35), ('VENT', 0.6), ('WAKE', 0.8))): y = 230 + i * 50 d.text((32, y), nm, font=font(24), fill=teal) d.rectangle((150, y + 8, 560, y + 22), outline=dim, width=2) d.rectangle((152, y + 10, 152 + int(406 * v), y + 20), fill=amber if i == 2 else teal) d.rectangle((2, 2, bw - 3, bh - 3), outline=dim, width=2) S.paste(T_, (1420, 660)); cell('screen_bunk', 1420, 660, bw, bh) S.save(os.path.join(TEX, 'T_CQ_Screens.png')) if __name__ == '__main__': atlas = {} panel_rough(); quilt_normal(); fabric_normal(); camo(); perf() decals(atlas); screens(atlas) json.dump(atlas, open(os.path.join(TEX, 'atlas.json'), 'w'), indent=1) print('TEXTURES OK', sorted(k for k in atlas if not k.endswith('_aspect')))