nx_voronoi.nx source
↩ module page · 227 lines · 7919 B
1// nx_voronoi.nx -- 2D Voronoi diagram (procgen LAYER 3).
2//
3// Sites are scattered in [0, w) x [0, h); each cell of the canvas is
4// assigned to its NEAREST site by squared Euclidean distance. Output
5// is a per-pixel label array (which site index owns each pixel).
6//
7// USE CASES:
8// - City-block partition for procgen city generation (each block
9// becomes a Voronoi cell around its district seed)
10// - Zombie-escape agent territories (each spawn point owns the
11// region closest to it)
12// - Reaction-diffusion / cellular-automaton substrate (cell shape)
13// - Crystal-grain texture for procgen surfaces
14// - Lloyd relaxation seed for centroidal Voronoi tessellation
15//
16// ALGORITHM:
17// Brute-force nearest-site per pixel. For each pixel (x, y), scan
18// all n_sites and pick the index with minimum (dx^2 + dy^2). Cost:
19// O(w * h * n_sites). Patent-clean (squared-distance is textbook).
20//
21// SCALING NOTE:
22// Fortune's sweepline (1986) and Aurenhammer (1991) give O(n log n)
23// for the EDGES of the diagram, but per-pixel labelling for a
24// raster canvas is still O(w*h*n) without spatial acceleration.
25// For substrate use (canvas <= 256x256, sites <= 256), brute-force
26// is fast under qemu-riscv64 -- millions of i64 ops, no f64, no
27// sqrt. Upgrade path: jump-flooding-algorithm (JFA, Rong+Tan 2006)
28// for GPU-equivalent O(w*h*log(max(w,h))) when canvas grows.
29//
30// QUALITATIVE BAND (dual-reading cardinal):
31// Per-site cell-area sealed enum classifies how uniform the
32// partition is, derived from variance of per-site pixel count.
33//
34// COMPOSITION:
35// sites can be supplied by any source -- in practice
36// nx_poisson_disk_sample gives the most visually-pleasing
37// uniform-but-not-grid distribution. Demo wiring in
38// nx_voronoi_test.nx.
39//
40// genealogy_id: voronoi_1908 + dirichlet_1850 + fortune_1986 +
41// aurenhammer_1991 + rong_tan_2006_jfa
42// lineage_id: nx_voronoi_q10
43
44// nx_safety_envelope:
45// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
46// sil_target: SIL1
47// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
48// verdict: NOT_YET_EVALUATED
49
50import "nx_syscalls.nx"
51import "nx_tier.nx"
52
53// ===== Sealed-enum: cell-area uniformity bands ===================
54//
55// After labelling, we compute per-site pixel counts and classify
56// the distribution's coefficient of variation (sd / mean) into one
57// of four bands. Q10 fixed-point throughout.
58
59const NX_VORONOI_BAND_UNIFORM: nx_int = 0 // very even, CV < 0.15
60const NX_VORONOI_BAND_BALANCED: nx_int = 1 // moderate spread, CV < 0.35
61const NX_VORONOI_BAND_LOPSIDED: nx_int = 2 // some sites dominate, CV < 0.65
62const NX_VORONOI_BAND_DEGENERATE: nx_int = 3 // wildly uneven, CV >= 0.65
63const NX_VORONOI_N_BANDS: nx_int = 4
64
65const NX_VORONOI_Q: nx_int = 1024
66// CV thresholds in Q10 (0.15, 0.35, 0.65 * 1024).
67const NX_VORONOI_CV_UNIFORM_Q10: nx_int = 154
68const NX_VORONOI_CV_BALANCED_Q10: nx_int = 358
69const NX_VORONOI_CV_LOPSIDED_Q10: nx_int = 666
70
71func nx_voronoi_band_is_valid(band: nx_int) -> nx_int {
72 if band < 0 { return 0 }
73 if band >= NX_VORONOI_N_BANDS { return 0 }
74 return 1
75}
76
77// ===== Per-pixel labelling =========================================
78//
79// Writes labels[y * w + x] = nearest-site index for each pixel.
80// Pixels strictly outside (w, h) are not touched. Returns the
81// number of pixels labelled.
82
83func nx_voronoi_label(w: nx_int, h: nx_int,
84 site_xs: *i64, site_ys: *i64, n_sites: nx_int,
85 labels: *i64) -> nx_int {
86 if w <= 0 { return 0 }
87 if h <= 0 { return 0 }
88 if n_sites <= 0 { return 0 }
89
90 var y: nx_int = 0
91 while y < h {
92 var x: nx_int = 0
93 while x < w {
94 var best_idx: nx_int = 0
95 let dx0: nx_int = x - site_xs[0]
96 let dy0: nx_int = y - site_ys[0]
97 var best_d_sq: nx_int = dx0 * dx0 + dy0 * dy0
98 var i: nx_int = 1
99 while i < n_sites {
100 let dx: nx_int = x - site_xs[i]
101 let dy: nx_int = y - site_ys[i]
102 let d_sq: nx_int = dx * dx + dy * dy
103 if d_sq < best_d_sq {
104 best_d_sq = d_sq
105 best_idx = i
106 }
107 i = i + 1
108 }
109 labels[y * w + x] = best_idx
110 x = x + 1
111 }
112 y = y + 1
113 }
114 return w * h
115}
116
117// ===== Per-site pixel count =======================================
118//
119// Walk the label grid once, accumulating per-site counts. Caller
120// supplies a zero-initialised counts[n_sites] buffer.
121
122func nx_voronoi_site_counts(w: nx_int, h: nx_int,
123 labels: *i64, n_sites: nx_int,
124 counts: *i64) -> nx_int {
125 if n_sites <= 0 { return 0 }
126 var i: nx_int = 0
127 while i < n_sites {
128 counts[i] = 0
129 i = i + 1
130 }
131 var y: nx_int = 0
132 while y < h {
133 var x: nx_int = 0
134 while x < w {
135 let lbl: nx_int = labels[y * w + x]
136 if lbl >= 0 {
137 if lbl < n_sites {
138 counts[lbl] = counts[lbl] + 1
139 }
140 }
141 x = x + 1
142 }
143 y = y + 1
144 }
145 return 0
146}
147
148// ===== Integer sqrt (Newton-Raphson, monotone) =====================
149//
150// For CV computation: sqrt(variance). Newton-Raphson with initial
151// estimate via log2_floor; converges in ~6 iterations for inputs
152// up to 2^40. Returns floor(sqrt(n)) for non-negative n.
153
154func _vor_isqrt(n: nx_int) -> nx_int {
155 if n <= 0 { return 0 }
156 if n < 2 { return 1 }
157 // Initial estimate: half the bit-width.
158 var x: nx_int = n
159 var shift: nx_int = 0
160 while x > 0 {
161 x = x / 2
162 shift = shift + 1
163 }
164 var r: nx_int = 1
165 var i: nx_int = 0
166 while i < (shift / 2 + 1) {
167 r = r * 2
168 i = i + 1
169 }
170 // Newton iterations: r_next = (r + n/r) / 2
171 var iter: nx_int = 0
172 while iter < 20 {
173 if r <= 0 { return 0 }
174 let next: nx_int = (r + n / r) / 2
175 if next >= r { return r }
176 r = next
177 iter = iter + 1
178 }
179 return r
180}
181
182// ===== Coefficient-of-variation classifier ========================
183//
184// Given counts[n_sites], compute mean, variance, sd, and CV (in Q10);
185// map CV into one of the four sealed bands.
186
187func nx_voronoi_classify(counts: *i64, n_sites: nx_int) -> nx_int {
188 if n_sites <= 0 { return NX_VORONOI_BAND_DEGENERATE }
189 var total: nx_int = 0
190 var i: nx_int = 0
191 while i < n_sites {
192 total = total + counts[i]
193 i = i + 1
194 }
195 if total <= 0 { return NX_VORONOI_BAND_DEGENERATE }
196 let mean: nx_int = total / n_sites
197 if mean <= 0 { return NX_VORONOI_BAND_DEGENERATE }
198 // Sum of squared deviations.
199 var sum_sq_dev: nx_int = 0
200 var j: nx_int = 0
201 while j < n_sites {
202 let dev: nx_int = counts[j] - mean
203 sum_sq_dev = sum_sq_dev + dev * dev
204 j = j + 1
205 }
206 let variance: nx_int = sum_sq_dev / n_sites
207 let sd: nx_int = _vor_isqrt(variance)
208 // CV in Q10 = (sd * Q) / mean.
209 let cv_q10: nx_int = (sd * NX_VORONOI_Q) / mean
210 if cv_q10 < NX_VORONOI_CV_UNIFORM_Q10 { return NX_VORONOI_BAND_UNIFORM }
211 if cv_q10 < NX_VORONOI_CV_BALANCED_Q10 { return NX_VORONOI_BAND_BALANCED }
212 if cv_q10 < NX_VORONOI_CV_LOPSIDED_Q10 { return NX_VORONOI_BAND_LOPSIDED }
213 return NX_VORONOI_BAND_DEGENERATE
214}
215
216// ===== Accessor: label lookup =====================================
217//
218// Bounds-checked read; returns -1 if (x, y) is out of canvas.
219
220func nx_voronoi_label_at(labels: *i64, w: nx_int, h: nx_int,
221 x: nx_int, y: nx_int) -> nx_int {
222 if x < 0 { return 0 - 1 }
223 if x >= w { return 0 - 1 }
224 if y < 0 { return 0 - 1 }
225 if y >= h { return 0 - 1 }
226 return labels[y * w + x]
227}