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