Initial Commit
This commit is contained in:
989
golf_course_generator.py
Normal file
989
golf_course_generator.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user