|
| 1 | +"""Generate Pretzel's assets: a trefoil-knot mesh and background wallpapers. |
| 2 | +
|
| 3 | +The generated files are already checked into version control, so you only |
| 4 | +need to run this script if you want to tweak the assets: |
| 5 | +
|
| 6 | +$ uv run make_assets.py |
| 7 | +""" |
| 8 | + |
| 9 | +import math |
| 10 | +import pathlib |
| 11 | + |
| 12 | +ASSETS_DIR = pathlib.Path(__file__).parent / "src" / "pretzel" / "assets" |
| 13 | + |
| 14 | +SEGMENTS = 240 |
| 15 | +RING_POINTS = 16 |
| 16 | +TUBE_RADIUS = 0.62 |
| 17 | + |
| 18 | +WALLPAPER_SIZE = 512 |
| 19 | +WALLPAPERS = { |
| 20 | + "bakery_dawn.ppm": ((252, 210, 153), (146, 90, 118), (255, 236, 179)), |
| 21 | + "bakery_noon.ppm": ((214, 235, 251), (245, 232, 201), (255, 255, 224)), |
| 22 | + "bakery_dusk.ppm": ((94, 63, 107), (233, 156, 90), (255, 214, 138)), |
| 23 | +} |
| 24 | + |
| 25 | + |
| 26 | +def trace_trefoil(t): |
| 27 | + return ( |
| 28 | + math.sin(t) + 2.0 * math.sin(2.0 * t), |
| 29 | + math.cos(t) - 2.0 * math.cos(2.0 * t), |
| 30 | + -math.sin(3.0 * t) * 1.2, |
| 31 | + ) |
| 32 | + |
| 33 | + |
| 34 | +def subtract(a, b): |
| 35 | + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) |
| 36 | + |
| 37 | + |
| 38 | +def cross(a, b): |
| 39 | + return ( |
| 40 | + a[1] * b[2] - a[2] * b[1], |
| 41 | + a[2] * b[0] - a[0] * b[2], |
| 42 | + a[0] * b[1] - a[1] * b[0], |
| 43 | + ) |
| 44 | + |
| 45 | + |
| 46 | +def dot(a, b): |
| 47 | + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] |
| 48 | + |
| 49 | + |
| 50 | +def normalize(vector): |
| 51 | + length = math.sqrt(dot(vector, vector)) or 1.0 |
| 52 | + return (vector[0] / length, vector[1] / length, vector[2] / length) |
| 53 | + |
| 54 | + |
| 55 | +def sweep_tube(): |
| 56 | + step = 2.0 * math.pi / SEGMENTS |
| 57 | + centers = [trace_trefoil(index * step) for index in range(SEGMENTS)] |
| 58 | + tangents = [ |
| 59 | + normalize(subtract(centers[(i + 1) % SEGMENTS], centers[i - 1])) |
| 60 | + for i in range(SEGMENTS) |
| 61 | + ] |
| 62 | + normal = normalize(cross(tangents[0], (0.0, 0.0, 1.0))) |
| 63 | + vertices = [] |
| 64 | + for center, tangent in zip(centers, tangents): |
| 65 | + projection = dot(normal, tangent) |
| 66 | + normal = normalize( |
| 67 | + ( |
| 68 | + normal[0] - projection * tangent[0], |
| 69 | + normal[1] - projection * tangent[1], |
| 70 | + normal[2] - projection * tangent[2], |
| 71 | + ) |
| 72 | + ) |
| 73 | + binormal = cross(tangent, normal) |
| 74 | + for point in range(RING_POINTS): |
| 75 | + angle = 2.0 * math.pi * point / RING_POINTS |
| 76 | + radial = math.cos(angle), math.sin(angle) |
| 77 | + vertices.append( |
| 78 | + tuple( |
| 79 | + center[axis] |
| 80 | + + TUBE_RADIUS |
| 81 | + * (radial[0] * normal[axis] + radial[1] * binormal[axis]) |
| 82 | + for axis in range(3) |
| 83 | + ) |
| 84 | + ) |
| 85 | + faces = [] |
| 86 | + for segment in range(SEGMENTS): |
| 87 | + next_segment = (segment + 1) % SEGMENTS |
| 88 | + for point in range(RING_POINTS): |
| 89 | + next_point = (point + 1) % RING_POINTS |
| 90 | + a = segment * RING_POINTS + point |
| 91 | + b = segment * RING_POINTS + next_point |
| 92 | + c = next_segment * RING_POINTS + point |
| 93 | + d = next_segment * RING_POINTS + next_point |
| 94 | + faces.append((a, c, b)) |
| 95 | + faces.append((b, c, d)) |
| 96 | + return vertices, faces |
| 97 | + |
| 98 | + |
| 99 | +def write_mesh(path): |
| 100 | + vertices, faces = sweep_tube() |
| 101 | + with path.open("w", encoding="utf-8") as file: |
| 102 | + file.write(f"# Trefoil-knot pretzel: {len(vertices)} vertices,") |
| 103 | + file.write(f" {len(faces)} faces\n") |
| 104 | + for x, y, z in vertices: |
| 105 | + file.write(f"v {x:.6f} {y:.6f} {z:.6f}\n") |
| 106 | + for a, b, c in faces: |
| 107 | + file.write(f"f {a + 1} {b + 1} {c + 1}\n") |
| 108 | + print(f"Wrote {path} ({len(vertices)} vertices, {len(faces)} faces)") |
| 109 | + |
| 110 | + |
| 111 | +def blend(low, high, amount): |
| 112 | + return tuple( |
| 113 | + round(low[channel] + (high[channel] - low[channel]) * amount) |
| 114 | + for channel in range(3) |
| 115 | + ) |
| 116 | + |
| 117 | + |
| 118 | +def write_wallpaper(path, top, bottom, glow): |
| 119 | + size = WALLPAPER_SIZE |
| 120 | + lights = [ |
| 121 | + (size * 0.25, size * 0.3, size * 0.22), |
| 122 | + (size * 0.7, size * 0.55, size * 0.3), |
| 123 | + (size * 0.45, size * 0.8, size * 0.18), |
| 124 | + ] |
| 125 | + rows = bytearray() |
| 126 | + for y in range(size): |
| 127 | + vertical = y / (size - 1) |
| 128 | + base = blend(top, bottom, vertical) |
| 129 | + for x in range(size): |
| 130 | + halo = 0.0 |
| 131 | + for cx, cy, radius in lights: |
| 132 | + distance = math.hypot(x - cx, y - cy) |
| 133 | + halo += max(0.0, 1.0 - distance / radius) ** 2 |
| 134 | + color = blend(base, glow, min(1.0, halo)) |
| 135 | + rows.extend(color) |
| 136 | + with path.open("wb") as file: |
| 137 | + file.write(b"P6\n%d %d\n255\n" % (size, size)) |
| 138 | + file.write(rows) |
| 139 | + print(f"Wrote {path} ({size}x{size})") |
| 140 | + |
| 141 | + |
| 142 | +def main(): |
| 143 | + ASSETS_DIR.mkdir(parents=True, exist_ok=True) |
| 144 | + write_mesh(ASSETS_DIR / "pretzel.mdl") |
| 145 | + for name, (top, bottom, glow) in WALLPAPERS.items(): |
| 146 | + write_wallpaper(ASSETS_DIR / name, top, bottom, glow) |
| 147 | + |
| 148 | + |
| 149 | +if __name__ == "__main__": |
| 150 | + main() |
0 commit comments