"""Aethel-9 hull texture set, painted from the sheet: the hull's UV is its surface parameter (u = phi round the hull, v = SIDE x along it), so every texel has a known 3D point; features drawn on SIDE and TOP canvases (sheet pixels x 8) are projected onto it through the texel's normal. Writes textures/T_Aethel_Hull_{BaseColor,ORM,Normal}.png (4096). Run with the system Python (PIL): python build/ae_textures.py. apply_hull_material() runs inside Blender. ORM: R = ambient occlusion (1), G = roughness, B = metallic. Normal: OpenGL (+Y); flip green for Unreal's import or tick "Flip Green Channel" on the texture. """ import os import numpy as np HERE = os.path.dirname(os.path.abspath(__file__)) TEXDIR = os.path.join(os.path.dirname(HERE), 'textures') RES = 4096 K = 8 # canvas pixels per sheet pixel (1.56 mm) SIDE_BOX = (225, 90, 705, 372) # SIDE canvas: x 225-705, y 90-372 TOP_BOX = (225, -110, 705, 110) # TOP canvas: SIDE x, X (port +) -110..110 # seams on the flanks (SIDE, both sides), read from reference/z4_seams.png and z4_swin.png SEAMS_SIDE = [ [(545, 208), (566, 196), (586, 178), (598, 160), (604, 149)], # A: from the strip's end up to the deck [(653, 158), (640, 172), (626, 188), (613, 207), (614, 230), (619, 252)], # B: the aft panel's front edge [(546, 213), (600, 213.5), (659, 215)], # C: under the strip, aft [(316, 231), (395, 231), (437, 232), (452, 255), (550, 255)], # D: the stepped lower seam [(265, 238), (309, 238)], # nose waist, belt to the strip [(338, 147), (394, 139)], # canopy roof edge (above the window) [(395, 260), (545, 256)], # chine over the nacelle ] # recesses (closed outlines, SIDE): the vent slot, the lower slot, the trapezoid intake RECESS_SIDE = [ [(489, 231), (538, 231), (541, 236.5), (538, 242.5), (489, 242.5), (486, 236.5)], [(483, 251), (533, 249.5), (536, 254), (533, 258.5), (481, 260), (479, 255.5)], ] # SIDE's trapezoid (536-595 x 219-236) is the upper thruster's wing since the concept pass: a part, not paint SEAMS_TOP = [ [(436, 34), (500, 40), (560, 42), (610, 40), (650, 36)], [(436, -34), (500, -40), (560, -42), (610, -40), (650, -36)], [(610, 40), (612, 0), (610, -40)], ] import sys as _sys _sys.path.insert(0, HERE) import ae_lib as _L NAME = (_L.TEXT_NAME['text'], _L.TEXT_NAME['box']) SUB = (_L.TEXT_SUB['text'], _L.TEXT_SUB['box']) def _font(size, bold=True): from PIL import ImageFont for f in ('C:/Windows/Fonts/bahnschrift.ttf', 'C:/Windows/Fonts/arialbd.ttf'): if os.path.exists(f): ft = ImageFont.truetype(f, size) try: ft.set_variation_by_name('Bold' if bold else 'SemiBold') except Exception: pass return ft return ImageFont.load_default() def _text_img(text, w, h, bold=True, mirror=False): """Text fitted to a w x h box (canvas pixels), white on black, mirrored for the starboard flank.""" from PIL import Image, ImageDraw size = h while True: ft = _font(size, bold) bb = ft.getbbox(text) if bb[2] - bb[0] <= w and bb[3] - bb[1] <= h * 1.02: break size -= 1 img = Image.new('L', (w, h), 0) d = ImageDraw.Draw(img) bb = ft.getbbox(text) tw, th = bb[2] - bb[0], bb[3] - bb[1] # stretch to the box's width as drawn on the sheet big = Image.new('L', (tw + 4, th + 4), 0) ImageDraw.Draw(big).text((2 - bb[0], 2 - bb[1]), text, fill=255, font=ft) big = big.resize((w, h), Image.LANCZOS) if mirror: big = big.transpose(Image.FLIP_LEFT_RIGHT) return big def paint_canvases(): from PIL import Image, ImageDraw x0, y0, x1, y1 = SIDE_BOX cw, ch = (x1 - x0) * K, (y1 - y0) * K seam = Image.new('L', (cw, ch), 0); rec = Image.new('L', (cw, ch), 0) txtL = Image.new('L', (cw, ch), 0); txtR = Image.new('L', (cw, ch), 0) ds, dr = ImageDraw.Draw(seam), ImageDraw.Draw(rec) P = lambda pts: [((x - x0) * K, (y - y0) * K) for x, y in pts] for s in SEAMS_SIDE: ds.line(P(s), fill=255, width=5, joint='curve') for r in RECESS_SIDE: dr.polygon(P(r), fill=255) for (text, (bx0, by0, bx1, by1)), bold in ((NAME, True), (SUB, False)): w, h = int((bx1 - bx0) * K), int((by1 - by0) * K) txtL.paste(_text_img(text, w, h, bold), (int((bx0 - x0) * K), int((by0 - y0) * K))) txtR.paste(_text_img(text, w, h, bold, mirror=True), (int((bx0 - x0) * K), int((by0 - y0) * K))) tx0, tX0, tx1, tX1 = TOP_BOX tw_, th_ = (tx1 - tx0) * K, (tX1 - tX0) * K tseam = Image.new('L', (tw_, th_), 0); dt = ImageDraw.Draw(tseam) for s in SEAMS_TOP: dt.line([((x - tx0) * K, (X - tX0) * K) for x, X in s], fill=255, width=5, joint='curve') A = lambda im: np.asarray(im, np.float32) / 255.0 return dict(seam=A(seam), rec=A(rec), txtL=A(txtL), txtR=A(txtR), tseam=A(tseam)) def _sample(img, a, b, box): """Bilinear sample of a canvas at sheet coords a (across), b (down).""" x0, y0, x1, y1 = box fx = (a - x0) * K - 0.5; fy = (b - y0) * K - 0.5 h, w = img.shape ok = (fx >= 0) & (fx < w - 1) & (fy >= 0) & (fy < h - 1) fx = np.clip(fx, 0, w - 1.001); fy = np.clip(fy, 0, h - 1.001) ix = fx.astype(int); iy = fy.astype(int); tx = fx - ix; ty = fy - iy v = (img[iy, ix] * (1 - tx) * (1 - ty) + img[iy, ix + 1] * tx * (1 - ty) + img[iy + 1, ix] * (1 - tx) * ty + img[iy + 1, ix + 1] * tx * ty) return np.where(ok, v, 0.0) def paint(res=RES): import sys sys.path.insert(0, HERE) import ae_lib as L from PIL import Image C = paint_canvases() os.makedirs(TEXDIR, exist_ok=True) base = np.zeros((res, res, 3), np.float32); rough = np.zeros((res, res), np.float32) height = np.zeros((res, res), np.float32); metal = np.zeros((res, res), np.float32) rng = np.random.default_rng(9) # low-frequency paint variation lf = rng.normal(0, 1, (res // 128 + 2, res // 128 + 2)).astype(np.float32) lf = np.asarray(Image.fromarray(lf).resize((res, res), Image.BICUBIC)) # paint chips (the concept: worn to bare metal low on the flanks, round the nose's bottom and at the stern): # fractal value noise in texel space, thresholded by a wear density that follows the hull's height and length def octave(cells, seed): g = np.random.default_rng(seed).random((cells + 3, cells + 3)).astype(np.float32) return np.asarray(Image.fromarray(g).resize((res + res // cells * 3, res + res // cells * 3), Image.BICUBIC))[:res, :res] fine = 0.5 * octave(160, 2) + 0.3 * octave(420, 3) + 0.2 * octave(900, 4) fine = (fine - fine.mean()) / fine.std() coarse = octave(18, 1); coarse = (coarse - coarse.mean()) / coarse.std() fn = fine chunk = 256 for r0 in range(0, res, chunk): rows = np.arange(r0, r0 + chunk) # image row 0 = top = v-coordinate 1 (UV origin bottom-left) w = 1.0 - (rows + 0.5) / res u = (np.arange(res) + 0.5) / res Wv, Up = np.meshgrid(w, u, indexing='ij') v = L.V_TIP + Wv * (L.V_END - L.V_TIP) X, _, y = L.surf(v, Up) # normal (px frame is fine for direction) e = 0.05 a = np.stack(L.surf(v + e, Up), -1) - np.stack(L.surf(v - e, Up), -1) b = np.stack(L.surf(v, Up + 5e-4), -1) - np.stack(L.surf(v, Up - 5e-4), -1) n = np.cross(b, a); n /= np.linalg.norm(n, axis=-1, keepdims=True) + 1e-9 # outward: away from the section's middle; in the px frame (X, v, y-down) "up" is -y yt, yb, _ = L.lines(v) flip = np.where(n[..., 0] * X + n[..., 2] * (y - (yt + yb) / 2) < 0, -1, 1) nx, nup = n[..., 0] * flip, -n[..., 2] * flip ws = np.clip((np.abs(nx) - 0.25) / 0.25, 0, 1) wt = np.clip((nup - 0.55) / 0.2, 0, 1) seam = _sample(C['seam'], v, y, SIDE_BOX) * ws rec = _sample(C['rec'], v, y, SIDE_BOX) * ws txt = np.where(X >= 0, _sample(C['txtL'], v, y, SIDE_BOX), _sample(C['txtR'], v, y, SIDE_BOX)) * ws tseam = _sample(C['tseam'], v, X, TOP_BOX) * wt seam = np.maximum(seam, tseam) sl = slice(r0, r0 + chunk) tpar = (y - yt) / (yb - yt) dens = (0.75 * np.clip((tpar - 0.32) / 0.40, 0, 1) + 0.45 * np.clip((300 - v) / 50, 0, 1) * (tpar > 0.45) + 0.5 * np.clip((v - 600) / 50, 0, 1)) region = np.clip(dens + 0.30 * coarse[sl], 0, 1) * (1 - 0.9 * txt) thr = 2.35 - 1.25 * region z = fine[sl] - thr chip = np.clip(z / 0.18 + 0.5, 0, 1) * (z > -0.09) chip = np.maximum(chip, (seam > 0.3) * (fine[sl] > 1.2) * 0.7) # chipped along some seams paint_c = np.array([0.034, 0.045, 0.066], np.float32) * (1 + 0.06 * lf[sl])[..., None] col = paint_c * (1 - 0.45 * seam[..., None]) worn = np.array([0.15, 0.16, 0.18], np.float32) * (0.8 + 0.12 * np.clip(coarse[sl], -1.5, 1.5))[..., None] col = col * (1 - chip[..., None]) + worn * chip[..., None] col = col * (1 - rec[..., None]) + np.array([0.012, 0.013, 0.016]) * rec[..., None] col = col * (1 - txt[..., None]) + np.array([0.78, 0.81, 0.86]) * txt[..., None] base[sl] = col rough[sl] = 0.40 + 0.15 * seam + 0.10 * rec - 0.08 * chip - 0.05 * txt metal[sl] = 0.35 * (1 - chip) * (1 - txt) + 0.92 * chip height[sl] = -1.0 * seam - 2.5 * rec + 0.12 * txt - 0.25 * chip # normal from height (mm) with the texel's metric size import ae_lib as L2 vv = L2.V_TIP + (1.0 - (np.arange(res) + 0.5) / res) * (L2.V_END - L2.V_TIP) ph = np.linspace(0, 1, 401) Vg, Pg = np.meshgrid(vv, ph, indexing='ij') Q = L2.surf_world(Vg, Pg) du_m = np.linalg.norm(np.diff(Q, axis=1), axis=-1).sum(1) / res dw_m = (L2.V_END - L2.V_TIP) * L2.S / res hmm = height * 1.0 # millimetres gx = (np.roll(hmm, -1, 1) - np.roll(hmm, 1, 1)) / 2 / (du_m[:, None] * 1000) gy = (np.roll(hmm, 1, 0) - np.roll(hmm, -1, 0)) / 2 / (dw_m * 1000) nrm = np.stack([-gx, -gy, np.ones_like(gx)], -1); nrm /= np.linalg.norm(nrm, axis=-1, keepdims=True) to8 = lambda a: (np.clip(a, 0, 1) * 255 + 0.5).astype(np.uint8) srgb = np.where(base <= 0.0031308, base * 12.92, 1.055 * np.power(np.clip(base, 0, 1), 1 / 2.4) - 0.055) Image.fromarray(to8(srgb)).save(os.path.join(TEXDIR, 'T_Aethel_Hull_BaseColor.png')) orm = np.stack([np.ones_like(rough), rough, metal], -1) Image.fromarray(to8(orm)).save(os.path.join(TEXDIR, 'T_Aethel_Hull_ORM.png')) Image.fromarray(to8(nrm * 0.5 + 0.5)).save(os.path.join(TEXDIR, 'T_Aethel_Hull_Normal.png')) print('painted', TEXDIR) def apply_hull_material(ob, texdir): """Inside Blender: give the hull skin's 'Hull' slot the painted texture set.""" import bpy mat = None for slot in ob.material_slots: if slot.material and slot.material.name == 'Hull': mat = slot.material if mat is None: return nt = mat.node_tree p = nt.nodes['Principled BSDF'] def img(name, colour): path = os.path.join(texdir, name) if not os.path.exists(path): return None n = nt.nodes.new('ShaderNodeTexImage') n.image = bpy.data.images.load(path, check_existing=True) n.image.colorspace_settings.name = 'sRGB' if colour else 'Non-Color' return n bc = img('T_Aethel_Hull_BaseColor.png', True) if bc: nt.links.new(bc.outputs['Color'], p.inputs['Base Color']) orm = img('T_Aethel_Hull_ORM.png', False) if orm: sep = nt.nodes.new('ShaderNodeSeparateColor') nt.links.new(orm.outputs['Color'], sep.inputs['Color']) nt.links.new(sep.outputs['Green'], p.inputs['Roughness']) nt.links.new(sep.outputs['Blue'], p.inputs['Metallic']) nm = img('T_Aethel_Hull_Normal.png', False) if nm: nmap = nt.nodes.new('ShaderNodeNormalMap'); nmap.uv_map = 'UVMap' nt.links.new(nm.outputs['Color'], nmap.inputs['Color']) nt.links.new(nmap.outputs['Normal'], p.inputs['Normal']) if __name__ == '__main__': paint()