Skip to content

Commit db01d54

Browse files
DocAtPromptclaude
andcommitted
mandala: two more motif families — scallops and pinwheel; expand repo keywords
Completes the mandala variety work with the two families held back before: - `scallops` — a wavy ring of N arcs bulging out from a base circle by `depth`, or turned in (`inward`) for a ring of cusps. Reuses `_arc_geometry`, the petal's own two-arc helper, with the side that bulges away from the centre — no new geometry, Arc only. - `pinwheel` — small `sides`-gons on a ring, each turned with its position and by `twist` so the ring appears to spin. `polar_point` + Polygon, the polygon family's own tools. Both are single-or-list like the other ring families, and both report their reach so a motif past the pattern area is refused before page one (§ 8.2, § 12). A second gallery entry, examples/10b-mandala-scallops, shows them. 12 tests, test-first; verified on a real rendered PDF. Spec § 7.11, README, CLAUDE.md and implementation-decisions (#41) updated. Keywords: expand pyproject `keywords` from 6 to 19 to cover what the tool actually does (mandala, maze, staff-paper, polar, tiling, perspective, isometric, dot-grid, cli, reproducible). The public GitHub repo's topics — previously empty — were set to a matching 20-topic set out of band (repo metadata, not tracked in git). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 10bf4f0 commit db01d54

10 files changed

Lines changed: 306 additions & 22 deletions

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ things landed on the handle side, each small and each in the spec: the
2121
**`pattern.align`** — anchor the grid at a chosen corner (§ 8.5). Since then,
2222
`--embed-def`**the PDF carries its own source** as a file attachment (§ 8.8,
2323
was § 15 open question 5) — open question 3 closed (reportlab is BSD-3-Clause,
24-
verified), and **`mandala` grew three motif families**`petals`, `beads` and a
25-
single-or-list `rosette` (§ 7.11). Only M5's two device edges are left (see *Not
26-
done*).
24+
verified), and **`mandala` grew five motif families**`petals`, `beads`,
25+
`scallops`, `pinwheel` and a single-or-list `rosette` (§ 7.11). Only M5's two
26+
device edges are left (see *Not done*).
2727
0.1.0 is the version in the code; nothing has been released, because releasing
2828
needs a human *and* the user has chosen to defer PyPI for now (see *Not done*).
2929

@@ -69,7 +69,7 @@ and a PNG preview, guarded by `test_every_example_validates`.
6969
| **M7** PNG writer | raster at exact device resolution, one file per page, text via caps (§ 10.4) |
7070
| **M8** `perspective` | horizon + 1–3 vanishing-point fans, equal base division, Liang–Barsky clip, `verticals` (§ 7.11) |
7171
| **M8** `mandala` | sectors/rings scaffold, rosette of circles (`mirror`), inscribed regular / star polygons, on shared `polar_geometry` (§ 7.11) |
72-
| post-M9 `mandala` motifs | three more families: `petals` (a leaf = two arcs, `Arc` only), `beads` (dots on a ring — introduces `Dot` to the blade, § 7.11 mark line extended), and `rosette` widened to **single-or-list**; `petals`/`beads` take single-or-list too (layered bands). `check`/`_max_reach`/`describe` iterate the rings; the example gallery shows them (§ 7.11) |
72+
| post-M9 `mandala` motifs | five more families: `petals` (a leaf = two arcs, `Arc` only), `beads` (dots on a ring — introduces `Dot` to the blade, § 7.11 mark line extended), `scallops` (a wavy ring of arcs, `inward` for cusps), `pinwheel` (twisted small polygons on a ring), and `rosette` widened to **single-or-list**; all five ring families take single-or-list (layered bands). `check`/`_max_reach`/`describe` iterate the rings; two gallery entries (`10`, `10b`) show them (§ 7.11) |
7373
| **M9** `staves` clefs | `treble/bass/alto/tenor` as `Text` in a bundled, subset music font (Bravura, OFL→TrueType), SMuFL placement, 5-line only (§ 15.3) |
7474
| **M5** relative measure | `%w`/`%h`/`%s` as a fraction of the pattern area, resolved in seam 1 like `px`, `RelativeLengthField` opt-in across the blades (§ 8.11) |
7575
| post-M9 interactive | `ctrlgrid` with no args → preset browser (preset → pages → output), TTY-guarded, help otherwise (§ 11.2) |

