"""Turn any flat 2D shape into chiselled metal. The Asheron's Call wordmark is bevelled chrome letterforms. The cheap, accurate way to fake that from a silhouette: take the distance transform of the mask, treat distance-to-edge as height with a bevel profile, derive surface normals from the height gradient, then shade those normals through the same matcap the 3D ring uses -- so letter and ring are lit by the same imaginary environment. """ import numpy as np from PIL import Image, ImageDraw, ImageFilter from scipy.ndimage import distance_transform_edt, gaussian_filter def bevel_normals(mask, bevel_px=26, profile="chisel", plateau=0.0): """mask: HxW float 0..1. Returns (normals HxWx3, height HxW).""" inside = mask > 0.5 d = distance_transform_edt(inside).astype(np.float32) t = np.clip(d / max(bevel_px, 1e-6), 0, 1) if profile == "round": h = np.sqrt(np.clip(1 - (1 - t) ** 2, 0, 1)) elif profile == "flat": h = t else: # chisel: linear ramp to a flat top -- sharp facet break h = np.clip(t / max(1e-6, (1 - plateau)), 0, 1) # The distance transform has a crease along the medial axis of every stroke; # differentiating it raw produces a visible hatched ridge. Smooth the height # field first -- it costs nothing and removes the artefact entirely. h = gaussian_filter(h, sigma=max(bevel_px * 0.16, 0.8)) gy, gx = np.gradient(h * bevel_px) nx, ny, nz = -gx, -gy, np.ones_like(h) ln = np.sqrt(nx * nx + ny * ny + nz * nz) n = np.stack([nx / ln, ny / ln, nz / ln], axis=-1) return n, h def shade(mask, matcap, bevel_px=26, profile="chisel", plateau=0.35, ao=0.35, exposure=1.0, tilt=(0.0, 0.0)): """Shade a mask as metal. Returns an RGBA image.""" n, h = bevel_normals(mask, bevel_px, profile, plateau) nx = np.clip(n[..., 0] + tilt[0], -1, 1) ny = np.clip(n[..., 1] + tilt[1], -1, 1) mh, mw = matcap.shape[0], matcap.shape[1] mu = np.clip((nx * 0.5 + 0.5) * (mw - 1), 0, mw - 1).astype(np.int32) mv = np.clip((1.0 - (ny * 0.5 + 0.5)) * (mh - 1), 0, mh - 1).astype(np.int32) rgb = matcap[mv, mu][..., :3] * exposure # darken the bevel skirt so the form reads as raised rgb = rgb * (1.0 - ao * (1.0 - h))[..., None] a = np.clip(mask, 0, 1) out = np.concatenate([np.clip(rgb, 0, 1), a[..., None]], axis=2) return Image.fromarray((out * 255).astype(np.uint8), "RGBA") # --------------------------------------------------------------- letterforms def letter_from_font(ch="A", size=1024, font_path=r"C:\Windows\Fonts\palab.ttf", fill=0.86): """Render a glyph to a mask. A real serif gives correct counters and serifs; the chisel pass supplies the forged-metal reading.""" from PIL import ImageFont S = 3 W = size * S lo, hi = 10, W * 3 best = None while lo <= hi: mid = (lo + hi) // 2 f = ImageFont.truetype(font_path, mid) box = f.getbbox(ch) w, h = box[2] - box[0], box[3] - box[1] if max(w, h) <= W * fill: best = (mid, box); lo = mid + 1 else: hi = mid - 1 px, box = best f = ImageFont.truetype(font_path, px) img = Image.new("L", (W, W), 0) d = ImageDraw.Draw(img) w, h = box[2] - box[0], box[3] - box[1] d.text(((W - w) / 2 - box[0], (W - h) / 2 - box[1]), ch, font=f, fill=255) return np.asarray(img.resize((size, size), Image.LANCZOS), dtype=np.float32) / 255.0 def letter_A(size=1024, weight=1.0, serif=1.0): """A hand-built chiselled 'A' -- angular, sharp apex, flared serif feet. Hand-built rather than set from a system font: full control over the barbed terminals that make the AC wordmark look forged, and no font licence riding along in a project logo. """ S = 4 # supersample W = size * S img = Image.new("L", (W, W), 0) d = ImageDraw.Draw(img) def P(pts): return [(int(x / 1000 * W), int(y / 1000 * W)) for x, y in pts] w = 105 * weight apex_y, foot_y = 70, 880 # two tapered strokes meeting at the apex left = [(500 - w * 0.28, apex_y), (500 + w * 0.42, apex_y), (330 + w * 0.55, foot_y), (330 - w * 0.62, foot_y)] right = [(500 + w * 0.28, apex_y), (500 - w * 0.42, apex_y), (670 - w * 0.55, foot_y), (670 + w * 0.62, foot_y)] d.polygon(P(left), fill=255) d.polygon(P(right), fill=255) # crossbar, dipped in the middle like the AC wordmark's angular bar d.polygon(P([(300, 646), (700, 646), (700, 646 + 74), (300, 646 + 74)]), fill=255) # flared serif feet -- barbed, wider on the outside sf = 96 * serif d.polygon(P([(330 - w * 0.62, foot_y), (330 + w * 0.55, foot_y), (330 + w * 0.55 + sf * 0.35, foot_y + 62), (330 - w * 0.62 - sf, foot_y + 62), (330 - w * 0.62 - sf * 1.45, foot_y + 20)]), fill=255) d.polygon(P([(670 + w * 0.62, foot_y), (670 - w * 0.55, foot_y), (670 - w * 0.55 - sf * 0.35, foot_y + 62), (670 + w * 0.62 + sf, foot_y + 62), (670 + w * 0.62 + sf * 1.45, foot_y + 20)]), fill=255) # apex spur d.polygon(P([(500, 34), (500 + w * 0.60, 158), (500 - w * 0.60, 158)]), fill=255) img = img.resize((size, size), Image.LANCZOS) return np.asarray(img, dtype=np.float32) / 255.0 def arch_mask(size=1024, R=0.66, thick=0.155, leg_bottom=0.86, wob_seed=5): """A standing portal arch: half-ring on two legs that thicken toward the floor.""" y, x = np.mgrid[0:size, 0:size].astype(np.float32) x = (x / (size - 1)) * 2 - 1 y = (y / (size - 1)) * 2 - 1 cy = -0.10 r = np.sqrt(x * x + (y - cy) ** 2) th = np.arctan2(y - cy, x) wob = 0.018 * np.sin(7.0 * th + 1.3) + 0.012 * np.sin(13.0 * th + 0.4) band = np.abs(r - (R + wob)) < thick / 2 upper = band & (y <= cy) # legs flare as they descend, like a forged post set into a plinth flare = thick / 2 * (1.0 + 0.55 * np.clip((y - cy) / max(leg_bottom - cy, 1e-6), 0, 1) ** 1.6) legs = (np.abs(np.abs(x) - R) < flare) & (y > cy) & (y < leg_bottom) plinth = (np.abs(y - (leg_bottom + thick * 0.34)) < thick * 0.42) & (np.abs(x) < R + thick * 1.5) m = (upper | legs | plinth).astype(np.float32) im = Image.fromarray((m * 255).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(1.2)) return np.asarray(im, dtype=np.float32) / 255.0 def bone_matcap(size=512): """Ivory tusk: warm, waxy, low-frequency highlight rather than mirror.""" yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) x = (xx / (size - 1)) * 2 - 1 y = 1 - (yy / (size - 1)) * 2 r2 = x * x + y * y inside = (r2 <= 1.0).astype(np.float32) lam = np.clip(0.30 + 0.72 * (y * 0.55 + 0.55), 0, 1.4) warm = np.array([1.00, 0.965, 0.865], dtype=np.float32) base = lam[..., None] * warm base = base + 0.42 * np.exp(-(((x + 0.30) ** 2 + (y - 0.42) ** 2)) / 0.075)[..., None] # subsurface warmth low down base = base + 0.20 * np.clip(-y, 0, 1)[..., None] * np.array([0.62, 0.46, 0.28], dtype=np.float32) base = base * (1.0 - 0.30 * np.clip((r2 - 0.60) / 0.40, 0, 1))[..., None] return np.concatenate([np.clip(base, 0, 1.4), inside[..., None]], axis=2).astype(np.float32)