Refactor site to new theme
Some checks failed
Build and Deploy Hugo / deploy (push) Failing after 20s
Some checks failed
Build and Deploy Hugo / deploy (push) Failing after 20s
This commit is contained in:
parent
1c5cdb3b97
commit
2519b96a86
16 changed files with 2590 additions and 83 deletions
235
scripts/generate_triangulated_mesh.py
Normal file
235
scripts/generate_triangulated_mesh.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue