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>
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 | + | |
| 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 | + | ||
| 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 differ | Binary 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() | ||