roxlap_core/meltsphere.rs
1//! Sphere-region voxel extraction (`meltsphere`) and its support
2//! tables.
3//!
4//! Port of voxlap's `meltsphere` (voxlap5.c:10222-10344). Walks an
5//! AABB-bounded sphere of voxels in the world and packages the
6//! visible (border) voxels into a fresh `kv6` sprite.
7//!
8//! Supporting helpers:
9//! - `lightvox` — alpha-byte face shader (voxlap5.c:623-632).
10//! - [`PowerTables`] + [`PowerTables::build_tempfloatbuf`] — the
11//! factr / logint / tempfloatbuf machinery (voxlap5.c:118-120
12//! statics, :12224-12236 init, :10240-10252 per-call build).
13
14// Entire module is a port of voxlap C bit-twiddle; the casts
15// implicit in the source map cleanly to Rust's `as` casts.
16#![allow(
17 clippy::cast_possible_truncation,
18 clippy::cast_possible_wrap,
19 clippy::cast_sign_loss,
20 clippy::cast_precision_loss
21)]
22
23use roxlap_formats::kv6::{Kv6, Voxel};
24
25use crate::world_query::{getcube, Cube};
26
27/// Voxlap's `SETSPHMAXRAD` (voxlap5.c:117). Upper bound on
28/// `hitrad`; the fast-pow tables are sized to this.
29pub(crate) const SETSPHMAXRAD: usize = 256;
30
31/// Voxlap's `MAXZDIM` (voxlap5.h:10) — voxlap stores z as a single
32/// byte, so the world is at most 256 voxels tall.
33pub(crate) const MAXZDIM: i32 = 256;
34
35/// Apply alpha-byte face shading to a packed voxlap colour.
36///
37/// Port of `lightvox` (voxlap5.c:623-632). The high byte of `i` is
38/// treated as a brightness multiplier (`0x80` is neutral); each RGB
39/// channel is multiplied by it, shifted right by 7, and clamped to
40/// 255. The returned colour has its alpha byte cleared.
41///
42/// Voxlap uses this to bake alpha-byte intensity into the per-voxel
43/// colour stored in a kv6 sprite: meltsphere copies the world's
44/// `BR(rgb)`-style packed colour through `lightvox` so the resulting
45/// `Voxel::col` is plain `0x00rrggbb`.
46#[must_use]
47pub(crate) fn lightvox(i: u32) -> u32 {
48 let b = i >> 24;
49 let r = ((((i >> 16) & 0xff) * b) >> 7).min(255);
50 let g = ((((i >> 8) & 0xff) * b) >> 7).min(255);
51 let bl = (((i & 0xff) * b) >> 7).min(255);
52 (r << 16) | (g << 8) | bl
53}
54
55/// Tables that voxlap precomputes once in `initvoxlap` and reuses
56/// across every `meltsphere` / `setsphere` call.
57///
58/// Both tables are indexed by an integer `z ∈ [0, SETSPHMAXRAD)`:
59///
60/// - `factr[z]` is voxlap's prime-decomposition cache. If `z` is
61/// prime, `factr[z][0] == 0`. If `z` is composite,
62/// `factr[z][0] * factr[z][1] == z` and `factr[z][0]` is the
63/// smallest prime divisor of `z`. `factr[2][0]` is forced to 0.
64/// - `logint[z] = ln(z)` (natural log) for `z >= 1`; `logint[0]`
65/// is unused.
66///
67/// Used by [`PowerTables::build_tempfloatbuf`] to compute
68/// `i^curpow` cheaply: prime indices go through `exp(ln(i) *
69/// curpow)`, composite indices fold into a multiplication of two
70/// already-computed entries (avoiding `pow` per index).
71#[derive(Debug, Clone)]
72pub struct PowerTables {
73 pub factr: [[u32; 2]; SETSPHMAXRAD],
74 pub logint: [f64; SETSPHMAXRAD],
75}
76
77impl Default for PowerTables {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl PowerTables {
84 /// Mirror of voxlap's `initvoxlap` factr-/logint-init block
85 /// (voxlap5.c:12224-12236). Sieves the prime decomposition and
86 /// fills `logint[i] = ln(i)`.
87 #[must_use]
88 pub fn new() -> Self {
89 let mut factr = [[0u32; 2]; SETSPHMAXRAD];
90
91 // Voxlap's hand-written prime sieve. `i` tracks the largest
92 // prime ≤ √z; `j` is the next perfect square at which `i`
93 // increments by 2. `k` is the previous prime — `factr[k][1]`
94 // is rewritten on every iteration so that, by the time the
95 // loop has crossed the next prime, `factr[k][1]` holds that
96 // next prime (used to walk the prime list during composite
97 // testing).
98 factr[2][0] = 0;
99 let mut i: u32 = 1;
100 let mut j: u32 = 9;
101 let mut k: usize = 0;
102 let mut z: u32 = 3;
103 while (z as usize) < SETSPHMAXRAD {
104 if z == j {
105 j += (i << 2) + 12;
106 i += 2;
107 }
108 factr[z as usize][0] = 0;
109 factr[k][1] = z;
110 // Walk primes ≤ i looking for a divisor of z.
111 let mut zz: u32 = 3;
112 while zz <= i {
113 if z % zz == 0 {
114 factr[z as usize][0] = zz;
115 factr[z as usize][1] = z / zz;
116 break;
117 }
118 zz = factr[zz as usize][1];
119 }
120 if factr[z as usize][0] == 0 {
121 k = z as usize;
122 }
123 // Even number z + 1 is always 2 × ((z+1)/2).
124 if (z as usize) + 1 < SETSPHMAXRAD {
125 factr[(z as usize) + 1][0] = (z + 1) >> 1;
126 factr[(z as usize) + 1][1] = 2;
127 }
128 z += 2;
129 }
130
131 let mut logint = [0.0f64; SETSPHMAXRAD];
132 // logint[0] stays 0.0 (unused; voxlap leaves it uninitialised
133 // but the meltsphere loop starts at i=2 after special-casing
134 // tempfloatbuf[1]=1.0).
135 for (zz, slot) in logint.iter_mut().enumerate().skip(1) {
136 *slot = f64::ln(zz as f64);
137 }
138
139 Self { factr, logint }
140 }
141
142 /// Build a per-call `tempfloatbuf` such that
143 /// `tempfloatbuf[i] ≈ i.powf(curpow)` for `i ∈ [0, hitrad]`.
144 /// `hitrad + 1` is filled with the IEEE-754 max-finite-float
145 /// sentinel (`0x7f7fffff`) so the int-bit-pattern comparisons
146 /// in meltsphere terminate cleanly at the table edge.
147 ///
148 /// Port of voxlap5.c:10240-10252. `hitrad` is clamped to
149 /// `SETSPHMAXRAD - 2`.
150 #[must_use]
151 pub fn build_tempfloatbuf(&self, hitrad: i32, curpow: f32) -> [f32; SETSPHMAXRAD] {
152 let mut buf = [0.0f32; SETSPHMAXRAD];
153 let hitrad_clamped = (hitrad.max(0) as usize).min(SETSPHMAXRAD - 2);
154
155 buf[0] = 0.0;
156 if hitrad_clamped >= 1 {
157 buf[1] = 1.0;
158 }
159 // Voxlap mixes f64 / f32 here: logint[i] is f64, curpow is
160 // f32 promoted to f64 for the multiply, exp is f64,
161 // assignment to tempfloatbuf truncates to f32.
162 let curpow_d = f64::from(curpow);
163 for i in 2..=hitrad_clamped {
164 if self.factr[i][0] == 0 {
165 // Prime: tempfloatbuf[i] = exp(log(i) * curpow).
166 buf[i] = (self.logint[i] * curpow_d).exp() as f32;
167 } else {
168 // Composite: factor[a] * factor[b] where a*b == i.
169 let a = self.factr[i][0] as usize;
170 let b = self.factr[i][1] as usize;
171 buf[i] = buf[a] * buf[b];
172 }
173 }
174 // Sentinel: 0x7f7fffff (= f32::MAX bit pattern) lives at
175 // hitrad + 1 so the binary-search loops in meltsphere
176 // hit a "guaranteed > anything finite" boundary.
177 buf[hitrad_clamped + 1] = f32::from_bits(0x7f7f_ffff);
178 buf
179 }
180}
181
182/// Output of [`meltsphere`] — a freshly-built kv6 sprite plus the
183/// computed sprite centroid and total solid-voxel count.
184#[derive(Debug, Clone)]
185pub struct MeltsphereOutput {
186 /// Freshly-built kv6 with one record per visible (color) voxel
187 /// inside the sphere. `xpiv`/`ypiv`/`zpiv` are set to the
188 /// centroid offset within the kv6's local AABB. `vis = 63`
189 /// (all 6 faces) and `dir = 0` for every voxel — voxlap's
190 /// meltsphere comment flags both as "FIX THIS!!!" so we mirror.
191 pub kv6: Kv6,
192 /// Centroid in world coordinates. The caller typically
193 /// overwrites this with a placement position; voxlap stores it
194 /// in `vx5sprite.p` then expects callers to relocate.
195 pub p: [f32; 3],
196 /// Centroid weight = total solid voxels in the sphere region
197 /// (both `Color` and `UnexposedSolid`). Voxlap's return value.
198 pub cw: u32,
199}
200
201/// Region-grow a sphere of voxels out of the world into a kv6
202/// sprite.
203///
204/// Port of voxlap's `meltsphere` (voxlap5.c:10222-10344). Two-pass:
205/// pass 1 counts voxels and tallies the centroid, pass 2 emits
206/// voxel records, `xlen` and `ylen` slice counts.
207///
208/// Returns `None` if the sphere AABB is empty (the hit point is
209/// off-map), if the sphere is fully clipped against the world
210/// bounds, or if no visible (color) voxels are found.
211///
212/// # Faithful-port notes
213///
214/// - `(i==1) && (1)` in voxlap is a placeholder (commented "FIX
215/// THIS!!!") that always evaluates to true — meaning unexposed
216/// solid voxels are *not* emitted into the kv6 but *do* count
217/// toward the centroid via `cw`. We mirror.
218/// - Voxlap's loop uses `for(z=z0; z<z1; z++)` with `z1 =
219/// min(hit->z+sq+1, ze)`. Because `ze` is voxlap's *inclusive*
220/// max bound, the loop excludes `z = ze` and the topmost voxel
221/// that the sphere should touch on the +z axis. We mirror this
222/// off-by-one — diverging would change the sphere's voxel set
223/// and break byte-equality against the C oracle's sprite.
224/// - `vis = 63` (all faces visible) and `dir = 0` (normal index 0)
225/// per voxel, matching voxlap's stub fields.
226/// - Centroid math uses voxlap's `f = 1.0 / (float)cw; p = base +
227/// (float)cx * f` exactly (1.0 promoted to double for the divide,
228/// truncated to f32, then float-multiplied by the f32-cast
229/// centroid sum).
230#[allow(clippy::too_many_lines, clippy::similar_names)]
231#[must_use]
232pub fn meltsphere(
233 slab_buf: &[u8],
234 column_offsets: &[u32],
235 vsid: u32,
236 hit: [i32; 3],
237 hitrad: i32,
238 curpow: f32,
239 tables: &PowerTables,
240) -> Option<MeltsphereOutput> {
241 let vsid_i = vsid as i32;
242
243 // AABB clamp (voxlap5.c:10233-10236).
244 let xs = (hit[0] - hitrad).max(0);
245 let xe = (hit[0] + hitrad).min(vsid_i - 1);
246 let ys = (hit[1] - hitrad).max(0);
247 let ye = (hit[1] + hitrad).min(vsid_i - 1);
248 let zs = (hit[2] - hitrad).max(0);
249 let ze = (hit[2] + hitrad).min(MAXZDIM - 1);
250 if xs > xe || ys > ye || zs > ze {
251 return None;
252 }
253
254 // Clamp hitrad to fit the power-table span (voxlap5.c:10238).
255 let hitrad_clamped = if hitrad >= (SETSPHMAXRAD as i32) - 1 {
256 (SETSPHMAXRAD as i32) - 2
257 } else {
258 hitrad
259 };
260 let buf = tables.build_tempfloatbuf(hitrad_clamped, curpow);
261 let h = hitrad_clamped as usize;
262
263 // -- Pass 1 (voxlap5.c:10254-10283) — count voxels + centroid.
264 // Voxlap uses int32_t for the centroid sums with implicit
265 // overflow. Wrapping_add matches that without UB on the rare
266 // huge-radius path.
267 let mut cx: i32 = 0;
268 let mut cy: i32 = 0;
269 let mut cz: i32 = 0;
270 let mut cw: i32 = 0;
271 let mut numvoxs: u32 = 0;
272 let mut sq: usize = 0;
273
274 for x in xs..=xe {
275 let dx = (x - hit[0]).unsigned_abs() as usize;
276 let ff = buf[h] - buf[dx];
277 for y in ys..=ye {
278 let dy = (y - hit[1]).unsigned_abs() as usize;
279 let f = ff - buf[dy];
280 // *(int32_t *)&f > 0 with positive-finite buf entries
281 // matches `f > 0.0f` exactly; any negative result of the
282 // subtractions above flips the sign bit and tests false.
283 if f > 0.0 {
284 while buf[sq] < f {
285 sq += 1;
286 }
287 while sq > 0 && buf[sq] >= f {
288 sq -= 1;
289 }
290 let sq_i = sq as i32;
291 let z0 = (hit[2] - sq_i).max(zs);
292 // Voxlap's `z1 = min(hit->z+sq+1, ze)`; the loop
293 // `z<z1` excludes `z=ze` even though ze is inclusive.
294 // Faithful port — see function-level note above.
295 let z1 = (hit[2] + sq_i + 1).min(ze);
296 for z in z0..z1 {
297 match getcube(slab_buf, column_offsets, vsid, x, y, z) {
298 Cube::Air => {}
299 Cube::UnexposedSolid => {
300 cx = cx.wrapping_add(x.wrapping_sub(hit[0]));
301 cy = cy.wrapping_add(y.wrapping_sub(hit[1]));
302 cz = cz.wrapping_add(z.wrapping_sub(hit[2]));
303 cw = cw.wrapping_add(1);
304 // Don't emit (i==1 path).
305 }
306 Cube::Color(_) => {
307 cx = cx.wrapping_add(x.wrapping_sub(hit[0]));
308 cy = cy.wrapping_add(y.wrapping_sub(hit[1]));
309 cz = cz.wrapping_add(z.wrapping_sub(hit[2]));
310 cw = cw.wrapping_add(1);
311 numvoxs = numvoxs.wrapping_add(1);
312 }
313 }
314 }
315 }
316 }
317 }
318
319 if numvoxs == 0 {
320 return None;
321 }
322
323 // Centroid (voxlap5.c:10286-10289). Match voxlap's mixed
324 // double/float math: 1.0 (double) / cw (float-promoted-to-double)
325 // → double, cast to f32; then per-axis: f32 base + f32 sum * f32
326 // reciprocal.
327 let f_inv = (1.0f64 / f64::from(cw)) as f32;
328 let p = [
329 hit[0] as f32 + cx as f32 * f_inv,
330 hit[1] as f32 + cy as f32 * f_inv,
331 hit[2] as f32 + cz as f32 * f_inv,
332 ];
333
334 let xsiz = (xe - xs + 1) as u32;
335 let ysiz = (ye - ys + 1) as u32;
336 let zsiz = (ze - zs + 1) as u32;
337 let xpiv = p[0] - xs as f32;
338 let ypiv = p[1] - ys as f32;
339 let zpiv = p[2] - zs as f32;
340
341 // -- Pass 2 (voxlap5.c:10313-10343) — emit voxel records,
342 // xlen, ylen. Same iteration as pass 1 (sq reset to 0 to match).
343 let mut voxels: Vec<Voxel> = Vec::with_capacity(numvoxs as usize);
344 let mut xlen: Vec<u32> = Vec::with_capacity(xsiz as usize);
345 let mut ylen_flat: Vec<u16> = Vec::with_capacity((xsiz as usize) * (ysiz as usize));
346
347 let mut o_x_voxs: u32 = 0;
348 let mut o_y_voxs: u32 = 0;
349 let mut sq: usize = 0;
350
351 for x in xs..=xe {
352 let dx = (x - hit[0]).unsigned_abs() as usize;
353 let ff = buf[h] - buf[dx];
354 for y in ys..=ye {
355 let dy = (y - hit[1]).unsigned_abs() as usize;
356 let f = ff - buf[dy];
357 if f > 0.0 {
358 while buf[sq] < f {
359 sq += 1;
360 }
361 while sq > 0 && buf[sq] >= f {
362 sq -= 1;
363 }
364 let sq_i = sq as i32;
365 let z0 = (hit[2] - sq_i).max(zs);
366 let z1 = (hit[2] + sq_i + 1).min(ze);
367 for z in z0..z1 {
368 if let Cube::Color(c) = getcube(slab_buf, column_offsets, vsid, x, y, z) {
369 voxels.push(Voxel {
370 col: lightvox(c),
371 z: (z - zs) as u16,
372 vis: 63,
373 dir: 0,
374 });
375 }
376 }
377 }
378 let cur = voxels.len() as u32;
379 ylen_flat.push((cur - o_y_voxs) as u16);
380 o_y_voxs = cur;
381 }
382 let cur = voxels.len() as u32;
383 xlen.push(cur - o_x_voxs);
384 o_x_voxs = cur;
385 }
386
387 // Convert flat ylen into the nested Vec<Vec<u16>> shape that
388 // roxlap-formats::Kv6 expects (outer length xsiz, inner ysiz).
389 let ylen: Vec<Vec<u16>> = ylen_flat
390 .chunks_exact(ysiz as usize)
391 .map(<[u16]>::to_vec)
392 .collect();
393
394 let kv6 = Kv6 {
395 xsiz,
396 ysiz,
397 zsiz,
398 xpiv,
399 ypiv,
400 zpiv,
401 voxels,
402 xlen,
403 ylen,
404 palette: None,
405 };
406
407 Some(MeltsphereOutput {
408 kv6,
409 p,
410 cw: cw as u32,
411 })
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 // --- lightvox -------------------------------------------------------
419
420 #[test]
421 fn lightvox_neutral_brightness_passes_rgb_through() {
422 // Alpha 0x80 = 128, multiplier (x * 128) >> 7 = x.
423 assert_eq!(lightvox(0x80ff_4030), 0x00ff_4030);
424 assert_eq!(lightvox(0x80ff_ffff), 0x00ff_ffff);
425 assert_eq!(lightvox(0x8000_0000), 0x0000_0000);
426 }
427
428 #[test]
429 fn lightvox_zero_alpha_blackens() {
430 assert_eq!(lightvox(0x00ff_ffff), 0);
431 assert_eq!(lightvox(0x0080_4020), 0);
432 }
433
434 #[test]
435 fn lightvox_clamps_at_255() {
436 // Alpha 0xff = 255; multiplier (0xff * 255) >> 7 = 510 → clamp 255.
437 assert_eq!(lightvox(0xffff_ffff), 0x00ff_ffff);
438 // Alpha 0xc0 = 192; (0x80 * 192) >> 7 = 0xc0 = 192. Not clamped.
439 assert_eq!(lightvox(0xc080_8080), 0x00c0_c0c0);
440 // Alpha 0xc0 with 0xff channel: (0xff * 192) >> 7 = 382 → clamp 255.
441 assert_eq!(lightvox(0xc0ff_4030), 0x00ff_6048);
442 }
443
444 #[test]
445 fn lightvox_half_brightness() {
446 // Alpha 0x40 = 64; (x * 64) >> 7 = x / 2.
447 assert_eq!(lightvox(0x4080_8080), 0x0040_4040);
448 }
449
450 // --- factr / prime sieve --------------------------------------------
451
452 #[test]
453 fn factr_known_primes_have_zero_factor() {
454 let pt = PowerTables::new();
455 // factr[2][0] is force-zero in init.
456 for &p in &[
457 2u32, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 251,
458 ] {
459 assert_eq!(
460 pt.factr[p as usize][0], 0,
461 "prime {p} should have factr[{p}][0] == 0"
462 );
463 }
464 }
465
466 #[test]
467 fn factr_composites_decompose_to_their_factors() {
468 let pt = PowerTables::new();
469 // Spot-check: each is `(z, expected_a, expected_b)` with a*b == z.
470 let cases = [
471 (4u32, 2u32, 2u32),
472 (6, 3, 2),
473 (8, 4, 2),
474 (9, 3, 3),
475 (10, 5, 2),
476 (12, 6, 2),
477 (15, 3, 5),
478 (21, 3, 7),
479 (25, 5, 5),
480 (27, 3, 9),
481 (35, 5, 7),
482 (49, 7, 7),
483 (121, 11, 11),
484 (169, 13, 13),
485 // 255 = 3 × 5 × 17; smallest-prime-first → 3 × 85.
486 (255, 3, 85),
487 ];
488 for (z, a, b) in cases {
489 assert_eq!(
490 pt.factr[z as usize],
491 [a, b],
492 "factr[{z}] should be [{a}, {b}]"
493 );
494 }
495 }
496
497 #[test]
498 fn factr_invariant_holds_for_all_composites() {
499 // Voxlap's invariant: if factr[z][0] != 0 then a*b == z.
500 let pt = PowerTables::new();
501 for z in 2..SETSPHMAXRAD as u32 {
502 let a = pt.factr[z as usize][0];
503 if a != 0 {
504 let b = pt.factr[z as usize][1];
505 assert_eq!(a * b, z, "factr[{z}] = [{a}, {b}], product {}", a * b);
506 }
507 }
508 }
509
510 // --- logint ----------------------------------------------------------
511
512 #[test]
513 fn logint_matches_natural_log() {
514 let pt = PowerTables::new();
515 // Compare bit patterns: these are exact-equal floats produced
516 // by the same `ln` call, so any drift is a real bug.
517 assert_eq!(pt.logint[1].to_bits(), 0.0f64.to_bits());
518 assert_eq!(pt.logint[10].to_bits(), (10.0f64).ln().to_bits());
519 assert_eq!(pt.logint[100].to_bits(), (100.0f64).ln().to_bits());
520 assert_eq!(pt.logint[255].to_bits(), (255.0f64).ln().to_bits());
521 }
522
523 // --- tempfloatbuf ---------------------------------------------------
524
525 #[test]
526 fn tempfloatbuf_curpow_two_approximates_squares() {
527 let pt = PowerTables::new();
528 let buf = pt.build_tempfloatbuf(64, 2.0);
529 // tempfloatbuf[i] should be very close to i² for curpow=2.
530 // Not exactly equal because the prime path goes through
531 // exp(ln*2), which carries ULP rounding. Tolerance: 1 ULP
532 // of i² in f32, or just relative 1e-6.
533 for i in 0..=64u32 {
534 let want = (i * i) as f32;
535 let got = buf[i as usize];
536 let rel = ((got - want) / want.max(1.0)).abs();
537 assert!(rel < 1e-5, "tempfloatbuf[{i}] = {got}, want {want}");
538 }
539 }
540
541 #[test]
542 fn tempfloatbuf_zero_and_one_are_exact() {
543 let pt = PowerTables::new();
544 let buf = pt.build_tempfloatbuf(8, 2.0);
545 // Voxlap hard-codes tempfloatbuf[0]=0, tempfloatbuf[1]=1
546 // before the prime-walk loop.
547 assert_eq!(buf[0].to_bits(), 0.0f32.to_bits());
548 assert_eq!(buf[1].to_bits(), 1.0f32.to_bits());
549 }
550
551 #[test]
552 fn tempfloatbuf_sentinel_is_max_finite_float() {
553 let pt = PowerTables::new();
554 let buf = pt.build_tempfloatbuf(8, 2.0);
555 assert_eq!(buf[9].to_bits(), 0x7f7f_ffff);
556 }
557
558 #[test]
559 fn tempfloatbuf_clamps_huge_hitrad() {
560 let pt = PowerTables::new();
561 // hitrad past SETSPHMAXRAD-2 should clamp; the function must
562 // not panic and the sentinel should sit at index 255.
563 let buf = pt.build_tempfloatbuf(10_000, 2.0);
564 assert_eq!(buf[SETSPHMAXRAD - 1].to_bits(), 0x7f7f_ffff);
565 }
566
567 #[test]
568 fn tempfloatbuf_curpow_three_matches_cubes() {
569 let pt = PowerTables::new();
570 let buf = pt.build_tempfloatbuf(20, 3.0);
571 for i in 0..=20u32 {
572 let want = (i as f32).powi(3);
573 let got = buf[i as usize];
574 let rel = ((got - want) / want.max(1.0)).abs();
575 assert!(rel < 1e-4, "tempfloatbuf[{i}] = {got}, want {want}");
576 }
577 }
578
579 // --- meltsphere -----------------------------------------------------
580
581 /// Build a synthetic 16×16 world. Every column is the minimal
582 /// "deep solid only" form (one floor voxel at z=255). Then any
583 /// `(x, y, z)` overrides in `paints` add a single visible floor
584 /// voxel at that z with the given color via a two-slab column.
585 fn synth_world(paints: &[(i32, i32, i32, u32)]) -> (Vec<u8>, Vec<u32>) {
586 const VSID: u32 = 16;
587 // Empty column: single slab [0, 255, 255, 0] + 1 floor color
588 // = 8 bytes per empty column.
589 let empty_col: [u8; 8] = [0, 255, 255, 0, 0xff, 0xff, 0xff, 0x80];
590 let n_cols = (VSID * VSID) as usize;
591 let mut data: Vec<u8> = Vec::new();
592 let mut offsets: Vec<u32> = Vec::with_capacity(n_cols + 1);
593
594 // Resolve paints to per-column override.
595 let mut overrides: std::collections::HashMap<usize, (i32, u32)> =
596 std::collections::HashMap::new();
597 for &(x, y, z, c) in paints {
598 let idx = y as usize * VSID as usize + x as usize;
599 overrides.insert(idx, (z, c));
600 }
601
602 for i in 0..n_cols {
603 offsets.push(u32::try_from(data.len()).unwrap());
604 if let Some(&(z, c)) = overrides.get(&i) {
605 // Two slabs:
606 // slab 0 [nextptr=2, z1=z, z1c=z, dummy=0] + 1 floor (4 bytes)
607 // slab 1 [nextptr=0, z1=255, z1c=255, z0=z+1] + 1 floor (4 bytes)
608 let z_u8 = z as u8;
609 data.extend_from_slice(&[2, z_u8, z_u8, 0]);
610 data.extend_from_slice(&c.to_le_bytes());
611 data.extend_from_slice(&[0, 255, 255, z_u8 + 1]);
612 data.extend_from_slice(&[0xff, 0xff, 0xff, 0x80]);
613 } else {
614 data.extend_from_slice(&empty_col);
615 }
616 }
617 offsets.push(u32::try_from(data.len()).unwrap());
618 (data, offsets)
619 }
620
621 #[test]
622 fn meltsphere_empty_region_returns_none() {
623 // No paints: the entire 16×16 world is one-voxel-deep solid
624 // at z=255. Sphere at (8, 8, 4) with radius 2 finds no color
625 // voxels in the 5×5×5 AABB (z=2..=6) — only unexposed solid
626 // and air. So numvoxs == 0 → None.
627 let (buf, off) = synth_world(&[]);
628 let pt = PowerTables::new();
629 let r = meltsphere(&buf, &off, 16, [8, 8, 4], 2, 2.0, &pt);
630 assert!(r.is_none(), "expected None, got {r:?}");
631 }
632
633 #[test]
634 fn meltsphere_single_voxel_at_center_extracts_one() {
635 let (buf, off) = synth_world(&[(8, 8, 4, 0x8011_2233)]);
636 let pt = PowerTables::new();
637 let out = meltsphere(&buf, &off, 16, [8, 8, 4], 2, 2.0, &pt)
638 .expect("sphere should hit the painted voxel");
639 // Exactly one color voxel emitted.
640 assert_eq!(out.kv6.voxels.len(), 1);
641 let v = out.kv6.voxels[0];
642 // lightvox(0x8011_2233): alpha=0x80=128, channels pass through.
643 assert_eq!(v.col, 0x0011_2233);
644 assert_eq!(v.vis, 63);
645 assert_eq!(v.dir, 0);
646 // Sphere AABB: x ∈ [6,10], y ∈ [6,10], z ∈ [2,6]. zs=2 so
647 // the painted voxel at z=4 lands at local z = 4-2 = 2.
648 assert_eq!(v.z, 2);
649 // kv6 dimensions = AABB clamp to map.
650 assert_eq!(out.kv6.xsiz, 5);
651 assert_eq!(out.kv6.ysiz, 5);
652 assert_eq!(out.kv6.zsiz, 5);
653 // numvoxs is the sum of xlen.
654 let xsum: u32 = out.kv6.xlen.iter().sum();
655 assert_eq!(xsum, 1);
656 // ylen rows match xsiz.
657 assert_eq!(out.kv6.ylen.len(), 5);
658 for row in &out.kv6.ylen {
659 assert_eq!(row.len(), 5);
660 }
661 }
662
663 #[test]
664 fn meltsphere_returns_none_when_aabb_off_map() {
665 // hit way outside the 16×16 world → AABB clamps to empty.
666 let (buf, off) = synth_world(&[]);
667 let pt = PowerTables::new();
668 // hit at x = -100: xs = max(-102, 0) = 0; xe = min(-98, 15)
669 // = -98. xs > xe → None.
670 let r = meltsphere(&buf, &off, 16, [-100, 8, 4], 2, 2.0, &pt);
671 assert!(r.is_none());
672 }
673
674 #[test]
675 fn meltsphere_centroid_is_on_painted_voxel() {
676 // Single voxel at hit location → centroid == hit location.
677 let (buf, off) = synth_world(&[(8, 8, 4, 0x8011_2233)]);
678 let pt = PowerTables::new();
679 let out = meltsphere(&buf, &off, 16, [8, 8, 4], 2, 2.0, &pt).unwrap();
680 // cx = cy = cz = 0 (the only voxel is at offset (0, 0, 0)
681 // from hit). centroid = hit + 0/cw = hit.
682 assert_eq!(out.p[0].to_bits(), 8.0f32.to_bits());
683 assert_eq!(out.p[1].to_bits(), 8.0f32.to_bits());
684 assert_eq!(out.p[2].to_bits(), 4.0f32.to_bits());
685 assert_eq!(out.cw, 1);
686 }
687
688 #[test]
689 fn meltsphere_skips_unexposed_solid_but_counts_for_centroid() {
690 // Place two color voxels at (8,8,4) and (9,8,4) and let the
691 // empty columns' z=255 deep-solid voxels NOT overlap the
692 // sphere (sphere stays well above z=10). Then cw should
693 // equal numvoxs since no UnexposedSolid voxels are in range.
694 // (This tests centroid math is consistent with cw=numvoxs.)
695 let (buf, off) = synth_world(&[(8, 8, 4, 0x8000_ff00), (9, 8, 4, 0x8000_00ff)]);
696 let pt = PowerTables::new();
697 let out = meltsphere(&buf, &off, 16, [8, 8, 4], 2, 2.0, &pt).unwrap();
698 assert_eq!(out.kv6.voxels.len(), 2);
699 assert_eq!(out.cw, 2);
700 // cx = (8-8) + (9-8) = 1; cy = 0; cz = 0.
701 // p.x = 8 + 1 * (1/2) = 8.5
702 let expected_px = 8.0f32 + 1.0 * (1.0 / 2.0f64) as f32;
703 assert_eq!(out.p[0].to_bits(), expected_px.to_bits());
704 }
705
706 #[test]
707 fn meltsphere_xlen_ylen_partition_voxels() {
708 // Three painted voxels on different (x, y) columns. Verify
709 // xlen sums to numvoxs and ylen rows sum to xlen.
710 let paints = [
711 (8, 8, 4, 0x8011_2233),
712 (9, 8, 4, 0x8044_5566),
713 (8, 9, 4, 0x8077_8899),
714 ];
715 let (buf, off) = synth_world(&paints);
716 let pt = PowerTables::new();
717 let out = meltsphere(&buf, &off, 16, [8, 8, 4], 2, 2.0, &pt).unwrap();
718 assert_eq!(out.kv6.voxels.len(), 3);
719 let xsum: u32 = out.kv6.xlen.iter().sum();
720 assert_eq!(xsum, 3);
721 for (x_i, ylen_row) in out.kv6.ylen.iter().enumerate() {
722 let row_sum: u32 = ylen_row.iter().map(|&v| u32::from(v)).sum();
723 assert_eq!(
724 row_sum, out.kv6.xlen[x_i],
725 "ylen row {x_i} sum {row_sum} != xlen[{x_i}] {}",
726 out.kv6.xlen[x_i]
727 );
728 }
729 }
730}