ctrlgrid/generators/mandala.py

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,42 @@ class Beads(Section):
132132
color: ColorField = "#000000"
133133

134134

135+
class Scallops(Section):
136+
"""A wavy ring: N arcs bulging out from (or into) a base circle (§ 7.11).
137+
138+
The scallops sit on a base circle at `at` of the outer radius; each arc runs
139+
between two adjacent points and bulges by `depth` (a share of the outer
140+
radius). `inward` turns the bulge in, for a ring of cusps rather than lobes.
141+
Only `Arc`. Count defaults to the sector count.
142+
"""
143+
144+
at: float = Field(gt=0.0)
145+
count: int | None = Field(default=None, ge=1)
146+
depth: float = Field(default=0.06, gt=0.0)
147+
inward: bool = False
148+
weight: LengthField = HAIRLINE
149+
color: ColorField = "#000000"
150+
151+
152+
class Pinwheel(Section):
153+
"""Small polygons repeated round a ring, each twisted (§ 7.11).
154+
155+
N little `sides`-gons of circumradius `size` (a share of the outer radius)
156+
sit on a ring at `at`. Each is turned with its position and by an extra
157+
`twist`, which is what makes the ring spin rather than sit still. Only
158+
`Polygon`. Count defaults to the sector count.
159+
"""
160+
161+
at: float = Field(gt=0.0)
162+
size: float = Field(gt=0.0)
163+
sides: int = Field(default=4, ge=3)
164+
count: int | None = Field(default=None, ge=1)
165+
twist: float = 0.0
166+
weight: LengthField = HAIRLINE
167+
color: ColorField = "#000000"
168+
fill_color: ColorField = None
169+
170+
135171
class PolygonSpec(Section):
136172
"""An inscribed regular polygon or star polygon (§ 7.11).
137173
@@ -165,6 +201,8 @@ class MandalaConfig(BaseModel):
165201
rosette: Rosette | tuple[Rosette, ...] | None = None
166202
petals: Petals | tuple[Petals, ...] | None = None
167203
beads: Beads | tuple[Beads, ...] | None = None
204+
scallops: Scallops | tuple[Scallops, ...] | None = None
205+
pinwheel: Pinwheel | tuple[Pinwheel, ...] | None = None
168206
polygons: tuple[PolygonSpec, ...] | None = None
169207

170208
@property
@@ -179,14 +217,24 @@ def petal_rings(self) -> tuple[Petals, ...]:
179217
def bead_rings(self) -> tuple[Beads, ...]:
180218
return _as_rings(self.beads)
181219

220+
@property
221+
def scallop_rings(self) -> tuple[Scallops, ...]:
222+
return _as_rings(self.scallops)
223+
224+
@property
225+
def pinwheels(self) -> tuple[Pinwheel, ...]:
226+
return _as_rings(self.pinwheel)
227+
182228
@model_validator(mode="after")
183229
def _something_has_to_be_drawn(self) -> MandalaConfig:
184-
if not any(
185-
(self.rings, self.spokes, self.rosette, self.petals, self.beads, self.polygons)
186-
):
230+
if not any((
231+
self.rings, self.spokes, self.rosette, self.petals, self.beads,
232+
self.scallops, self.pinwheel, self.polygons,
233+
)):
187234
raise ValueError(
188-
"a mandala needs `rings`, `spokes`, a `rosette`, `petals`, `beads` or "
189-
"`polygons` — with none there is only an empty disc (§ 7.11)"
235+
"a mandala needs `rings`, `spokes`, a `rosette`, `petals`, `beads`, "
236+
"`scallops`, a `pinwheel` or `polygons` — with none there is only an "
237+
"empty disc (§ 7.11)"
190238
)
191239
return self
192240

@@ -244,6 +292,14 @@ def describe(self, cfg: MandalaConfig) -> list[str]:
244292
for beads in cfg.bead_rings:
245293
count = beads.count if beads.count is not None else cfg.sectors
246294
lines.append(f"beads: {count} at {beads.at:.2f} of the radius")
295+
for scallops in cfg.scallop_rings:
296+
count = scallops.count if scallops.count is not None else cfg.sectors
297+
lines.append(f"scallops: {count} at {scallops.at:.2f} of the radius")
298+
for pinwheel in cfg.pinwheels:
299+
count = pinwheel.count if pinwheel.count is not None else cfg.sectors
300+
lines.append(
301+
f"pinwheel: {count} {pinwheel.sides}-gons at {pinwheel.at:.2f} of the radius"
302+
)
247303
for index, spec in enumerate(cfg.polygons or ()):
248304
sides = spec.sides if spec.sides is not None else cfg.sectors
249305
shape = f"{{{sides}/{spec.step}}}" if spec.step > 1 else f"{sides}-gon"
@@ -296,6 +352,10 @@ def generate(
296352
yield from _petals(petals, cfg.sectors, center, outer)
297353
for beads in cfg.bead_rings:
298354
yield from _beads(beads, cfg.sectors, center, outer)
355+
for scallops in cfg.scallop_rings:
356+
yield from _scallops(scallops, cfg.sectors, center, outer)
357+
for pinwheel in cfg.pinwheels:
358+
yield from _pinwheel(pinwheel, cfg.sectors, center, outer)
299359
for spec in cfg.polygons or ():
300360
yield _polygon(spec, cfg.sectors, center, outer)
301361

@@ -420,6 +480,63 @@ def _beads(beads: Beads, sectors: int, center: Point, outer: Um) -> Iterator[Dot
420480
)
421481

422482

483+
def _scallops(scallops: Scallops, sectors: int, center: Point, outer: Um) -> Iterator[Arc]:
484+
"""A closed wavy ring: one arc between each pair of adjacent base points."""
485+
count = scallops.count if scallops.count is not None else sectors
486+
at = round(scallops.at * outer)
487+
depth = round(scallops.depth * outer)
488+
points = [polar_point(center, at, UP + i * 360.0 / count) for i in range(count)]
489+
for i in range(count):
490+
p1, p2 = points[i], points[(i + 1) % count]
491+
side = _outward_side(p1, p2, center, inward=scallops.inward)
492+
o, radius, start, sweep = _arc_geometry(p1, p2, depth, side)
493+
yield Arc(
494+
center=o,
495+
radius=radius,
496+
start_angle=start,
497+
sweep=sweep,
498+
weight=scallops.weight.mm,
499+
color=scallops.color or "#000000",
500+
layer=Layer.PATTERN,
501+
)
502+
503+
504+
def _outward_side(p1: Point, p2: Point, center: Point, *, inward: bool) -> float:
505+
"""Which side of the chord bulges away from the centre (§ 7.11).
506+
507+
`_arc_geometry` bulges toward the perpendicular `(-dy, dx)` scaled by the
508+
returned sign; picking the sign that points away from the centre makes a
509+
lobe, its opposite makes a cusp.
510+
"""
511+
mx, my = (p1.x + p2.x) / 2, (p1.y + p2.y) / 2
512+
dx, dy = p2.x - p1.x, p2.y - p1.y
513+
outward = (-dy) * (mx - center.x) + dx * (my - center.y)
514+
side = 1.0 if outward >= 0 else -1.0
515+
return -side if inward else side
516+
517+
518+
def _pinwheel(pinwheel: Pinwheel, sectors: int, center: Point, outer: Um) -> Iterator[Polygon]:
519+
"""Small polygons round a ring, each turned with its place and by `twist`."""
520+
count = pinwheel.count if pinwheel.count is not None else sectors
521+
at = round(pinwheel.at * outer)
522+
size = round(pinwheel.size * outer)
523+
for i in range(count):
524+
angle = UP + i * 360.0 / count
525+
hub = polar_point(center, at, angle)
526+
points = tuple(
527+
polar_point(hub, size, angle + pinwheel.twist + k * 360.0 / pinwheel.sides)
528+
for k in range(pinwheel.sides)
529+
)
530+
yield Polygon(
531+
points=points,
532+
closed=True,
533+
weight=pinwheel.weight.mm,
534+
color=pinwheel.color or "#000000",
535+
fill_color=pinwheel.fill_color,
536+
layer=Layer.PATTERN,
537+
)
538+
539+
423540
def _rosette(rosette: Rosette, sectors: int, center: Point, outer: Um) -> Iterator[Arc]:
424541
at = round(rosette.at * outer)
425542
radius = round(rosette.radius * outer)
@@ -478,6 +595,12 @@ def _max_reach(cfg: MandalaConfig, outer: Um) -> tuple[Um, str]:
478595
reaches.append((round(share * outer), "petals"))
479596
for beads in cfg.bead_rings:
480597
reaches.append((round(beads.at * outer) + round(beads.size.um / 2), "beads"))
598+
for scallops in cfg.scallop_rings:
599+
# Outward lobes reach `at + depth`; cusps turned in reach only the ring.
600+
share = scallops.at + (0.0 if scallops.inward else scallops.depth)
601+
reaches.append((round(share * outer), "scallops"))
602+
for pinwheel in cfg.pinwheels:
603+
reaches.append((round((pinwheel.at + pinwheel.size) * outer), "pinwheel"))
481604
for index, spec in enumerate(cfg.polygons or ()):
482605
reaches.append((round(spec.radius * outer), f"polygons.{index}"))
483606
return max(reaches, key=lambda item: item[0])

docs/implementation-decisions.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -635,13 +635,13 @@ no extension — a preset name like `millimeter-a4` is not a filename until it d
635635
**§ 7.11.** Making `mandala` more varied added three families (`petals`,
636636
`beads`, and a widened `rosette`). Three calls were made where § 7.11 was silent:
637637

638-
- **A motif ring is single *or* a list.** `rosette`, `petals` and `beads` each
639-
accept one mapping or a list of them (`Rosette | tuple[Rosette, ...]`), so a
640-
band can be stacked at several radii. A single mapping still validates, so the
641-
old example and preset are untouched — backward compatibility over a second
642-
plural key. `polygons` stays a plain list, as it already was. A `_as_rings`
643-
helper normalises to a tuple so `generate`/`check`/`describe`/`_max_reach`
644-
iterate one shape.
638+
- **A motif ring is single *or* a list.** `rosette`, `petals`, `beads`,
639+
`scallops` and `pinwheel` each accept one mapping or a list of them
640+
(`Rosette | tuple[Rosette, ...]`), so a band can be stacked at several radii. A
641+
single mapping still validates, so the old example and preset are untouched —
642+
backward compatibility over a second plural key. `polygons` stays a plain list,
643+
as it already was. A `_as_rings` helper normalises to a tuple so
644+
`generate`/`check`/`describe`/`_max_reach` iterate one shape.
645645
- **A petal is two arcs, not a new primitive.** § 6 fixes six primitives and
646646
§ 15.2 forbids free paths. A pointed leaf is two circular arcs meeting at a
647647
base and a tip: `_arc_geometry` turns two endpoints and a sagitta into a
@@ -653,9 +653,17 @@ no extension — a preset name like `millimeter-a4` is not a filename until it d
653653
bead ring is not new vocabulary, only a mark the blade had not used; the
654654
spec's § 7.11 mark line is extended to say so rather than adding it silently.
655655

656+
Two later families joined on the same terms. **`scallops`** — a wavy ring — is N
657+
arcs between adjacent base points, each reusing `_arc_geometry` with the side
658+
that bulges away from the centre (`inward` flips it to cusps); no new geometry,
659+
the petal's own two-arc helper. **`pinwheel`** — small polygons turned round a
660+
ring — is `polar_point` + `Polygon`, the polygon family's own tools. Both are
661+
single-or-list like the rest.
662+
656663
The area check follows the existing rule (§ 8.2, § 12 point 13): each ring
657664
reports its reach — petal tip (`outer`, with a lateral term for a fat leaf),
658-
bead ring (`at + size/2`) — so a motif past the pattern area is refused before
665+
bead ring (`at + size/2`), scallop lobe (`at + depth`, or `at` for cusps),
666+
pinwheel (`at + size`) — so a motif past the pattern area is refused before
659667
page one, named, never clipped.
660668

661669
---

examples/10b-mandala-scallops.pdf

8.14 KB
Binary file not shown.

examples/10b-mandala-scallops.yaml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# mandala (variant) — the same blade, a different character: a scalloped outer
2+
# border, a pinwheel of tilted squares, a second scalloped ring turned inward
3+
# around a flower-of-life centre, and a small bead ring. Sixteen-fold. A4.
4+
# Regenerate: ctrlgrid -d examples/10b-mandala-scallops.yaml -o out.pdf
5+
version: 1
6+
7+
defs:
8+
guide: &guide "#c2c8d0"
9+
petal: &petal "#8aa0b8"
10+
star: &star "#2f5686"
11+
12+
page:
13+
format: a4
14+
margin: 16mm
15+
16+
generator: mandala
17+
sectors: 16
18+
outer_radius: 48%s
19+
20+
rings:
21+
count: 4
22+
weight: 0.15pt
23+
color: *guide
24+
spokes:
25+
inner: 0.06
26+
weight: 0.15pt
27+
color: *guide
28+
29+
# Two wavy rings: thirty-two lobes bulging out at the rim, and sixteen turned
30+
# inward (cusps) around the centre.
31+
scallops:
32+
- {at: 0.98, count: 32, depth: 0.05, color: *star}
33+
- {at: 0.62, count: 16, depth: 0.05, inward: true, color: *petal}
34+
35+
# A pinwheel: sixteen little squares on a ring, each twisted 22° so the ring
36+
# looks to spin.
37+
pinwheel:
38+
at: 0.80
39+
size: 0.10
40+
sides: 4
41+
count: 16
42+
twist: 22
43+
weight: 0.2pt
44+
color: *star
45+
46+
# A flower-of-life rosette and a small bead ring at the middle.
47+
rosette:
48+
at: 0.34
49+
radius: 0.16
50+
mirror: true
51+
weight: 0.2pt
52+
color: *petal
53+
beads:
54+
- {at: 0.16, count: 16, size: 1.0mm, color: *star}

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ proportionally.
3030
| <img src="previews/08-form-weekly.png" width="220"> | **`form`** — a weekly planner: seven day rows with a done-box, then a notes block.<br>[`08-form-weekly.yaml`](08-form-weekly.yaml) · [PDF](08-form-weekly.pdf) |
3131
| <img src="previews/09-perspective-2pt.png" width="220"> | **`perspective`** — a two-point drawing grid: a horizon, two vanishing points off the sheet, and true verticals.<br>[`09-perspective-2pt.yaml`](09-perspective-2pt.yaml) · [PDF](09-perspective-2pt.pdf) |
3232
| <img src="previews/10-mandala.png" width="220"> | **`mandala`** — a twelve-fold template: guide rings and spokes, a flower-of-life rosette, a double ring of lotus petals, two bead rings and a twelve-sided frame.<br>[`10-mandala.yaml`](10-mandala.yaml) · [PDF](10-mandala.pdf) |
33+
| <img src="previews/10b-mandala-scallops.png" width="220"> | **`mandala`** (variant) — the same blade, a different character: a scalloped outer border, a pinwheel of tilted squares, an inward-turned scalloped ring and a flower-of-life centre.<br>[`10b-mandala-scallops.yaml`](10b-mandala-scallops.yaml) · [PDF](10b-mandala-scallops.pdf) |
3334
| <img src="previews/11-cover-calibration.png" width="220"> | **`--cover`** — the calibration cover sheet (§ 8.8): a 50 mm square and 100 mm rule to catch a scaling print driver, a ladder of stroke weights so you see how each one prints, and a settings summary. The preview is the cover; the grid it precedes is page two.<br>[`11-cover-calibration.yaml`](11-cover-calibration.yaml) · [PDF](11-cover-calibration.pdf) |
3435

3536
The maze booklet uses a fixed seed for a reproducible set — regenerate it with:
74.3 KB
Loading

pflichtenheft-vorlagengenerator.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,12 +1105,18 @@ Die Motivfamilien (jede optional):
11051105
- **`beads`** — Punkte gleichmäßig auf einem Ring (`at`, `count`, `size`,
11061106
`rotate`); führt `Dot` ein. `Dot` ist ein reguläres Primitiv (§ 6), also kein
11071107
neues Vokabular, nur eine bisher ungenutzte Marke.
1108+
- **`scallops`** — ein gewellter Ring aus N Bögen, die von einem Basiskreis
1109+
(`at`) um `depth` nach außen wölben; `inward` dreht die Wölbung nach innen
1110+
(Spitzen statt Lappen). Nur `Arc`.
1111+
- **`pinwheel`** — kleine `sides`-Ecke (Umkreisradius `size`) auf einem Ring
1112+
(`at`), jede mit ihrer Position und um `twist` gedreht — ein Windrad. Nur
1113+
`Polygon`.
11081114
- **`polygons`** — einbeschriebene reguläre oder Sternpolygone.
11091115

1110-
**Motivring einzeln oder als Liste.** `rosette`, `petals` und `beads` nehmen
1111-
entweder eine einzelne Angabe **oder** eine Liste — gestapelte Bänder in
1112-
verschiedenen Radien. Eine einzelne Map bleibt gültig, ältere Defs laufen
1113-
unverändert. (`polygons` ist wie bisher stets eine Liste.)
1116+
**Motivring einzeln oder als Liste.** `rosette`, `petals`, `beads`, `scallops`
1117+
und `pinwheel` nehmen entweder eine einzelne Angabe **oder** eine Liste —
1118+
gestapelte Bänder in verschiedenen Radien. Eine einzelne Map bleibt gültig,
1119+
ältere Defs laufen unverändert. (`polygons` ist wie bisher stets eine Liste.)
11141120

11151121
Beide sind bewusst **nicht** in v1. Sie stehen hier, damit die Architektur sie
11161122
nicht ausschließt — insbesondere `Arc` und `Polygon` im Vokabular (§ 6) und die

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ license = "MIT"
1414
license-files = ["LICENSE"]
1515
requires-python = ">=3.11"
1616
authors = [{ name = "DocAtPrompt (Alexander Erben)" }]
17-
keywords = ["pdf", "graph-paper", "grid", "template", "eink", "remarkable"]
17+
keywords = [
18+
"pdf", "pdf-generation", "graph-paper", "grid-paper", "dot-grid",
19+
"isometric", "printable", "template", "reproducible", "cli",
20+
"eink", "remarkable", "mandala", "maze", "staff-paper", "sheet-music",
21+
"polar-graph", "tiling", "perspective-grid",
22+
]
1823
classifiers = [
1924
"Development Status :: 3 - Alpha",
2025
"Environment :: Console",

0 commit comments

Comments
 (0)