235 lines
6.6 KiB
Python
235 lines
6.6 KiB
Python
#!/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'<polygon points="{pts}" fill="none" '
|
|
f'stroke="{stroke}" stroke-width="{stroke_width:.3f}" stroke-linejoin="round" />'
|
|
)
|
|
|
|
|
|
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'<circle cx="{p.x:.2f}" cy="{p.y:.2f}" r="{dot_radius:.2f}" fill="{dot_color}" />')
|
|
|
|
svg = "\n".join(
|
|
[
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
|
|
f'<rect x="0" y="0" width="100%" height="100%" fill="{background}" />',
|
|
*elements,
|
|
*circles,
|
|
"</svg>",
|
|
]
|
|
)
|
|
|
|
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()
|