990 lines
38 KiB
Python
990 lines
38 KiB
Python
"""Printable, randomly generated paper-golf courses.
|
|
|
|
Run without arguments to open the small desktop exporter, or use the command
|
|
line for repeatable output:
|
|
|
|
python golf_course_generator.py --output golf-courses.pdf --courses 36 --seed 20260723
|
|
|
|
The program deliberately uses only Python's standard library. It writes a
|
|
vector PDF containing four different mini-golf holes on every A4 page.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import random
|
|
import tkinter as tk
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from tkinter import filedialog, messagebox, ttk
|
|
from typing import Iterable
|
|
|
|
|
|
# PDF uses points. These dimensions are the ISO A4 standard in points.
|
|
A4_WIDTH = 595.276
|
|
A4_HEIGHT = 841.890
|
|
MM = 72.0 / 25.4
|
|
INCH = 72.0
|
|
BOTTOM_MARGIN = 10 * MM
|
|
TOP_STAPLE_MARGIN = 26 * MM
|
|
HORIZONTAL_GUTTER = 6 * MM
|
|
CARD_WIDTH = 3 * INCH
|
|
CARD_HEIGHT = 5 * INCH
|
|
SIDE_MARGIN = (A4_WIDTH - (2 * CARD_WIDTH) - HORIZONTAL_GUTTER) / 2
|
|
VERTICAL_GUTTER = A4_HEIGHT - TOP_STAPLE_MARGIN - BOTTOM_MARGIN - (2 * CARD_HEIGHT)
|
|
|
|
|
|
Color = tuple[float, float, float]
|
|
PAPER: Color = (0.988, 0.990, 0.975)
|
|
INK: Color = (0.115, 0.130, 0.120)
|
|
GRID: Color = (0.380, 0.390, 0.380)
|
|
FAIRWAY: Color = (0.800, 0.810, 0.795)
|
|
WATER: Color = (0.355, 0.365, 0.350)
|
|
SAND: Color = (0.955, 0.955, 0.935)
|
|
CUT_LINE: Color = (0.675, 0.685, 0.665)
|
|
GRID_SPACING = 12.0
|
|
GRID_COLUMNS = 16
|
|
GRID_ROWS = 26
|
|
GRID_X_OFFSET = GRID_SPACING / 2
|
|
GRID_Y_OFFSET = GRID_SPACING / 2
|
|
MIN_TEE_TO_CUP_DOTS = 12
|
|
MIN_TEE_TO_CUP_DISTANCE = MIN_TEE_TO_CUP_DOTS * GRID_SPACING
|
|
TARGET_TEE_TO_CUP_DOTS = 18
|
|
TARGET_TEE_TO_CUP_DISTANCE = TARGET_TEE_TO_CUP_DOTS * GRID_SPACING
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rect:
|
|
"""A rectangle described in PDF coordinates."""
|
|
|
|
x: float
|
|
y: float
|
|
width: float
|
|
height: float
|
|
|
|
@property
|
|
def right(self) -> float:
|
|
return self.x + self.width
|
|
|
|
@property
|
|
def top(self) -> float:
|
|
return self.y + self.height
|
|
|
|
def inset(self, amount: float) -> "Rect":
|
|
return Rect(
|
|
self.x + amount,
|
|
self.y + amount,
|
|
self.width - (2 * amount),
|
|
self.height - (2 * amount),
|
|
)
|
|
|
|
|
|
class PdfCanvas:
|
|
"""A deliberately small PDF content-stream builder for vector artwork."""
|
|
|
|
def __init__(self, width: float, height: float) -> None:
|
|
self.width = width
|
|
self.height = height
|
|
self._commands: list[str] = []
|
|
|
|
@staticmethod
|
|
def _number(value: float) -> str:
|
|
return f"{value:.3f}".rstrip("0").rstrip(".")
|
|
|
|
def command(self, value: str) -> None:
|
|
self._commands.append(value)
|
|
|
|
def save(self) -> None:
|
|
self.command("q")
|
|
|
|
def restore(self) -> None:
|
|
self.command("Q")
|
|
|
|
def fill(self, color: Color) -> None:
|
|
self.command(" ".join(self._number(v) for v in color) + " rg")
|
|
|
|
def stroke(self, color: Color) -> None:
|
|
self.command(" ".join(self._number(v) for v in color) + " RG")
|
|
|
|
def line_width(self, width: float) -> None:
|
|
self.command(f"{self._number(width)} w")
|
|
|
|
def dashed(self, pattern: Iterable[float], phase: float = 0) -> None:
|
|
values = " ".join(self._number(value) for value in pattern)
|
|
self.command(f"[{values}] {self._number(phase)} d")
|
|
|
|
def solid(self) -> None:
|
|
self.command("[] 0 d")
|
|
|
|
def line(self, x1: float, y1: float, x2: float, y2: float) -> None:
|
|
self.command(
|
|
f"{self._number(x1)} {self._number(y1)} m "
|
|
f"{self._number(x2)} {self._number(y2)} l S"
|
|
)
|
|
|
|
def rect(self, rect: Rect, *, fill: bool = False, stroke: bool = False) -> None:
|
|
self.command(
|
|
f"{self._number(rect.x)} {self._number(rect.y)} "
|
|
f"{self._number(rect.width)} {self._number(rect.height)} re"
|
|
)
|
|
self.command("B" if fill and stroke else "f" if fill else "S" if stroke else "n")
|
|
|
|
def _round_rect_path(self, rect: Rect, radius: float) -> None:
|
|
radius = max(0.0, min(radius, rect.width / 2, rect.height / 2))
|
|
# Cubic Bézier approximation for a quarter circle.
|
|
bend = radius * 0.55228475
|
|
x, y, w, h = rect.x, rect.y, rect.width, rect.height
|
|
self.command(f"{self._number(x + radius)} {self._number(y)} m")
|
|
self.command(f"{self._number(x + w - radius)} {self._number(y)} l")
|
|
self.command(
|
|
f"{self._number(x + w - radius + bend)} {self._number(y)} "
|
|
f"{self._number(x + w)} {self._number(y + radius - bend)} "
|
|
f"{self._number(x + w)} {self._number(y + radius)} c"
|
|
)
|
|
self.command(f"{self._number(x + w)} {self._number(y + h - radius)} l")
|
|
self.command(
|
|
f"{self._number(x + w)} {self._number(y + h - radius + bend)} "
|
|
f"{self._number(x + w - radius + bend)} {self._number(y + h)} "
|
|
f"{self._number(x + w - radius)} {self._number(y + h)} c"
|
|
)
|
|
self.command(f"{self._number(x + radius)} {self._number(y + h)} l")
|
|
self.command(
|
|
f"{self._number(x + radius - bend)} {self._number(y + h)} "
|
|
f"{self._number(x)} {self._number(y + h - radius + bend)} "
|
|
f"{self._number(x)} {self._number(y + h - radius)} c"
|
|
)
|
|
self.command(f"{self._number(x)} {self._number(y + radius)} l")
|
|
self.command(
|
|
f"{self._number(x)} {self._number(y + radius - bend)} "
|
|
f"{self._number(x + radius - bend)} {self._number(y)} "
|
|
f"{self._number(x + radius)} {self._number(y)} c h"
|
|
)
|
|
|
|
def round_rect(
|
|
self, rect: Rect, radius: float, *, fill: bool = False, stroke: bool = False
|
|
) -> None:
|
|
self._round_rect_path(rect, radius)
|
|
self.command("B" if fill and stroke else "f" if fill else "S" if stroke else "n")
|
|
|
|
def circle(
|
|
self, x: float, y: float, radius: float, *, fill: bool = False, stroke: bool = False
|
|
) -> None:
|
|
bend = radius * 0.55228475
|
|
n = self._number
|
|
self.command(f"{n(x + radius)} {n(y)} m")
|
|
self.command(f"{n(x + radius)} {n(y + bend)} {n(x + bend)} {n(y + radius)} {n(x)} {n(y + radius)} c")
|
|
self.command(f"{n(x - bend)} {n(y + radius)} {n(x - radius)} {n(y + bend)} {n(x - radius)} {n(y)} c")
|
|
self.command(f"{n(x - radius)} {n(y - bend)} {n(x - bend)} {n(y - radius)} {n(x)} {n(y - radius)} c")
|
|
self.command(f"{n(x + bend)} {n(y - radius)} {n(x + radius)} {n(y - bend)} {n(x + radius)} {n(y)} c h")
|
|
self.command("B" if fill and stroke else "f" if fill else "S" if stroke else "n")
|
|
|
|
def polygon(
|
|
self, points: list[tuple[float, float]], *, fill: bool = False, stroke: bool = False
|
|
) -> None:
|
|
if not points:
|
|
return
|
|
n = self._number
|
|
first_x, first_y = points[0]
|
|
self.command(f"{n(first_x)} {n(first_y)} m")
|
|
for x, y in points[1:]:
|
|
self.command(f"{n(x)} {n(y)} l")
|
|
self.command("h")
|
|
self.command("B" if fill and stroke else "f" if fill else "S" if stroke else "n")
|
|
|
|
def text(self, x: float, y: float, value: str, size: float, *, bold: bool = False) -> None:
|
|
safe = value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
|
font = "F2" if bold else "F1"
|
|
self.command(
|
|
f"BT /{font} {self._number(size)} Tf 1 0 0 1 {self._number(x)} {self._number(y)} Tm ({safe}) Tj ET"
|
|
)
|
|
|
|
def clipped_round_rect(self, rect: Rect, radius: float) -> None:
|
|
"""Start a graphics state clipped to a rounded rectangle; call restore()."""
|
|
self.save()
|
|
self._round_rect_path(rect, radius)
|
|
self.command("W n")
|
|
|
|
def content_bytes(self) -> bytes:
|
|
return "\n".join(self._commands).encode("ascii")
|
|
|
|
def bytes(self) -> bytes:
|
|
"""Return this canvas as a single-page PDF."""
|
|
return build_pdf([self])
|
|
|
|
|
|
def build_pdf(pages: Iterable[PdfCanvas]) -> bytes:
|
|
"""Combine canvases into a standards-compliant, multi-page vector PDF."""
|
|
page_list = list(pages)
|
|
if not page_list:
|
|
raise ValueError("A PDF must contain at least one page.")
|
|
|
|
page_object_numbers = [5 + (index * 2) for index in range(len(page_list))]
|
|
page_references = " ".join(f"{number} 0 R" for number in page_object_numbers)
|
|
objects = [
|
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
f"<< /Type /Pages /Kids [{page_references}] /Count {len(page_list)} >>".encode("ascii"),
|
|
b"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>",
|
|
b"<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>",
|
|
]
|
|
for index, page in enumerate(page_list):
|
|
content = page.content_bytes()
|
|
content_object_number = 6 + (index * 2)
|
|
objects.append(
|
|
(
|
|
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page.width:.3f} {page.height:.3f}] "
|
|
f"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents {content_object_number} 0 R >>"
|
|
).encode("ascii")
|
|
)
|
|
objects.append(b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"\nendstream")
|
|
|
|
result = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
|
offsets = [0]
|
|
for object_number, value in enumerate(objects, start=1):
|
|
offsets.append(len(result))
|
|
result.extend(f"{object_number} 0 obj\n".encode("ascii"))
|
|
result.extend(value)
|
|
result.extend(b"\nendobj\n")
|
|
xref = len(result)
|
|
result.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
|
result.extend(b"0000000000 65535 f \n")
|
|
for offset in offsets[1:]:
|
|
result.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
|
result.extend(
|
|
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode("ascii")
|
|
)
|
|
return bytes(result)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CourseStyle:
|
|
fairway: Color
|
|
water: Color
|
|
sand: Color
|
|
|
|
|
|
def _shift_colour(colour: Color, amount: float) -> Color:
|
|
return tuple(min(1.0, max(0.0, value + amount)) for value in colour) # type: ignore[return-value]
|
|
|
|
|
|
def choose_style(rng: random.Random) -> CourseStyle:
|
|
"""Choose a subtle print-friendly palette for one course."""
|
|
tint = rng.uniform(-0.035, 0.035)
|
|
return CourseStyle(
|
|
_shift_colour(FAIRWAY, tint),
|
|
_shift_colour(WATER, tint),
|
|
_shift_colour(SAND, tint / 2),
|
|
)
|
|
|
|
|
|
def draw_dot_grid(
|
|
canvas: PdfCanvas,
|
|
board: Rect,
|
|
spacing: float = GRID_SPACING,
|
|
colour: Color = GRID,
|
|
clip_to: tuple[Rect, float] | None = None,
|
|
) -> None:
|
|
"""Draw the straight, evenly spaced dot grid above all ground terrain."""
|
|
if clip_to:
|
|
canvas.clipped_round_rect(*clip_to)
|
|
canvas.fill(colour)
|
|
start_x = board.x + GRID_X_OFFSET
|
|
start_y = board.y + GRID_Y_OFFSET
|
|
for row in range(GRID_ROWS):
|
|
for column in range(GRID_COLUMNS):
|
|
canvas.circle(start_x + column * spacing, start_y + row * spacing, 0.78, fill=True)
|
|
if clip_to:
|
|
canvas.restore()
|
|
|
|
|
|
def draw_conifer(canvas: PdfCanvas, x: float, y: float, scale: float = 1.0) -> None:
|
|
"""A simple, high-contrast conifer obstacle."""
|
|
canvas.fill(INK)
|
|
canvas.rect(Rect(x - scale, y, 2 * scale, 4 * scale), fill=True)
|
|
canvas.polygon([(x, y + 17 * scale), (x - 7 * scale, y + 4 * scale), (x + 7 * scale, y + 4 * scale)], fill=True)
|
|
|
|
|
|
def draw_broadleaf_tree(canvas: PdfCanvas, x: float, y: float, scale: float = 1.0) -> None:
|
|
"""A simple round-canopy tree, giving each course a second silhouette."""
|
|
canvas.fill(INK)
|
|
canvas.rect(Rect(x - scale, y, 2 * scale, 5 * scale), fill=True)
|
|
canvas.circle(x, y + 11 * scale, 6 * scale, fill=True)
|
|
|
|
|
|
def draw_tree(canvas: PdfCanvas, x: float, y: float, scale: float, kind: str) -> None:
|
|
if kind == "broadleaf":
|
|
draw_broadleaf_tree(canvas, x, y, scale)
|
|
else:
|
|
draw_conifer(canvas, x, y, scale)
|
|
|
|
|
|
def draw_tee(canvas: PdfCanvas, x: float, y: float) -> None:
|
|
canvas.fill(PAPER)
|
|
canvas.stroke(INK)
|
|
canvas.line_width(1.35)
|
|
canvas.circle(x, y, 6.25, fill=True, stroke=True)
|
|
canvas.fill(INK)
|
|
canvas.circle(x, y, 1.25, fill=True)
|
|
|
|
|
|
def draw_hole(canvas: PdfCanvas, x: float, y: float) -> None:
|
|
canvas.fill(INK)
|
|
canvas.circle(x, y, 4.5, fill=True)
|
|
|
|
|
|
def _point_rect_distance(point: tuple[float, float], rect: Rect) -> float:
|
|
"""Return the distance from a point to the nearest point on a rectangle."""
|
|
point_x, point_y = point
|
|
nearest_x = min(max(point_x, rect.x), rect.right)
|
|
nearest_y = min(max(point_y, rect.y), rect.top)
|
|
return math.hypot(point_x - nearest_x, point_y - nearest_y)
|
|
|
|
|
|
def _rectangles_intersect(first: Rect, second: Rect, gap: float = 0.0) -> bool:
|
|
"""Return whether terrain cells overlap, optionally including a clear gap."""
|
|
return (
|
|
first.x < second.right + gap
|
|
and first.right + gap > second.x
|
|
and first.y < second.top + gap
|
|
and first.top + gap > second.y
|
|
)
|
|
|
|
|
|
def _cell(board: Rect, column: int, row: int) -> Rect:
|
|
return Rect(
|
|
board.x + column * GRID_SPACING,
|
|
board.y + row * GRID_SPACING,
|
|
GRID_SPACING,
|
|
GRID_SPACING,
|
|
)
|
|
|
|
|
|
def _available_cell(
|
|
candidate: Rect,
|
|
*,
|
|
blocked: list[Rect],
|
|
avoid: list[tuple[float, float]],
|
|
clearance: float,
|
|
) -> bool:
|
|
return (
|
|
all(not _rectangles_intersect(candidate, feature, gap=0.8) for feature in blocked)
|
|
and all(_point_rect_distance(point, candidate) > clearance for point in avoid)
|
|
)
|
|
|
|
|
|
def _natural_shape(
|
|
rng: random.Random,
|
|
board: Rect,
|
|
*,
|
|
cell_count: int,
|
|
avoid: list[tuple[float, float]],
|
|
blocked: list[Rect],
|
|
clearance: float = 10.0,
|
|
preferred_point: tuple[float, float] | None = None,
|
|
) -> list[Rect]:
|
|
"""Grow a connected, organic terrain blob from dot-centred grid cells."""
|
|
for _ in range(100):
|
|
if preferred_point and rng.random() < 0.78:
|
|
preferred_x, preferred_y = preferred_point
|
|
start_column = round((preferred_x - (board.x + GRID_X_OFFSET)) / GRID_SPACING) + rng.randint(-3, 3)
|
|
start_row = round((preferred_y - (board.y + GRID_Y_OFFSET)) / GRID_SPACING) + rng.randint(-3, 3)
|
|
start_column = min(max(start_column, 0), GRID_COLUMNS - 1)
|
|
start_row = min(max(start_row, 0), GRID_ROWS - 1)
|
|
else:
|
|
start_column = rng.randrange(GRID_COLUMNS)
|
|
start_row = rng.randrange(GRID_ROWS)
|
|
first_cell = _cell(board, start_column, start_row)
|
|
if not _available_cell(first_cell, blocked=blocked, avoid=avoid, clearance=clearance):
|
|
continue
|
|
|
|
cells = [(start_column, start_row)]
|
|
selected = {(start_column, start_row)}
|
|
while len(cells) < cell_count:
|
|
frontier: list[tuple[int, int]] = []
|
|
for column, row in cells:
|
|
for next_column, next_row in (
|
|
(column - 1, row),
|
|
(column + 1, row),
|
|
(column, row - 1),
|
|
(column, row + 1),
|
|
):
|
|
if not (0 <= next_column < GRID_COLUMNS and 0 <= next_row < GRID_ROWS):
|
|
continue
|
|
if (next_column, next_row) in selected:
|
|
continue
|
|
candidate = _cell(board, next_column, next_row)
|
|
if _available_cell(candidate, blocked=blocked, avoid=avoid, clearance=clearance):
|
|
frontier.append((next_column, next_row))
|
|
if not frontier:
|
|
break
|
|
# Duplicate frontier entries give cells with more neighbours a
|
|
# higher chance of being selected, naturally rounding the blob.
|
|
# Occasional tip growth creates the longer natural arms seen in
|
|
# fairways and sand traps.
|
|
if rng.random() < 0.32:
|
|
tip_column, tip_row = cells[-1]
|
|
tip_frontier = [
|
|
candidate
|
|
for candidate in frontier
|
|
if abs(candidate[0] - tip_column) + abs(candidate[1] - tip_row) == 1
|
|
]
|
|
if tip_frontier:
|
|
frontier = tip_frontier
|
|
next_cell = rng.choice(frontier)
|
|
selected.add(next_cell)
|
|
cells.append(next_cell)
|
|
if len(cells) == cell_count:
|
|
return [_cell(board, column, row) for column, row in cells]
|
|
raise RuntimeError("Could not grow non-overlapping terrain.")
|
|
|
|
|
|
def _corridor_point(
|
|
rng: random.Random, tee: tuple[float, float], cup: tuple[float, float]
|
|
) -> tuple[float, float]:
|
|
"""Return a varied point along the middle of the tee-to-cup corridor."""
|
|
ratio = rng.uniform(0.28, 0.72)
|
|
return tee[0] + (cup[0] - tee[0]) * ratio, tee[1] + (cup[1] - tee[1]) * ratio
|
|
|
|
|
|
def draw_natural_area(canvas: PdfCanvas, cells: list[Rect], colour: Color) -> None:
|
|
"""Fill one seamless rounded outline around a connected terrain area."""
|
|
canvas.fill(colour)
|
|
_append_natural_area_path(canvas, cells)
|
|
canvas.command("f")
|
|
|
|
|
|
def _natural_area_boundary_loops(
|
|
cells: list[Rect],
|
|
) -> list[list[tuple[tuple[float, float], tuple[float, float]]]]:
|
|
"""Return directed exterior edges for each boundary loop of a cell union."""
|
|
positions = {(round(cell.x, 3), round(cell.y, 3)) for cell in cells}
|
|
|
|
def has_cell(cell: Rect, column_offset: int, row_offset: int) -> bool:
|
|
return (
|
|
round(cell.x + column_offset * GRID_SPACING, 3),
|
|
round(cell.y + row_offset * GRID_SPACING, 3),
|
|
) in positions
|
|
|
|
edges: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
|
for cell in cells:
|
|
if not has_cell(cell, 0, -1):
|
|
edges.append(((cell.x, cell.y), (cell.right, cell.y)))
|
|
if not has_cell(cell, 1, 0):
|
|
edges.append(((cell.right, cell.y), (cell.right, cell.top)))
|
|
if not has_cell(cell, 0, 1):
|
|
edges.append(((cell.right, cell.top), (cell.x, cell.top)))
|
|
if not has_cell(cell, -1, 0):
|
|
edges.append(((cell.x, cell.top), (cell.x, cell.y)))
|
|
|
|
def point_key(point: tuple[float, float]) -> tuple[float, float]:
|
|
return round(point[0], 3), round(point[1], 3)
|
|
|
|
def direction(edge: tuple[tuple[float, float], tuple[float, float]]) -> int:
|
|
start, end = edge
|
|
if end[0] > start[0]:
|
|
return 0 # east
|
|
if end[1] > start[1]:
|
|
return 1 # north
|
|
if end[0] < start[0]:
|
|
return 2 # west
|
|
return 3 # south
|
|
|
|
outgoing: dict[tuple[float, float], list[int]] = {}
|
|
for index, edge in enumerate(edges):
|
|
outgoing.setdefault(point_key(edge[0]), []).append(index)
|
|
|
|
unused = set(range(len(edges)))
|
|
loops: list[list[tuple[tuple[float, float], tuple[float, float]]]] = []
|
|
while unused:
|
|
first_index = next(iter(unused))
|
|
first_edge = edges[first_index]
|
|
loop = [first_edge]
|
|
unused.remove(first_index)
|
|
start = point_key(first_edge[0])
|
|
end = point_key(first_edge[1])
|
|
current_direction = direction(first_edge)
|
|
while end != start:
|
|
candidates = [index for index in outgoing.get(end, []) if index in unused]
|
|
if not candidates:
|
|
raise ValueError("Terrain boundary could not be traced.")
|
|
|
|
# Prefer a left turn to keep diagonal-touching shapes in separate
|
|
# loops. Straight and right turns are used for natural concavities.
|
|
next_index = min(
|
|
candidates,
|
|
key=lambda index: ({1: 0, 0: 1, 3: 2, 2: 3})[
|
|
(direction(edges[index]) - current_direction) % 4
|
|
],
|
|
)
|
|
next_edge = edges[next_index]
|
|
loop.append(next_edge)
|
|
unused.remove(next_index)
|
|
current_direction = direction(next_edge)
|
|
end = point_key(next_edge[1])
|
|
loops.append(loop)
|
|
return loops
|
|
|
|
|
|
def _append_natural_area_path(canvas: PdfCanvas, cells: list[Rect]) -> None:
|
|
"""Append seamless, rounded terrain outlines to the current PDF path."""
|
|
radius = GRID_SPACING * 0.4
|
|
bend = radius * 0.55228475
|
|
n = canvas._number
|
|
|
|
for loop in _natural_area_boundary_loops(cells):
|
|
corners: list[
|
|
tuple[
|
|
tuple[float, float],
|
|
tuple[float, float],
|
|
tuple[float, float] | None,
|
|
tuple[float, float] | None,
|
|
]
|
|
] = []
|
|
for index, edge in enumerate(loop):
|
|
previous_edge = loop[index - 1]
|
|
vertex = edge[0]
|
|
incoming = (
|
|
(vertex[0] - previous_edge[0][0]) / GRID_SPACING,
|
|
(vertex[1] - previous_edge[0][1]) / GRID_SPACING,
|
|
)
|
|
outgoing = (
|
|
(edge[1][0] - vertex[0]) / GRID_SPACING,
|
|
(edge[1][1] - vertex[1]) / GRID_SPACING,
|
|
)
|
|
is_exposed_convex_corner = incoming[0] * outgoing[1] - incoming[1] * outgoing[0] > 0
|
|
if not is_exposed_convex_corner:
|
|
corners.append((vertex, vertex, None, None))
|
|
continue
|
|
|
|
entry = (vertex[0] - incoming[0] * radius, vertex[1] - incoming[1] * radius)
|
|
exit_point = (vertex[0] + outgoing[0] * radius, vertex[1] + outgoing[1] * radius)
|
|
first_control = (entry[0] + incoming[0] * bend, entry[1] + incoming[1] * bend)
|
|
second_control = (exit_point[0] - outgoing[0] * bend, exit_point[1] - outgoing[1] * bend)
|
|
corners.append((entry, exit_point, first_control, second_control))
|
|
|
|
canvas.command(f"{n(corners[0][1][0])} {n(corners[0][1][1])} m")
|
|
for index in range(1, len(corners) + 1):
|
|
entry, exit_point, first_control, second_control = corners[index % len(corners)]
|
|
canvas.command(f"{n(entry[0])} {n(entry[1])} l")
|
|
if first_control and second_control:
|
|
canvas.command(
|
|
f"{n(first_control[0])} {n(first_control[1])} "
|
|
f"{n(second_control[0])} {n(second_control[1])} "
|
|
f"{n(exit_point[0])} {n(exit_point[1])} c"
|
|
)
|
|
canvas.command("h")
|
|
|
|
|
|
def draw_hatched_natural_area(canvas: PdfCanvas, cells: list[Rect], colour: Color) -> None:
|
|
"""Draw one seamless, diagonally shaded sand-trap outline."""
|
|
draw_natural_area(canvas, cells, colour)
|
|
bounds = Rect(
|
|
min(cell.x for cell in cells),
|
|
min(cell.y for cell in cells),
|
|
max(cell.right for cell in cells) - min(cell.x for cell in cells),
|
|
max(cell.top for cell in cells) - min(cell.y for cell in cells),
|
|
)
|
|
canvas.save()
|
|
_append_natural_area_path(canvas, cells)
|
|
canvas.command("W n")
|
|
canvas.stroke(_shift_colour(colour, -0.28))
|
|
canvas.line_width(0.45)
|
|
for start in range(-int(bounds.height), int(bounds.width) + int(bounds.height), 6):
|
|
canvas.line(bounds.x + start, bounds.y, bounds.x + start + bounds.height, bounds.top)
|
|
canvas.restore()
|
|
|
|
|
|
def _point_on_shape(rng: random.Random, shape: list[Rect]) -> tuple[float, float]:
|
|
"""Pick a grid-dot centre with room for a tee or cup in a fairway segment."""
|
|
cells_with_neighbours = [
|
|
cell
|
|
for cell in shape
|
|
if sum(
|
|
abs(cell.x - other.x) == GRID_SPACING and cell.y == other.y
|
|
or abs(cell.y - other.y) == GRID_SPACING and cell.x == other.x
|
|
for other in shape
|
|
if other is not cell
|
|
) >= 2
|
|
]
|
|
cell = rng.choice(cells_with_neighbours or shape)
|
|
return cell.x + GRID_X_OFFSET, cell.y + GRID_Y_OFFSET
|
|
|
|
|
|
def _start_and_finish_fairways(
|
|
rng: random.Random, board: Rect
|
|
) -> tuple[list[Rect], tuple[float, float], list[Rect], tuple[float, float]]:
|
|
"""Generate two fairways that strongly favour a long, playable hole."""
|
|
longest_layout: tuple[list[Rect], tuple[float, float], list[Rect], tuple[float, float]] | None = None
|
|
longest_distance = 0.0
|
|
for _ in range(60):
|
|
try:
|
|
tee_fairway = _natural_shape(
|
|
rng, board, cell_count=rng.randint(14, 22), avoid=[], blocked=[]
|
|
)
|
|
except RuntimeError:
|
|
continue
|
|
tee = _point_on_shape(rng, tee_fairway)
|
|
try:
|
|
cup_fairway = _natural_shape(
|
|
rng,
|
|
board,
|
|
cell_count=rng.randint(14, 22),
|
|
avoid=[tee],
|
|
blocked=tee_fairway,
|
|
clearance=MIN_TEE_TO_CUP_DISTANCE,
|
|
)
|
|
except RuntimeError:
|
|
continue
|
|
cup = _point_on_shape(rng, cup_fairway)
|
|
distance = math.dist(tee, cup)
|
|
if distance < MIN_TEE_TO_CUP_DISTANCE:
|
|
continue
|
|
layout = tee_fairway, tee, cup_fairway, cup
|
|
if distance >= TARGET_TEE_TO_CUP_DISTANCE:
|
|
return layout
|
|
if distance > longest_distance:
|
|
longest_layout = layout
|
|
longest_distance = distance
|
|
if longest_layout:
|
|
return longest_layout
|
|
raise RuntimeError("Could not place tee and cup the required distance apart.")
|
|
|
|
|
|
def _tree_group_points(
|
|
rng: random.Random,
|
|
board: Rect,
|
|
*,
|
|
terrain: list[Rect],
|
|
avoid: list[tuple[float, float]],
|
|
tee: tuple[float, float] | None = None,
|
|
cup: tuple[float, float] | None = None,
|
|
) -> list[tuple[float, float, float]]:
|
|
"""Place larger clusters of trees on open dot-grid positions only."""
|
|
result: list[tuple[float, float, float]] = []
|
|
|
|
def dot_position(column: int, row: int) -> tuple[float, float]:
|
|
return board.x + GRID_X_OFFSET + column * GRID_SPACING, board.y + GRID_Y_OFFSET + row * GRID_SPACING
|
|
|
|
def clear_position(column: int, row: int, group: list[tuple[int, int]]) -> bool:
|
|
if not (1 <= column < GRID_COLUMNS - 1 and 1 <= row < GRID_ROWS - 2):
|
|
return False
|
|
x, y = dot_position(column, row)
|
|
canopy_centre = (x, y + 10)
|
|
if any(_point_rect_distance(canopy_centre, feature) < 13 for feature in terrain):
|
|
return False
|
|
if any(math.hypot(x - px, y - py) < 20 for px, py in avoid):
|
|
return False
|
|
if any(math.hypot(x - ox, y - oy) < GRID_SPACING * 1.35 for ox, oy, _ in result):
|
|
return False
|
|
return all((column, row) != existing for existing in group)
|
|
|
|
cluster_offsets = [
|
|
(0, 0),
|
|
(1, 1), (-1, 1), (1, -1), (-1, -1),
|
|
(2, 0), (-2, 0), (0, 2), (0, -2),
|
|
(2, 2), (-2, 2), (2, -2), (-2, -2),
|
|
]
|
|
for _ in range(rng.randint(4, 6)):
|
|
for _ in range(60):
|
|
if tee and cup and rng.random() < 0.7:
|
|
corridor_x, corridor_y = _corridor_point(rng, tee, cup)
|
|
distance = math.dist(tee, cup)
|
|
sideways = rng.uniform(-GRID_SPACING * 4, GRID_SPACING * 4)
|
|
anchor_x = corridor_x - (cup[1] - tee[1]) * sideways / distance
|
|
anchor_y = corridor_y + (cup[0] - tee[0]) * sideways / distance
|
|
anchor_column = round((anchor_x - (board.x + GRID_X_OFFSET)) / GRID_SPACING)
|
|
anchor_row = round((anchor_y - (board.y + GRID_Y_OFFSET)) / GRID_SPACING)
|
|
else:
|
|
anchor_column = rng.randrange(1, GRID_COLUMNS - 1)
|
|
anchor_row = rng.randrange(1, GRID_ROWS - 2)
|
|
group_cells: list[tuple[int, int]] = []
|
|
if not clear_position(anchor_column, anchor_row, group_cells):
|
|
continue
|
|
|
|
offsets = cluster_offsets[1:]
|
|
rng.shuffle(offsets)
|
|
target_size = rng.randint(5, 8)
|
|
for column_offset, row_offset in [(0, 0), *offsets]:
|
|
if len(group_cells) >= target_size:
|
|
break
|
|
column = anchor_column + column_offset
|
|
row = anchor_row + row_offset
|
|
if clear_position(column, row, group_cells):
|
|
group_cells.append((column, row))
|
|
if len(group_cells) >= 4:
|
|
for column, row in group_cells:
|
|
x, y = dot_position(column, row)
|
|
result.append((x, y, rng.uniform(0.76, 1.0)))
|
|
break
|
|
return result
|
|
|
|
|
|
def draw_course(canvas: PdfCanvas, card: Rect, hole_number: int, rng: random.Random) -> None:
|
|
"""Render one unique, playable-looking course into a card."""
|
|
style = choose_style(rng)
|
|
board = Rect(
|
|
card.x + (card.width - GRID_COLUMNS * GRID_SPACING) / 2,
|
|
card.y + 36,
|
|
GRID_COLUMNS * GRID_SPACING,
|
|
GRID_ROWS * GRID_SPACING,
|
|
)
|
|
|
|
canvas.fill(PAPER)
|
|
canvas.rect(card, fill=True)
|
|
|
|
# Build the fairways first, then choose a tee and cup within them. This
|
|
# guarantees that both start and finish are always placed on a fairway.
|
|
tee_fairway, (tee_x, tee_y), cup_fairway, (cup_x, cup_y) = _start_and_finish_fairways(rng, board)
|
|
avoid = [(tee_x, tee_y), (cup_x, cup_y)]
|
|
|
|
# Ground features are deliberately rendered before the dot grid, matching
|
|
# the paper board style in the supplied reference.
|
|
fairway_shapes = [tee_fairway, cup_fairway]
|
|
occupied_terrain = [*tee_fairway, *cup_fairway]
|
|
for _ in range(rng.randint(1, 2)):
|
|
try:
|
|
extra_fairway = _natural_shape(
|
|
rng,
|
|
board,
|
|
cell_count=rng.randint(7, 13),
|
|
avoid=avoid,
|
|
blocked=occupied_terrain,
|
|
clearance=12.0,
|
|
preferred_point=_corridor_point(rng, (tee_x, tee_y), (cup_x, cup_y)),
|
|
)
|
|
except RuntimeError:
|
|
continue
|
|
fairway_shapes.append(extra_fairway)
|
|
occupied_terrain.extend(extra_fairway)
|
|
|
|
for fairway_shape in fairway_shapes:
|
|
draw_natural_area(canvas, fairway_shape, style.fairway)
|
|
|
|
water_features: list[tuple[Rect, float]] = []
|
|
for terrain, count in (("water", rng.randint(1, 2)), ("sand", rng.randint(1, 2))):
|
|
for _ in range(count):
|
|
try:
|
|
terrain_shape = _natural_shape(
|
|
rng,
|
|
board,
|
|
cell_count=rng.randint(6, 13),
|
|
avoid=avoid,
|
|
blocked=occupied_terrain,
|
|
clearance=12.0,
|
|
preferred_point=_corridor_point(rng, (tee_x, tee_y), (cup_x, cup_y)),
|
|
)
|
|
except RuntimeError:
|
|
continue
|
|
occupied_terrain.extend(terrain_shape)
|
|
if terrain == "water":
|
|
draw_natural_area(canvas, terrain_shape, style.water)
|
|
water_features.extend((terrain_part, 0) for terrain_part in terrain_shape)
|
|
else:
|
|
draw_hatched_natural_area(canvas, terrain_shape, style.sand)
|
|
|
|
draw_dot_grid(canvas, board)
|
|
for water_feature in water_features:
|
|
draw_dot_grid(canvas, board, colour=PAPER, clip_to=water_feature)
|
|
|
|
tree_positions = _tree_group_points(
|
|
rng,
|
|
board,
|
|
terrain=occupied_terrain,
|
|
avoid=avoid,
|
|
tee=(tee_x, tee_y),
|
|
cup=(cup_x, cup_y),
|
|
)
|
|
for x, y, scale in tree_positions:
|
|
tree_kind = "broadleaf" if rng.random() < 0.38 else "conifer"
|
|
draw_tree(canvas, x, y, scale, tree_kind)
|
|
|
|
draw_tee(canvas, tee_x, tee_y)
|
|
draw_hole(canvas, cup_x, cup_y)
|
|
|
|
# A very light cut border remains outside the playing area.
|
|
canvas.stroke(CUT_LINE)
|
|
canvas.line_width(0.45)
|
|
canvas.dashed((2.0, 2.2))
|
|
canvas.rect(card, stroke=True)
|
|
canvas.solid()
|
|
|
|
canvas.fill(INK)
|
|
label_y = card.y + 13
|
|
canvas.text(card.x + 8, label_y, f"Hole {hole_number}", 10.2, bold=True)
|
|
canvas.text(card.x + 56, label_y, "Strokes:", 9.5)
|
|
canvas.text(card.x + 104, label_y, "___ / 6", 9.5)
|
|
canvas.text(card.x + 153, label_y, "Total:", 9.5)
|
|
canvas.text(card.x + 189, label_y, "____", 9.5)
|
|
|
|
|
|
def draw_cut_marks(canvas: PdfCanvas) -> None:
|
|
"""Add restrained cut guides between the four cards without using page edges."""
|
|
top_left, top_right, bottom_left, _ = a4_card_rectangles()
|
|
mid_x = (top_left.right + top_right.x) / 2
|
|
mid_y = (bottom_left.top + top_left.y) / 2
|
|
canvas.stroke(CUT_LINE)
|
|
canvas.line_width(0.45)
|
|
canvas.dashed((2.4, 2.4))
|
|
canvas.line(mid_x, bottom_left.y - 3.0, mid_x, bottom_left.y + 4.0)
|
|
canvas.line(mid_x, top_left.top - 4.0, mid_x, top_left.top + 3.0)
|
|
canvas.line(top_left.x - 3.0, mid_y, top_left.x + 4.0, mid_y)
|
|
canvas.line(top_right.right - 4.0, mid_y, top_right.right + 3.0, mid_y)
|
|
canvas.solid()
|
|
|
|
|
|
def a4_card_rectangles() -> list[Rect]:
|
|
"""Return the four cut-ready card positions on every A4 page."""
|
|
top_y = BOTTOM_MARGIN + CARD_HEIGHT + VERTICAL_GUTTER
|
|
return [
|
|
Rect(SIDE_MARGIN, top_y, CARD_WIDTH, CARD_HEIGHT),
|
|
Rect(SIDE_MARGIN + CARD_WIDTH + HORIZONTAL_GUTTER, top_y, CARD_WIDTH, CARD_HEIGHT),
|
|
Rect(SIDE_MARGIN, BOTTOM_MARGIN, CARD_WIDTH, CARD_HEIGHT),
|
|
Rect(SIDE_MARGIN + CARD_WIDTH + HORIZONTAL_GUTTER, BOTTOM_MARGIN, CARD_WIDTH, CARD_HEIGHT),
|
|
]
|
|
|
|
|
|
def build_a4_pdf(course_count: int = 4, seed: int | None = None) -> bytes:
|
|
"""Return a PDF with four randomly generated courses per A4 page."""
|
|
if course_count < 1:
|
|
raise ValueError("Course count must be at least one.")
|
|
|
|
rng = random.Random(seed)
|
|
pages: list[PdfCanvas] = []
|
|
cards = a4_card_rectangles()
|
|
for first_hole in range(0, course_count, len(cards)):
|
|
canvas = PdfCanvas(A4_WIDTH, A4_HEIGHT)
|
|
canvas.fill(PAPER)
|
|
canvas.rect(Rect(0, 0, A4_WIDTH, A4_HEIGHT), fill=True)
|
|
for card, hole_number in zip(cards, range(first_hole + 1, min(first_hole + len(cards), course_count) + 1)):
|
|
draw_course(canvas, card, hole_number, rng)
|
|
draw_cut_marks(canvas)
|
|
pages.append(canvas)
|
|
return build_pdf(pages)
|
|
|
|
|
|
def build_a4_sheet(seed: int | None = None) -> bytes:
|
|
"""Return the original single-page, four-course PDF."""
|
|
return build_a4_pdf(course_count=4, seed=seed)
|
|
|
|
|
|
def write_sheet(output_path: str | Path, seed: int | None = None, course_count: int = 4) -> Path:
|
|
"""Create the PDF, ensuring callers receive an absolute output path."""
|
|
destination = Path(output_path).expanduser().resolve()
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(build_a4_pdf(course_count=course_count, seed=seed))
|
|
return destination
|
|
|
|
|
|
class GolfCourseApp(ttk.Frame):
|
|
def __init__(self, master: tk.Tk, seed: int | None = None, course_count: int = 4) -> None:
|
|
super().__init__(master, padding=22)
|
|
self.master = master
|
|
self.seed = tk.StringVar(value="" if seed is None else str(seed))
|
|
self.course_count = tk.StringVar(value=str(course_count))
|
|
self.status = tk.StringVar(value="Choose a save location to create new courses.")
|
|
self._build()
|
|
|
|
def _build(self) -> None:
|
|
self.master.title("Paper Golf Course Generator")
|
|
self.master.minsize(500, 340)
|
|
self.grid(sticky="nsew")
|
|
self.master.columnconfigure(0, weight=1)
|
|
self.master.rowconfigure(0, weight=1)
|
|
self.columnconfigure(0, weight=1)
|
|
|
|
ttk.Label(self, text="Paper Golf Course Generator", font=("Segoe UI", 16, "bold")).grid(
|
|
row=0, column=0, sticky="w"
|
|
)
|
|
ttk.Label(
|
|
self,
|
|
text="Creates a printable A4 PDF with four different mini-golf holes on each page, ready to print and cut apart.",
|
|
wraplength=450,
|
|
).grid(row=1, column=0, pady=(7, 20), sticky="w")
|
|
|
|
field = ttk.Frame(self)
|
|
field.grid(row=2, column=0, sticky="ew")
|
|
field.columnconfigure(1, weight=1)
|
|
ttk.Label(field, text="Courses to create:").grid(row=0, column=0, padx=(0, 10), sticky="w")
|
|
ttk.Entry(field, textvariable=self.course_count, width=30).grid(row=0, column=1, sticky="ew")
|
|
ttk.Label(field, text="Optional seed:").grid(row=1, column=0, padx=(0, 10), pady=(8, 0), sticky="w")
|
|
ttk.Entry(field, textvariable=self.seed, width=30).grid(row=1, column=1, pady=(8, 0), sticky="ew")
|
|
ttk.Label(
|
|
self,
|
|
text="Four courses fit on each A4 page. Leave the seed blank for a fresh set; reuse it to reproduce the same courses.",
|
|
wraplength=450,
|
|
).grid(row=3, column=0, pady=(5, 20), sticky="w")
|
|
|
|
ttk.Button(self, text="Generate A4 PDF…", command=self.generate).grid(row=4, column=0, sticky="w")
|
|
ttk.Separator(self).grid(row=5, column=0, pady=20, sticky="ew")
|
|
ttk.Label(self, textvariable=self.status, foreground="#46524a", wraplength=450).grid(
|
|
row=6, column=0, sticky="w"
|
|
)
|
|
|
|
def generate(self) -> None:
|
|
raw_seed = self.seed.get().strip()
|
|
raw_course_count = self.course_count.get().strip()
|
|
try:
|
|
seed = int(raw_seed) if raw_seed else random.SystemRandom().randrange(1, 2**63)
|
|
except ValueError:
|
|
messagebox.showerror("Invalid seed", "The optional seed must be a whole number.")
|
|
return
|
|
try:
|
|
course_count = int(raw_course_count)
|
|
if course_count < 1:
|
|
raise ValueError
|
|
except ValueError:
|
|
messagebox.showerror("Invalid course count", "Enter a whole number of courses, starting at 1.")
|
|
return
|
|
|
|
output = filedialog.asksaveasfilename(
|
|
title="Save paper golf courses",
|
|
defaultextension=".pdf",
|
|
filetypes=[("PDF files", "*.pdf")],
|
|
initialfile="paper-golf-courses.pdf",
|
|
)
|
|
if not output:
|
|
return
|
|
try:
|
|
location = write_sheet(output, seed=seed, course_count=course_count)
|
|
except OSError as error:
|
|
messagebox.showerror("Could not write PDF", str(error))
|
|
return
|
|
self.seed.set(str(seed))
|
|
page_count = math.ceil(course_count / 4)
|
|
self.status.set(f"Created {location.name} — {course_count} courses across {page_count} pages — seed {seed}")
|
|
messagebox.showinfo("PDF created", f"{course_count} new courses across {page_count} pages were saved to:\n{location}")
|
|
|
|
|
|
def launch_gui(seed: int | None = None, course_count: int = 4) -> None:
|
|
root = tk.Tk()
|
|
try:
|
|
ttk.Style().theme_use("clam")
|
|
except tk.TclError:
|
|
pass
|
|
GolfCourseApp(root, seed, course_count)
|
|
root.mainloop()
|
|
|
|
|
|
def parse_arguments() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Create an A4 PDF with four paper-golf courses per page.")
|
|
parser.add_argument("--output", "-o", type=Path, help="PDF destination. Starts the desktop app if omitted.")
|
|
parser.add_argument("--seed", type=int, help="Number used to reproduce the same sheet.")
|
|
parser.add_argument("--courses", "-n", type=int, default=4, help="Number of courses to generate (default: 4).")
|
|
arguments = parser.parse_args()
|
|
if arguments.courses < 1:
|
|
parser.error("--courses must be at least 1")
|
|
return arguments
|
|
|
|
|
|
def main() -> None:
|
|
arguments = parse_arguments()
|
|
if arguments.output:
|
|
destination = write_sheet(arguments.output, arguments.seed, arguments.courses)
|
|
print(f"Created {destination}")
|
|
else:
|
|
launch_gui(arguments.seed, arguments.courses)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|