commit 7027db5a16427847817afbf70a00f85b542ef5bd Author: Luke Betteridge Date: Thu Jul 23 10:32:19 2026 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a136337 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pdf diff --git a/README.md b/README.md new file mode 100644 index 0000000..51007b0 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Paper Golf Course Generator + +A small Python application that makes a printable A4 PDF containing randomised paper-golf courses. Each page holds four 3" × 5" cut cards, each with a 16 × 26 straight dotted play grid, tee and cup placed on fairway grid dots at least 12 intervals apart (targeting 18), multiple non-overlapping naturally grown light-grey fairway pieces, dark-grey water, shaded sand traps, and larger groups of simple trees—all aligned to the dot grid. Extra terrain and most tree groups favour the area between tee and cup. Connected terrain cells render as a single seamless vector area with rounded exposed corners and straight joined corners, with a Courier typewriter-style score line. The PDF creates as many pages as required and leaves room above the top cards for stapling. + +It has no third-party dependencies: it writes a clean vector PDF with Python's standard library. It requires Python 3.10 or later (with Tk included, as it is in the standard Windows installer). + +## Use it + +Double-click `golf_course_generator.py`, or run: + +```powershell +python golf_course_generator.py +``` + +Enter how many courses to create and an optional whole-number seed, then select **Generate A4 PDF**. Four courses are placed on each page—for example, 36 courses produces a nine-page PDF. Leaving the seed blank makes a new random set. Once generated, the seed is shown so the same set can be made again later. + +For command-line use: + +```powershell +python golf_course_generator.py --output paper-golf-courses.pdf +python golf_course_generator.py --output paper-golf-courses.pdf --seed 20260723 +python golf_course_generator.py --output paper-golf-courses.pdf --courses 36 +``` + +Print the generated PDF at **Actual size** (or 100%), rather than fitting it to the printable area. + +## Check it + +```powershell +python -m unittest -v +``` diff --git a/golf_course_generator.py b/golf_course_generator.py new file mode 100644 index 0000000..71b9bcb --- /dev/null +++ b/golf_course_generator.py @@ -0,0 +1,989 @@ +"""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() diff --git a/test_golf_course_generator.py b/test_golf_course_generator.py new file mode 100644 index 0000000..a4da180 --- /dev/null +++ b/test_golf_course_generator.py @@ -0,0 +1,110 @@ +"""Small regression tests for the dependency-free PDF generator.""" + +import math +import random +import tempfile +import unittest +from pathlib import Path + +from golf_course_generator import ( + A4_HEIGHT, + A4_WIDTH, + CARD_HEIGHT, + CARD_WIDTH, + GRID_COLUMNS, + GRID_ROWS, + GRID_SPACING, + GRID_X_OFFSET, + GRID_Y_OFFSET, + MIN_TEE_TO_CUP_DISTANCE, + Rect, + _natural_area_boundary_loops, + _natural_shape, + _rectangles_intersect, + _start_and_finish_fairways, + _tree_group_points, + build_a4_pdf, + build_a4_sheet, + write_sheet, +) + + +class GolfCourseGeneratorTests(unittest.TestCase): + def test_sheet_is_a_nonempty_single_page_pdf(self) -> None: + document = build_a4_sheet(seed=123456) + + self.assertTrue(document.startswith(b"%PDF-1.4")) + self.assertIn(f"/MediaBox [0 0 {A4_WIDTH:.3f} {A4_HEIGHT:.3f}]".encode(), document) + self.assertIn(b"/Count 1", document) + self.assertIn(b"/BaseFont /Courier", document) + self.assertGreater(len(document), 25_000) + + def test_seed_makes_output_repeatable(self) -> None: + self.assertEqual(build_a4_sheet(4321), build_a4_sheet(4321)) + self.assertNotEqual(build_a4_sheet(4321), build_a4_sheet(4322)) + + def test_36_courses_are_packed_into_nine_pages(self) -> None: + document = build_a4_pdf(course_count=36, seed=36) + + self.assertIn(b"/Count 9", document) + self.assertEqual(document.count(b"/Type /Page /Parent"), 9) + self.assertIn(b"(Hole 36)", document) + + def test_rejects_a_non_positive_course_count(self) -> None: + with self.assertRaises(ValueError): + build_a4_pdf(course_count=0) + + def test_tee_and_cup_are_separated_fairway_grid_dots(self) -> None: + board = Rect(0, 0, GRID_COLUMNS * GRID_SPACING, GRID_ROWS * GRID_SPACING) + tee_fairway, tee, cup_fairway, cup = _start_and_finish_fairways(random.Random(52), board) + + self.assertGreaterEqual(math.dist(tee, cup), MIN_TEE_TO_CUP_DISTANCE) + for point, fairway in ((tee, tee_fairway), (cup, cup_fairway)): + x_index = (point[0] - (board.x + GRID_X_OFFSET)) / GRID_SPACING + y_index = (point[1] - (board.y + GRID_Y_OFFSET)) / GRID_SPACING + self.assertAlmostEqual(x_index, round(x_index), places=8) + self.assertAlmostEqual(y_index, round(y_index), places=8) + self.assertTrue(any(part.x < point[0] < part.right and part.y < point[1] < part.top for part in fairway)) + + def test_course_cards_and_playfields_have_the_requested_dimensions(self) -> None: + self.assertEqual(CARD_WIDTH, 3 * 72) + self.assertEqual(CARD_HEIGHT, 5 * 72) + self.assertEqual((GRID_COLUMNS, GRID_ROWS), (16, 26)) + + def test_tree_groups_and_terrain_do_not_overlap(self) -> None: + board = Rect(0, 0, GRID_COLUMNS * GRID_SPACING, GRID_ROWS * GRID_SPACING) + rng = random.Random(19) + first = _natural_shape(rng, board, cell_count=14, avoid=[], blocked=[]) + second = _natural_shape(rng, board, cell_count=8, avoid=[], blocked=first) + terrain = [*first, *second] + for cell in first[1:]: + self.assertTrue( + any(abs(cell.x - earlier.x) + abs(cell.y - earlier.y) == GRID_SPACING for earlier in first) + ) + self.assertTrue(all(not _rectangles_intersect(part, blocked) for part in second for blocked in first)) + for x, y, _ in _tree_group_points(rng, board, terrain=terrain, avoid=[]): + self.assertAlmostEqual((x - (board.x + GRID_X_OFFSET)) / GRID_SPACING, round((x - (board.x + GRID_X_OFFSET)) / GRID_SPACING), places=8) + self.assertAlmostEqual((y - (board.y + GRID_Y_OFFSET)) / GRID_SPACING, round((y - (board.y + GRID_Y_OFFSET)) / GRID_SPACING), places=8) + self.assertTrue(all(math.dist((x, y + 10), (max(feature.x, min(x, feature.right)), max(feature.y, min(y + 10, feature.top)))) >= 13 for feature in terrain)) + + def test_connected_terrain_has_one_continuous_boundary_loop(self) -> None: + cells = [Rect(0, 0, GRID_SPACING, GRID_SPACING), Rect(GRID_SPACING, 0, GRID_SPACING, GRID_SPACING)] + + loops = _natural_area_boundary_loops(cells) + + self.assertEqual(len(loops), 1) + self.assertEqual(len(loops[0]), 6) + self.assertEqual(loops[0][0][0], loops[0][-1][1]) + + def test_sheet_includes_each_hole_label_and_can_be_written(self) -> None: + with tempfile.TemporaryDirectory() as directory: + destination = write_sheet(Path(directory) / "courses.pdf", seed=9) + document = destination.read_bytes() + + for number in range(1, 5): + self.assertIn(f"Hole {number}".encode(), document) + self.assertIn(b"Strokes:", document) + + +if __name__ == "__main__": + unittest.main()