Files
paper-game-golf/golf_course_generator.py
2026-07-23 10:32:19 +01:00

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()