Skip to main content

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}