nandi/bresenham-circlepublic Fork 0
3d0b0a3
Commits
Clone
git clone https://git.rickub.com/nandi/bresenham-circle.git
git clone ssh://git@rickub.com/nandi/bresenham-circle.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Bresenham midpoint circle visualizer, with a zero-symmetry counterpart

Steps the midpoint circle algorithm one decision at a time on a zoomed pixel
grid: candidate pixels, the midpoint under test, the decision variable, and the
source line that just executed. Only the 0-45 octant is computed; the other
seven are drawn as reflections so the D4 symmetry is visible rather than
asserted.

Alongside it, the same circle drawn by sprinkling uniform directions and
snapping them to cells. That output has a trivial automorphism group but is
isotropic in distribution. Both panes count their exact symmetries live by
testing all 8 lattice operations against the drawn cells: 8/8 versus 1/8.

Window is resizable and the radius cap follows the available grid area.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T08:43:11-07:00 Browse files
3d0b0a3
added .gitignore +7 -0
new file mode 100644
@@ -0,0 +1,7 @@
1+# nim/nimble build output
2+/bresenham
3+nimcache/
4+*.o
5+
6+# screenshots produced by --shot
7+shot.png
new file mode 100644
@@ -0,0 +1,7 @@
1+# nim/nimble build output
2+/bresenham
3+nimcache/
4+*.o
5+
6+# screenshots produced by --shot
7+shot.png
added README.md +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+# bresenham-circle
2+
3+Bresenham's midpoint circle algorithm, stepped one decision at a time, next to a
4+Poisson sprinkle that draws the same circle with **zero** exact symmetries.
5+
6+Written in [Nim](https://nim-lang.org) with [naylib](https://github.com/planetis-m/naylib).
7+
8+![screenshot](docs/screenshot.png)
9+
10+## Build
11+
12+```sh
13+nimble build
14+./bresenham
15+```
16+
17+Flags: `--r=N` start at a radius, `--shot` render to `shot.png` and exit,
18+`--frame=N` which frame to capture.
19+
20+## Controls
21+
22+| key | |
23+|---|---|
24+| `SPACE` | one iteration of the loop body |
25+| `A` | auto-step |
26+| `R` | reset |
27+| `UP` / `DOWN` | radius (hold to scrub, accelerating) |
28+| `[` `]` | sprinkle oversample factor |
29+| `ESC` | quit |
30+
31+The window is resizable, and the maximum radius is whatever currently fits —
32+grow the window and the cap grows with it.
33+
34+## Left pane: Bresenham
35+
36+The whole algorithm is three integers and a sign test. No floats, no `sqrt`,
37+no trig, no π.
38+
39+```nim
40+x = 0; y = r; d = 1 - r
41+while x <= y:
42+ plot8(x, y)
43+ if d < 0: # midpoint (x+1, y-1/2) is inside
44+ d += 2*x + 3
45+ else: # outside
46+ d += 2*(x - y) + 5
47+ y -= 1
48+ x += 1
49+```
50+
51+`d` is the sign of `F(x, y) = x² + y² − r²` evaluated at the midpoint between
52+the two candidate pixels, carried forward incrementally. Inside means keep `y`,
53+outside means step it in. The visualization shows the two candidates (`E`/`SE`),
54+the midpoint under test, and lights up whichever branch just executed.
55+
56+Only the 0°–45° octant is computed. Inside that wedge the slope stays between
57+0 and −1, so `x` advances by exactly 1 every iteration and `y` either holds or
58+drops by 1 — never more. The other seven octants are reflections:
59+
60+```nim
61+[(x, y), (y, x), (y, -x), (x, -y), (-x, -y), (-y, -x), (-y, x), (-x, y)]
62+```
63+
64+That is D₄, the complete symmetry group of the square lattice. There is no
65+ninth symmetry available to any shape on a square grid.
66+
67+Cost is **O(r)**: the loop runs `r/√2 ≈ 0.707r` times, so the full circle is
68+`4√2·r ≈ 5.657` pixels per unit radius. (About 10% fewer than the circumference
69+`2πr`, because roughly half the steps are diagonal and cover √2 of arc for one
70+pixel.)
71+
72+## Right pane: Poisson sprinkle
73+
74+```nim
75+for _ in 1 .. n:
76+ let t = rng.rand(2.0 * PI)
77+ samples.add (int(round(r * cos(t))), int(round(r * sin(t))))
78+```
79+
80+No octants, no mirroring, no decision variable. Its output has a **trivial
81+automorphism group** — no rotation or reflection maps the pixel set to itself —
82+yet it is isotropic *in distribution*, because uniform sampling on the circle is
83+rotation-invariant. This is the causal-set trick: give up exact symmetry, keep
84+symmetry of the measure.
85+
86+Both panes print a live symmetry count, computed by testing all 8 lattice
87+operations against the drawn cells. Bresenham reports 8/8, the sprinkle 1/8.
88+
89+### The measurable difference
90+
91+Take the radial error as a function of angle and transform it. Bresenham's
92+spectrum has power **only** at harmonics that are multiples of 4 — the rotation
93+subgroup C₄ forces 90° periodicity — and is exactly zero elsewhere. Forbidden
94+harmonics, like a crystal's forbidden diffraction peaks. At r = 2000:
95+
96+| | power at k = 4, 8, 12, … | everywhere else |
97+|---|---|---|
98+| Bresenham | 0.00354 | **0.000000** |
99+| sprinkle | 0.00250 | 0.00085 |
100+
101+The sprinkle is broadband: no structure, no preferred directions.
102+
103+That trade is why production renderers use stochastic and blue-noise sampling —
104+structured aliasing (moiré, banding, visible staircases) is far more
105+objectionable than unstructured noise of the same magnitude. The price is
106+visible in the panel: several samples rolled per cell landed, more cells for the
107+same circle, no determinism, and trig plus an RNG where Bresenham used integer
108+adds. Drop the oversample to 1× with `[` and holes open in the ring — the
109+coupon-collector problem, on screen.
110+
111+## License
112+
113+MIT
new file mode 100644
@@ -0,0 +1,113 @@
1+# bresenham-circle
2+
3+Bresenham's midpoint circle algorithm, stepped one decision at a time, next to a
4+Poisson sprinkle that draws the same circle with **zero** exact symmetries.
5+
6+Written in [Nim](https://nim-lang.org) with [naylib](https://github.com/planetis-m/naylib).
7+
8+![screenshot](docs/screenshot.png)
9+
10+## Build
11+
12+```sh
13+nimble build
14+./bresenham
15+```
16+
17+Flags: `--r=N` start at a radius, `--shot` render to `shot.png` and exit,
18+`--frame=N` which frame to capture.
19+
20+## Controls
21+
22+| key | |
23+|---|---|
24+| `SPACE` | one iteration of the loop body |
25+| `A` | auto-step |
26+| `R` | reset |
27+| `UP` / `DOWN` | radius (hold to scrub, accelerating) |
28+| `[` `]` | sprinkle oversample factor |
29+| `ESC` | quit |
30+
31+The window is resizable, and the maximum radius is whatever currently fits —
32+grow the window and the cap grows with it.
33+
34+## Left pane: Bresenham
35+
36+The whole algorithm is three integers and a sign test. No floats, no `sqrt`,
37+no trig, no π.
38+
39+```nim
40+x = 0; y = r; d = 1 - r
41+while x <= y:
42+ plot8(x, y)
43+ if d < 0: # midpoint (x+1, y-1/2) is inside
44+ d += 2*x + 3
45+ else: # outside
46+ d += 2*(x - y) + 5
47+ y -= 1
48+ x += 1
49+```
50+
51+`d` is the sign of `F(x, y) = x² + y² − r²` evaluated at the midpoint between
52+the two candidate pixels, carried forward incrementally. Inside means keep `y`,
53+outside means step it in. The visualization shows the two candidates (`E`/`SE`),
54+the midpoint under test, and lights up whichever branch just executed.
55+
56+Only the 0°–45° octant is computed. Inside that wedge the slope stays between
57+0 and −1, so `x` advances by exactly 1 every iteration and `y` either holds or
58+drops by 1 — never more. The other seven octants are reflections:
59+
60+```nim
61+[(x, y), (y, x), (y, -x), (x, -y), (-x, -y), (-y, -x), (-y, x), (-x, y)]
62+```
63+
64+That is D₄, the complete symmetry group of the square lattice. There is no
65+ninth symmetry available to any shape on a square grid.
66+
67+Cost is **O(r)**: the loop runs `r/√2 ≈ 0.707r` times, so the full circle is
68+`4√2·r ≈ 5.657` pixels per unit radius. (About 10% fewer than the circumference
69+`2πr`, because roughly half the steps are diagonal and cover √2 of arc for one
70+pixel.)
71+
72+## Right pane: Poisson sprinkle
73+
74+```nim
75+for _ in 1 .. n:
76+ let t = rng.rand(2.0 * PI)
77+ samples.add (int(round(r * cos(t))), int(round(r * sin(t))))
78+```
79+
80+No octants, no mirroring, no decision variable. Its output has a **trivial
81+automorphism group** — no rotation or reflection maps the pixel set to itself —
82+yet it is isotropic *in distribution*, because uniform sampling on the circle is
83+rotation-invariant. This is the causal-set trick: give up exact symmetry, keep
84+symmetry of the measure.
85+
86+Both panes print a live symmetry count, computed by testing all 8 lattice
87+operations against the drawn cells. Bresenham reports 8/8, the sprinkle 1/8.
88+
89+### The measurable difference
90+
91+Take the radial error as a function of angle and transform it. Bresenham's
92+spectrum has power **only** at harmonics that are multiples of 4 — the rotation
93+subgroup C₄ forces 90° periodicity — and is exactly zero elsewhere. Forbidden
94+harmonics, like a crystal's forbidden diffraction peaks. At r = 2000:
95+
96+| | power at k = 4, 8, 12, … | everywhere else |
97+|---|---|---|
98+| Bresenham | 0.00354 | **0.000000** |
99+| sprinkle | 0.00250 | 0.00085 |
100+
101+The sprinkle is broadband: no structure, no preferred directions.
102+
103+That trade is why production renderers use stochastic and blue-noise sampling —
104+structured aliasing (moiré, banding, visible staircases) is far more
105+objectionable than unstructured noise of the same magnitude. The price is
106+visible in the panel: several samples rolled per cell landed, more cells for the
107+same circle, no determinism, and trig plus an RNG where Bresenham used integer
108+adds. Drop the oversample to 1× with `[` and holes open in the ring — the
109+coupon-collector problem, on screen.
110+
111+## License
112+
113+MIT
added bresenham.nimble +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+version = "0.1.0"
2+author = "scratch"
3+description = "Bresenham midpoint circle, visualized with naylib"
4+license = "MIT"
5+srcDir = "src"
6+bin = @["bresenham"]
7+
8+requires "nim >= 2.0.0"
9+requires "naylib >= 25.0.0"
new file mode 100644
@@ -0,0 +1,9 @@
1+version = "0.1.0"
2+author = "scratch"
3+description = "Bresenham midpoint circle, visualized with naylib"
4+license = "MIT"
5+srcDir = "src"
6+bin = @["bresenham"]
7+
8+requires "nim >= 2.0.0"
9+requires "naylib >= 25.0.0"
added docs/screenshot.png +0 -0
new file mode 100644
Binary files /dev/null and b/docs/screenshot.png differ
new file mode 100644
Binary files /dev/null and b/docs/screenshot.png differBinary files /dev/null and b/docs/screenshot.png differ
added src/bresenham.nim +342 -0
new file mode 100644
@@ -0,0 +1,342 @@
1+## Two ways to put a circle on a grid, side by side.
2+##
3+## left -- Bresenham: integer decisions, one octant, mirrored 8 ways.
4+## right -- Poisson sprinkle: uniform directions snapped to cells.
5+##
6+## Both land on the same lattice. The difference is the symmetry of the
7+## *selection*: Bresenham's is exactly 8-fold, the sprinkle's is trivial.
8+##
9+## Controls: SPACE step A auto R reset UP/DOWN radius [ ] oversample ESC
10+
11+import raylib, std/[strformat, os, math, strutils, random, sets, hashes]
12+
13+const
14+ InitW = 1400
15+ InitH = 760
16+ PanelWant = 380
17+ MinCell = 2
18+ MinRadius = 2
19+
20+ Bg = Color(r: 18, g: 18, b: 24, a: 255)
21+ GridLine = Color(r: 36, g: 36, b: 46, a: 255)
22+ Axis = Color(r: 70, g: 70, b: 88, a: 255)
23+ TrueArc = Color(r: 100, g: 100, b: 128, a: 255)
24+ Octant = Color(r: 88, g: 166, b: 255, a: 255)
25+ Mirror = Color(r: 88, g: 166, b: 255, a: 105)
26+ Sprink = Color(r: 118, g: 222, b: 160, a: 210)
27+ Cursor = Color(r: 255, g: 176, b: 64, a: 255)
28+ Candidate = Color(r: 255, g: 176, b: 64, a: 60)
29+ Mid = Color(r: 255, g: 92, b: 92, a: 255)
30+ Ink = Color(r: 226, g: 226, b: 236, a: 255)
31+ Dim = Color(r: 130, g: 130, b: 148, a: 255)
32+ HiLine = Color(r: 40, g: 62, b: 92, a: 255)
33+
34+type
35+ Branch = enum brNone, brEast, brSouthEast
36+
37+ Cells = HashSet[(int, int)]
38+
39+ State = object
40+ r: int
41+ # -- Bresenham
42+ x, y, d: int ## the entire algorithm state: three integers
43+ plotted: seq[(int, int)] ## octant only; the rest is reflection
44+ bcells: Cells ## all 8 octants, for counting
45+ branch: Branch
46+ lastUpdate: string
47+ done: bool
48+ # -- Poisson sprinkle
49+ over: int ## samples per Bresenham pixel
50+ samples: seq[(int, int)] ## pre-rolled, revealed progressively
51+ revealed: int
52+ scells: Cells
53+ perStep: int
54+
55+# ---------------------------------------------------------------- algorithms --
56+
57+proc mirrors(x, y: int): array[8, (int, int)] =
58+ ## The complete symmetry group of the square lattice: D4, order 8.
59+ [(x, y), (y, x), (y, -x), (x, -y), (-x, -y), (-y, -x), (-y, x), (-x, y)]
60+
61+proc reset(s: var State, r: int, over = -1) =
62+ if over >= 0: s.over = over
63+ if s.over == 0: s.over = 4
64+ s.r = r
65+ s.x = 0
66+ s.y = r
67+ s.d = 1 - r ## F(1, r - 1/2), the 1/4 dropped so d stays integral
68+ s.plotted = @[]
69+ s.bcells = initHashSet[(int, int)]()
70+ s.branch = brNone
71+ s.lastUpdate = ""
72+ s.done = false
73+
74+ # Sprinkle: uniform in angle, so isotropic in distribution but with no
75+ # exact symmetry at all. Fixed seed per radius so runs are reproducible.
76+ var rng = initRand(0xC0FFEE + r * 7919)
77+ let n = int(s.over.float * 4.0 * sqrt(2.0) * r.float)
78+ s.samples = newSeqOfCap[(int, int)](n)
79+ for _ in 1 .. n:
80+ let t = rng.rand(2.0 * PI)
81+ s.samples.add (int(round(r.float * cos(t))), int(round(r.float * sin(t))))
82+ s.revealed = 0
83+ s.scells = initHashSet[(int, int)]()
84+ s.perStep = max(1, n div max(1, int(r.float / sqrt(2.0))))
85+
86+proc reveal(s: var State, k: int) =
87+ let stop = min(s.revealed + k, s.samples.len)
88+ while s.revealed < stop:
89+ s.scells.incl s.samples[s.revealed]
90+ inc s.revealed
91+
92+proc step(s: var State) =
93+ ## Exactly one iteration of the Bresenham loop body, plus the sprinkle's
94+ ## proportional share of samples so the two fill at a comparable rate.
95+ if s.done: return
96+ if s.x > s.y:
97+ s.done = true
98+ s.branch = brNone
99+ s.reveal(s.samples.len)
100+ return
101+
102+ s.plotted.add (s.x, s.y)
103+ for m in mirrors(s.x, s.y): s.bcells.incl m
104+
105+ if s.d < 0:
106+ # midpoint (x+1, y-1/2) is INSIDE the circle -> East, keep y
107+ s.branch = brEast
108+ s.lastUpdate = fmt"d += 2*{s.x} + 3 = {2 * s.x + 3}"
109+ s.d += 2 * s.x + 3
110+ else:
111+ # midpoint is OUTSIDE -> South-East, step y in
112+ s.branch = brSouthEast
113+ s.lastUpdate = fmt"d += 2*({s.x} - {s.y}) + 5 = {2 * (s.x - s.y) + 5}"
114+ s.d += 2 * (s.x - s.y) + 5
115+ dec s.y
116+ inc s.x
117+
118+ s.reveal(s.perStep)
119+ if s.x > s.y:
120+ s.done = true
121+ s.reveal(s.samples.len)
122+
123+proc symmetries(c: Cells): int =
124+ ## How many of the lattice's 8 symmetries actually map this pixel set to
125+ ## itself. Bresenham: 8. A sprinkle: 1 (the identity), essentially always.
126+ if c.len == 0: return 0
127+ for k in 0 ..< 8:
128+ var ok = true
129+ for (x, y) in c:
130+ if mirrors(x, y)[k] notin c:
131+ ok = false
132+ break
133+ if ok: inc result
134+
135+# ------------------------------------------------------------------- layout --
136+
137+proc layout(sw, sh: int): tuple[panelW, gridW, halfW, maxR, cellCap: int] =
138+ let panelW = clamp(PanelWant, 280, sw div 3)
139+ let gridW = sw - panelW
140+ let halfW = gridW div 2
141+ let avail = max(min(halfW - 30, sh - 150), MinCell)
142+ let maxR = max(MinRadius, (avail div MinCell - 3) div 2)
143+ (panelW, gridW, halfW, maxR, avail)
144+
145+# ------------------------------------------------------------------ drawing --
146+
147+type View = object
148+ ox, oy, cell, half: int
149+
150+proc cx(v: View, gx: int): int = v.ox + gx * v.cell - v.half
151+proc cy(v: View, gy: int): int = v.oy - gy * v.cell - v.half
152+
153+proc drawRect(x, y, w, h: int, c: Color) =
154+ drawRectangle(x.int32, y.int32, w.int32, h.int32, c)
155+
156+proc drawRectLines(x, y, w, h: int, c: Color) =
157+ drawRectangleLines(x.int32, y.int32, w.int32, h.int32, c)
158+
159+proc drawLn(x1, y1, x2, y2: int, c: Color) =
160+ drawLine(x1.int32, y1.int32, x2.int32, y2.int32, c)
161+
162+proc text(s: string, x, y, size: int, c: Color) =
163+ drawText(s, x.int32, y.int32, size.int32, c)
164+
165+proc backdrop(v: View, r: int) =
166+ ## Grid, axes and the ideal circle both methods are chasing.
167+ let ext = r + 1
168+ if v.cell >= 9:
169+ for i in -ext .. ext:
170+ drawLn(v.cx(-ext), v.cy(i) + v.half, v.cx(ext) + v.cell, v.cy(i) + v.half, GridLine)
171+ drawLn(v.cx(i) + v.half, v.cy(-ext) + v.cell, v.cx(i) + v.half, v.cy(ext), GridLine)
172+ drawLn(v.cx(-ext), v.oy, v.cx(ext) + v.cell, v.oy, Axis)
173+ drawLn(v.ox, v.cy(-ext) + v.cell, v.ox, v.cy(ext), Axis)
174+ drawCircleLines(v.ox.int32, v.oy.int32, float32(r * v.cell), TrueArc)
175+
176+proc main =
177+ setConfigFlags(flags(WindowResizable))
178+ initWindow(InitW, InitH, "Bresenham vs Poisson sprinkle")
179+ defer: closeWindow()
180+ setWindowMinSize(900, 640)
181+ setTargetFPS(60)
182+
183+ var
184+ s = State()
185+ auto = false
186+ held = 0
187+ tick = 0
188+ frames = 0
189+ shot = "--shot" in commandLineParams()
190+ shotFrame = 400
191+ startR = 12
192+ for a in commandLineParams():
193+ if a.startsWith("--r="): startR = max(parseInt(a[4 .. ^1]), MinRadius)
194+ elif a.startsWith("--frame="): shotFrame = parseInt(a[8 .. ^1])
195+ s.over = 4
196+ s.reset(min(startR, layout(getScreenWidth().int, getScreenHeight().int).maxR))
197+ if shot: auto = true
198+
199+ while not windowShouldClose():
200+ inc frames
201+
202+ let
203+ sw = getScreenWidth().int
204+ sh = getScreenHeight().int
205+ (panelW, gridW, halfW, maxR, _) = layout(sw, sh)
206+ if s.r > maxR: s.reset(maxR)
207+
208+ # ---- input
209+ if isKeyPressed(Space): s.step()
210+ if isKeyPressed(A): auto = not auto
211+ if isKeyPressed(R): s.reset(s.r)
212+ if isKeyPressed(RightBracket) and s.over < 12: s.reset(s.r, s.over + 1)
213+ if isKeyPressed(LeftBracket) and s.over > 1: s.reset(s.r, s.over - 1)
214+ var delta = 0
215+ if isKeyPressed(Up): delta = 1
216+ elif isKeyPressed(Down): delta = -1
217+ elif isKeyDown(Up) or isKeyDown(Down):
218+ inc held
219+ if held > 18:
220+ let fast = if held > 70: 1 else: 3
221+ if held mod fast == 0: delta = if isKeyDown(Up): 1 else: -1
222+ else:
223+ held = 0
224+ if delta != 0:
225+ let want = clamp(s.r + delta, MinRadius, maxR)
226+ if want != s.r: s.reset(want)
227+ if auto and not s.done:
228+ inc tick
229+ if tick mod max(1, 10 - s.r div 8) == 0: s.step()
230+
231+ # ---- both panes share a cell size so they are directly comparable
232+ let
233+ span = 2 * s.r + 3
234+ cell = max(min(halfW - 30, sh - 150) div span, MinCell)
235+ oy = sh div 2 + 10
236+ left = View(ox: halfW div 2, oy: oy, cell: cell, half: cell div 2)
237+ right = View(ox: halfW + halfW div 2, oy: oy, cell: cell, half: cell div 2)
238+
239+ drawing:
240+ clearBackground(Bg)
241+
242+ # ================= left: Bresenham =================
243+ backdrop(left, s.r)
244+ let ext = s.r + 1
245+ drawLn(left.ox, left.oy, left.ox, left.cy(ext) + left.half, Axis)
246+ drawLn(left.ox, left.oy, left.cx(ext) + left.half, left.cy(ext) + left.half, Axis)
247+
248+ for (px, py) in s.plotted:
249+ for k, (mx, my) in mirrors(px, py):
250+ drawRect(left.cx(mx), left.cy(my), cell, cell,
251+ if k == 0: Octant else: Mirror)
252+
253+ if not s.done:
254+ drawRectLines(left.cx(s.x), left.cy(s.y), cell, cell, Cursor)
255+ if s.x + 1 <= s.y:
256+ drawRect(left.cx(s.x + 1), left.cy(s.y), cell, cell, Candidate)
257+ drawRect(left.cx(s.x + 1), left.cy(s.y - 1), cell, cell, Candidate)
258+ drawCircle(int32(left.cx(s.x + 1) + left.half), int32(left.cy(s.y) + cell),
259+ float32(max(3, cell div 5)), Mid)
260+
261+ # ================= right: Poisson sprinkle =================
262+ backdrop(right, s.r)
263+ for (mx, my) in s.scells:
264+ drawRect(right.cx(mx), right.cy(my), cell, cell, Sprink)
265+
266+ # ---- titles and per-pane stats
267+ let
268+ bSym = symmetries(s.bcells)
269+ sSym = symmetries(s.scells)
270+ lx = 24
271+ rx = halfW + 24
272+ text("BRESENHAM", lx, 16, 20, Octant)
273+ text("one octant, mirrored 8 ways", lx, 40, 14, Dim)
274+ text("POISSON SPRINKLE", rx, 16, 20, Sprink)
275+ text(fmt"uniform directions, snapped ({s.over}x oversample)", rx, 40, 14, Dim)
276+
277+ let by = sh - 66
278+ text(fmt"{s.bcells.len} cells {s.plotted.len} computed, rest free", lx, by, 16, Ink)
279+ text(fmt"exact symmetries: {bSym} / 8", lx, by + 22, 16, Octant)
280+ text(fmt"{s.scells.len} cells {s.revealed} samples rolled", rx, by, 16, Ink)
281+ text(fmt"exact symmetries: {sSym} / 8", rx, by + 22, 16, Sprink)
282+
283+ drawLn(halfW, 0, halfW, sh, GridLine)
284+
285+ # ================= panel =================
286+ let px = gridW + 24
287+ drawLn(gridW, 0, gridW, sh, GridLine)
288+
289+ text("MIDPOINT CIRCLE", px, 20, 22, Ink)
290+ text(fmt"radius {s.r} (max {maxR} at {sw}x{sh})", px, 48, 15, Dim)
291+
292+ let code = [
293+ "x = 0; y = r; d = 1 - r",
294+ "while x <= y:",
295+ " plot8(x, y)",
296+ " if d < 0: # mid inside",
297+ " d += 2*x + 3",
298+ " else: # mid outside",
299+ " d += 2*(x - y) + 5",
300+ " y -= 1",
301+ " x += 1",
302+ ]
303+ let lit =
304+ case s.branch
305+ of brEast: @[2, 3, 4, 8]
306+ of brSouthEast: @[2, 5, 6, 7, 8]
307+ of brNone: @[]
308+ for i, line in code:
309+ let ly = 84 + i * 22
310+ if i in lit: drawRect(px - 8, ly - 3, panelW - 32, 22, HiLine)
311+ text(line, px, ly, 16, if i in lit: Ink else: Dim)
312+
313+ var sy = 300
314+ text("state", px, sy, 16, Dim); sy += 26
315+ text(fmt"x = {s.x}", px, sy, 20, Ink); sy += 26
316+ text(fmt"y = {s.y}", px, sy, 20, Ink); sy += 26
317+ text(fmt"d = {s.d}", px, sy, 20, (if s.d < 0: Octant else: Cursor)); sy += 32
318+ text(s.lastUpdate, px, sy, 15, Cursor); sy += 30
319+ if s.done:
320+ text("done -- x > y, octant closed", px, sy, 18, Octant)
321+ elif s.x + 1 <= s.y:
322+ text(fmt"testing midpoint ({s.x + 1}, {s.y}-1/2)", px, sy, 15, Mid)
323+
324+ var ly = sh - 170
325+ text("cost of zero symmetry", px, ly, 15, Dim); ly += 24
326+ if s.done and s.scells.len > 0:
327+ let waste = s.revealed.float / s.scells.len.float
328+ text(fmt"{waste:.1f} samples per cell drawn", px, ly, 15, Sprink); ly += 22
329+ let gap = 100.0 * (s.scells.len.float / max(1, s.bcells.len).float - 1.0)
330+ text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Sprink); ly += 30
331+ else:
332+ ly += 52
333+
334+ text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20
335+ text("UP/DOWN radius (hold to scrub)", px, ly, 15, Dim); ly += 20
336+ text("[ ] sprinkle oversample", px, ly, 15, Dim)
337+
338+ if shot and frames == shotFrame:
339+ takeScreenshot("shot.png")
340+ break
341+
342+main()
new file mode 100644
@@ -0,0 +1,342 @@
1+## Two ways to put a circle on a grid, side by side.
2+##
3+## left -- Bresenham: integer decisions, one octant, mirrored 8 ways.
4+## right -- Poisson sprinkle: uniform directions snapped to cells.
5+##
6+## Both land on the same lattice. The difference is the symmetry of the
7+## *selection*: Bresenham's is exactly 8-fold, the sprinkle's is trivial.
8+##
9+## Controls: SPACE step A auto R reset UP/DOWN radius [ ] oversample ESC
10+
11+import raylib, std/[strformat, os, math, strutils, random, sets, hashes]
12+
13+const
14+ InitW = 1400
15+ InitH = 760
16+ PanelWant = 380
17+ MinCell = 2
18+ MinRadius = 2
19+
20+ Bg = Color(r: 18, g: 18, b: 24, a: 255)
21+ GridLine = Color(r: 36, g: 36, b: 46, a: 255)
22+ Axis = Color(r: 70, g: 70, b: 88, a: 255)
23+ TrueArc = Color(r: 100, g: 100, b: 128, a: 255)
24+ Octant = Color(r: 88, g: 166, b: 255, a: 255)
25+ Mirror = Color(r: 88, g: 166, b: 255, a: 105)
26+ Sprink = Color(r: 118, g: 222, b: 160, a: 210)
27+ Cursor = Color(r: 255, g: 176, b: 64, a: 255)
28+ Candidate = Color(r: 255, g: 176, b: 64, a: 60)
29+ Mid = Color(r: 255, g: 92, b: 92, a: 255)
30+ Ink = Color(r: 226, g: 226, b: 236, a: 255)
31+ Dim = Color(r: 130, g: 130, b: 148, a: 255)
32+ HiLine = Color(r: 40, g: 62, b: 92, a: 255)
33+
34+type
35+ Branch = enum brNone, brEast, brSouthEast
36+
37+ Cells = HashSet[(int, int)]
38+
39+ State = object
40+ r: int
41+ # -- Bresenham
42+ x, y, d: int ## the entire algorithm state: three integers
43+ plotted: seq[(int, int)] ## octant only; the rest is reflection
44+ bcells: Cells ## all 8 octants, for counting
45+ branch: Branch
46+ lastUpdate: string
47+ done: bool
48+ # -- Poisson sprinkle
49+ over: int ## samples per Bresenham pixel
50+ samples: seq[(int, int)] ## pre-rolled, revealed progressively
51+ revealed: int
52+ scells: Cells
53+ perStep: int
54+
55+# ---------------------------------------------------------------- algorithms --
56+
57+proc mirrors(x, y: int): array[8, (int, int)] =
58+ ## The complete symmetry group of the square lattice: D4, order 8.
59+ [(x, y), (y, x), (y, -x), (x, -y), (-x, -y), (-y, -x), (-y, x), (-x, y)]
60+
61+proc reset(s: var State, r: int, over = -1) =
62+ if over >= 0: s.over = over
63+ if s.over == 0: s.over = 4
64+ s.r = r
65+ s.x = 0
66+ s.y = r
67+ s.d = 1 - r ## F(1, r - 1/2), the 1/4 dropped so d stays integral
68+ s.plotted = @[]
69+ s.bcells = initHashSet[(int, int)]()
70+ s.branch = brNone
71+ s.lastUpdate = ""
72+ s.done = false
73+
74+ # Sprinkle: uniform in angle, so isotropic in distribution but with no
75+ # exact symmetry at all. Fixed seed per radius so runs are reproducible.
76+ var rng = initRand(0xC0FFEE + r * 7919)
77+ let n = int(s.over.float * 4.0 * sqrt(2.0) * r.float)
78+ s.samples = newSeqOfCap[(int, int)](n)
79+ for _ in 1 .. n:
80+ let t = rng.rand(2.0 * PI)
81+ s.samples.add (int(round(r.float * cos(t))), int(round(r.float * sin(t))))
82+ s.revealed = 0
83+ s.scells = initHashSet[(int, int)]()
84+ s.perStep = max(1, n div max(1, int(r.float / sqrt(2.0))))
85+
86+proc reveal(s: var State, k: int) =
87+ let stop = min(s.revealed + k, s.samples.len)
88+ while s.revealed < stop:
89+ s.scells.incl s.samples[s.revealed]
90+ inc s.revealed
91+
92+proc step(s: var State) =
93+ ## Exactly one iteration of the Bresenham loop body, plus the sprinkle's
94+ ## proportional share of samples so the two fill at a comparable rate.
95+ if s.done: return
96+ if s.x > s.y:
97+ s.done = true
98+ s.branch = brNone
99+ s.reveal(s.samples.len)
100+ return
101+
102+ s.plotted.add (s.x, s.y)
103+ for m in mirrors(s.x, s.y): s.bcells.incl m
104+
105+ if s.d < 0:
106+ # midpoint (x+1, y-1/2) is INSIDE the circle -> East, keep y
107+ s.branch = brEast
108+ s.lastUpdate = fmt"d += 2*{s.x} + 3 = {2 * s.x + 3}"
109+ s.d += 2 * s.x + 3
110+ else:
111+ # midpoint is OUTSIDE -> South-East, step y in
112+ s.branch = brSouthEast
113+ s.lastUpdate = fmt"d += 2*({s.x} - {s.y}) + 5 = {2 * (s.x - s.y) + 5}"
114+ s.d += 2 * (s.x - s.y) + 5
115+ dec s.y
116+ inc s.x
117+
118+ s.reveal(s.perStep)
119+ if s.x > s.y:
120+ s.done = true
121+ s.reveal(s.samples.len)
122+
123+proc symmetries(c: Cells): int =
124+ ## How many of the lattice's 8 symmetries actually map this pixel set to
125+ ## itself. Bresenham: 8. A sprinkle: 1 (the identity), essentially always.
126+ if c.len == 0: return 0
127+ for k in 0 ..< 8:
128+ var ok = true
129+ for (x, y) in c:
130+ if mirrors(x, y)[k] notin c:
131+ ok = false
132+ break
133+ if ok: inc result
134+
135+# ------------------------------------------------------------------- layout --
136+
137+proc layout(sw, sh: int): tuple[panelW, gridW, halfW, maxR, cellCap: int] =
138+ let panelW = clamp(PanelWant, 280, sw div 3)
139+ let gridW = sw - panelW
140+ let halfW = gridW div 2
141+ let avail = max(min(halfW - 30, sh - 150), MinCell)
142+ let maxR = max(MinRadius, (avail div MinCell - 3) div 2)
143+ (panelW, gridW, halfW, maxR, avail)
144+
145+# ------------------------------------------------------------------ drawing --
146+
147+type View = object
148+ ox, oy, cell, half: int
149+
150+proc cx(v: View, gx: int): int = v.ox + gx * v.cell - v.half
151+proc cy(v: View, gy: int): int = v.oy - gy * v.cell - v.half
152+
153+proc drawRect(x, y, w, h: int, c: Color) =
154+ drawRectangle(x.int32, y.int32, w.int32, h.int32, c)
155+
156+proc drawRectLines(x, y, w, h: int, c: Color) =
157+ drawRectangleLines(x.int32, y.int32, w.int32, h.int32, c)
158+
159+proc drawLn(x1, y1, x2, y2: int, c: Color) =
160+ drawLine(x1.int32, y1.int32, x2.int32, y2.int32, c)
161+
162+proc text(s: string, x, y, size: int, c: Color) =
163+ drawText(s, x.int32, y.int32, size.int32, c)
164+
165+proc backdrop(v: View, r: int) =
166+ ## Grid, axes and the ideal circle both methods are chasing.
167+ let ext = r + 1
168+ if v.cell >= 9:
169+ for i in -ext .. ext:
170+ drawLn(v.cx(-ext), v.cy(i) + v.half, v.cx(ext) + v.cell, v.cy(i) + v.half, GridLine)
171+ drawLn(v.cx(i) + v.half, v.cy(-ext) + v.cell, v.cx(i) + v.half, v.cy(ext), GridLine)
172+ drawLn(v.cx(-ext), v.oy, v.cx(ext) + v.cell, v.oy, Axis)
173+ drawLn(v.ox, v.cy(-ext) + v.cell, v.ox, v.cy(ext), Axis)
174+ drawCircleLines(v.ox.int32, v.oy.int32, float32(r * v.cell), TrueArc)
175+
176+proc main =
177+ setConfigFlags(flags(WindowResizable))
178+ initWindow(InitW, InitH, "Bresenham vs Poisson sprinkle")
179+ defer: closeWindow()
180+ setWindowMinSize(900, 640)
181+ setTargetFPS(60)
182+
183+ var
184+ s = State()
185+ auto = false
186+ held = 0
187+ tick = 0
188+ frames = 0
189+ shot = "--shot" in commandLineParams()
190+ shotFrame = 400
191+ startR = 12
192+ for a in commandLineParams():
193+ if a.startsWith("--r="): startR = max(parseInt(a[4 .. ^1]), MinRadius)
194+ elif a.startsWith("--frame="): shotFrame = parseInt(a[8 .. ^1])
195+ s.over = 4
196+ s.reset(min(startR, layout(getScreenWidth().int, getScreenHeight().int).maxR))
197+ if shot: auto = true
198+
199+ while not windowShouldClose():
200+ inc frames
201+
202+ let
203+ sw = getScreenWidth().int
204+ sh = getScreenHeight().int
205+ (panelW, gridW, halfW, maxR, _) = layout(sw, sh)
206+ if s.r > maxR: s.reset(maxR)
207+
208+ # ---- input
209+ if isKeyPressed(Space): s.step()
210+ if isKeyPressed(A): auto = not auto
211+ if isKeyPressed(R): s.reset(s.r)
212+ if isKeyPressed(RightBracket) and s.over < 12: s.reset(s.r, s.over + 1)
213+ if isKeyPressed(LeftBracket) and s.over > 1: s.reset(s.r, s.over - 1)
214+ var delta = 0
215+ if isKeyPressed(Up): delta = 1
216+ elif isKeyPressed(Down): delta = -1
217+ elif isKeyDown(Up) or isKeyDown(Down):
218+ inc held
219+ if held > 18:
220+ let fast = if held > 70: 1 else: 3
221+ if held mod fast == 0: delta = if isKeyDown(Up): 1 else: -1
222+ else:
223+ held = 0
224+ if delta != 0:
225+ let want = clamp(s.r + delta, MinRadius, maxR)
226+ if want != s.r: s.reset(want)
227+ if auto and not s.done:
228+ inc tick
229+ if tick mod max(1, 10 - s.r div 8) == 0: s.step()
230+
231+ # ---- both panes share a cell size so they are directly comparable
232+ let
233+ span = 2 * s.r + 3
234+ cell = max(min(halfW - 30, sh - 150) div span, MinCell)
235+ oy = sh div 2 + 10
236+ left = View(ox: halfW div 2, oy: oy, cell: cell, half: cell div 2)
237+ right = View(ox: halfW + halfW div 2, oy: oy, cell: cell, half: cell div 2)
238+
239+ drawing:
240+ clearBackground(Bg)
241+
242+ # ================= left: Bresenham =================
243+ backdrop(left, s.r)
244+ let ext = s.r + 1
245+ drawLn(left.ox, left.oy, left.ox, left.cy(ext) + left.half, Axis)
246+ drawLn(left.ox, left.oy, left.cx(ext) + left.half, left.cy(ext) + left.half, Axis)
247+
248+ for (px, py) in s.plotted:
249+ for k, (mx, my) in mirrors(px, py):
250+ drawRect(left.cx(mx), left.cy(my), cell, cell,
251+ if k == 0: Octant else: Mirror)
252+
253+ if not s.done:
254+ drawRectLines(left.cx(s.x), left.cy(s.y), cell, cell, Cursor)
255+ if s.x + 1 <= s.y:
256+ drawRect(left.cx(s.x + 1), left.cy(s.y), cell, cell, Candidate)
257+ drawRect(left.cx(s.x + 1), left.cy(s.y - 1), cell, cell, Candidate)
258+ drawCircle(int32(left.cx(s.x + 1) + left.half), int32(left.cy(s.y) + cell),
259+ float32(max(3, cell div 5)), Mid)
260+
261+ # ================= right: Poisson sprinkle =================
262+ backdrop(right, s.r)
263+ for (mx, my) in s.scells:
264+ drawRect(right.cx(mx), right.cy(my), cell, cell, Sprink)
265+
266+ # ---- titles and per-pane stats
267+ let
268+ bSym = symmetries(s.bcells)
269+ sSym = symmetries(s.scells)
270+ lx = 24
271+ rx = halfW + 24
272+ text("BRESENHAM", lx, 16, 20, Octant)
273+ text("one octant, mirrored 8 ways", lx, 40, 14, Dim)
274+ text("POISSON SPRINKLE", rx, 16, 20, Sprink)
275+ text(fmt"uniform directions, snapped ({s.over}x oversample)", rx, 40, 14, Dim)
276+
277+ let by = sh - 66
278+ text(fmt"{s.bcells.len} cells {s.plotted.len} computed, rest free", lx, by, 16, Ink)
279+ text(fmt"exact symmetries: {bSym} / 8", lx, by + 22, 16, Octant)
280+ text(fmt"{s.scells.len} cells {s.revealed} samples rolled", rx, by, 16, Ink)
281+ text(fmt"exact symmetries: {sSym} / 8", rx, by + 22, 16, Sprink)
282+
283+ drawLn(halfW, 0, halfW, sh, GridLine)
284+
285+ # ================= panel =================
286+ let px = gridW + 24
287+ drawLn(gridW, 0, gridW, sh, GridLine)
288+
289+ text("MIDPOINT CIRCLE", px, 20, 22, Ink)
290+ text(fmt"radius {s.r} (max {maxR} at {sw}x{sh})", px, 48, 15, Dim)
291+
292+ let code = [
293+ "x = 0; y = r; d = 1 - r",
294+ "while x <= y:",
295+ " plot8(x, y)",
296+ " if d < 0: # mid inside",
297+ " d += 2*x + 3",
298+ " else: # mid outside",
299+ " d += 2*(x - y) + 5",
300+ " y -= 1",
301+ " x += 1",
302+ ]
303+ let lit =
304+ case s.branch
305+ of brEast: @[2, 3, 4, 8]
306+ of brSouthEast: @[2, 5, 6, 7, 8]
307+ of brNone: @[]
308+ for i, line in code:
309+ let ly = 84 + i * 22
310+ if i in lit: drawRect(px - 8, ly - 3, panelW - 32, 22, HiLine)
311+ text(line, px, ly, 16, if i in lit: Ink else: Dim)
312+
313+ var sy = 300
314+ text("state", px, sy, 16, Dim); sy += 26
315+ text(fmt"x = {s.x}", px, sy, 20, Ink); sy += 26
316+ text(fmt"y = {s.y}", px, sy, 20, Ink); sy += 26
317+ text(fmt"d = {s.d}", px, sy, 20, (if s.d < 0: Octant else: Cursor)); sy += 32
318+ text(s.lastUpdate, px, sy, 15, Cursor); sy += 30
319+ if s.done:
320+ text("done -- x > y, octant closed", px, sy, 18, Octant)
321+ elif s.x + 1 <= s.y:
322+ text(fmt"testing midpoint ({s.x + 1}, {s.y}-1/2)", px, sy, 15, Mid)
323+
324+ var ly = sh - 170
325+ text("cost of zero symmetry", px, ly, 15, Dim); ly += 24
326+ if s.done and s.scells.len > 0:
327+ let waste = s.revealed.float / s.scells.len.float
328+ text(fmt"{waste:.1f} samples per cell drawn", px, ly, 15, Sprink); ly += 22
329+ let gap = 100.0 * (s.scells.len.float / max(1, s.bcells.len).float - 1.0)
330+ text(fmt"{gap:+.0f}% cells vs bresenham", px, ly, 15, Sprink); ly += 30
331+ else:
332+ ly += 52
333+
334+ text("SPACE step A auto R reset", px, ly, 15, (if auto: Cursor else: Dim)); ly += 20
335+ text("UP/DOWN radius (hold to scrub)", px, ly, 15, Dim); ly += 20
336+ text("[ ] sprinkle oversample", px, ly, 15, Dim)
337+
338+ if shot and frames == shotFrame:
339+ takeScreenshot("shot.png")
340+ break
341+
342+main()