| Bresenham midpoint circle visualizer, with a zero-symmetry counterpart 3d0b0a3 nandi 17h ago | 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() |