#!/usr/bin/env python3 """Generate a triangulated mesh graphic as an SVG file. Example: python scripts/generate_triangulated_mesh.py --width 1920 --height 1080 --output mesh.svg """ from __future__ import annotations import argparse import math import random from dataclasses import dataclass from pathlib import Path @dataclass(frozen=True) class Point: x: float y: float def clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate a triangulated mesh SVG.") parser.add_argument("--width", type=int, required=True, help="Graphic width in pixels.") parser.add_argument("--height", type=int, required=True, help="Graphic height in pixels.") parser.add_argument( "--output", type=Path, default=Path("triangulated-mesh.svg"), help="Output SVG path (default: triangulated-mesh.svg)", ) parser.add_argument( "--cell-size", type=float, default=120.0, help="Approximate grid cell size in pixels when x/y step are not set.", ) parser.add_argument( "--x-step", type=float, default=None, help="Horizontal point spacing in pixels (overrides --cell-size on x-axis).", ) parser.add_argument( "--y-step", type=float, default=None, help="Vertical point spacing in pixels (overrides --cell-size on y-axis).", ) parser.add_argument( "--jitter", type=float, default=0.35, help="Point jitter amount as a fraction of cell size (0.0-0.5).", ) parser.add_argument( "--seed", type=int, default=None, help="Random seed for reproducible output.", ) parser.add_argument( "--stroke", type=float, default=1.25, help="Edge stroke width in pixels.", ) parser.add_argument( "--dot-radius", type=float, default=1.9, help="Vertex dot radius in pixels.", ) parser.add_argument( "--line-color", type=str, default="#334155", help="Edge color as hex (default: #334155).", ) parser.add_argument( "--dot-color", type=str, default="#0f172a", help="Dot color as hex (default: #0f172a).", ) parser.add_argument( "--background", type=str, default="#f8fafc", help="Background color as hex (default: #f8fafc).", ) return parser.parse_args() def build_points( width: int, height: int, x_step: float, y_step: float, jitter_frac: float, rng: random.Random, ) -> list[list[Point]]: cols = max(2, math.ceil(width / x_step) + 1) rows = max(2, math.ceil(height / y_step) + 1) points: list[list[Point]] = [] jitter = clamp(jitter_frac, 0.0, 0.5) * min(x_step, y_step) for row in range(rows): y = (height * row) / (rows - 1) row_points: list[Point] = [] for col in range(cols): x = (width * col) / (cols - 1) # Keep border anchored to make the mesh fill the canvas cleanly. if 0 < row < rows - 1 and 0 < col < cols - 1: x += rng.uniform(-jitter, jitter) y_jittered = y + rng.uniform(-jitter, jitter) else: y_jittered = y row_points.append(Point(x=clamp(x, 0.0, float(width)), y=clamp(y_jittered, 0.0, float(height)))) points.append(row_points) return points def generate_triangles(points: list[list[Point]], rng: random.Random) -> list[tuple[Point, Point, Point]]: triangles: list[tuple[Point, Point, Point]] = [] rows = len(points) cols = len(points[0]) if rows else 0 for row in range(rows - 1): for col in range(cols - 1): p00 = points[row][col] p10 = points[row][col + 1] p01 = points[row + 1][col] p11 = points[row + 1][col + 1] if rng.random() < 0.5: triangles.append((p00, p10, p11)) triangles.append((p00, p11, p01)) else: triangles.append((p00, p10, p01)) triangles.append((p10, p11, p01)) return triangles def svg_polygon(points: tuple[Point, Point, Point], stroke: str, stroke_width: float) -> str: pts = " ".join(f"{p.x:.2f},{p.y:.2f}" for p in points) return ( f'' ) def write_svg( width: int, height: int, triangles: list[tuple[Point, Point, Point]], stroke_width: float, dot_radius: float, line_color: str, dot_color: str, background: str, output: Path, points: list[list[Point]], ) -> None: elements: list[str] = [] for tri in triangles: elements.append(svg_polygon(tri, stroke=line_color, stroke_width=stroke_width)) circles: list[str] = [] for row in points: for p in row: circles.append(f'') svg = "\n".join( [ '', f'', f'', *elements, *circles, "", ] ) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(svg, encoding="utf-8") def main() -> None: args = parse_args() if args.width <= 0 or args.height <= 0: raise SystemExit("--width and --height must be positive integers") if args.cell_size <= 1: raise SystemExit("--cell-size must be > 1") x_step = args.x_step if args.x_step is not None else args.cell_size y_step = args.y_step if args.y_step is not None else args.cell_size if x_step <= 1 or y_step <= 1: raise SystemExit("--x-step and --y-step must be > 1") rng = random.Random(args.seed) points = build_points(args.width, args.height, x_step, y_step, args.jitter, rng) triangles = generate_triangles(points, rng) write_svg( args.width, args.height, triangles, args.stroke, args.dot_radius, args.line_color, args.dot_color, args.background, args.output, points, ) print(f"Generated {len(triangles)} triangles -> {args.output}") if __name__ == "__main__": main()