Skip to main content

voxj_codec/
encode_voxj_object.rs

1use crate::{
2    Error, PositionEncoding, Result, SampleEncoding, encode_hilbert, encode_varint, hilbert_bits,
3    pack_bits, packed_width,
4};
5use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
6use voxj::{VoxjCodecObject, VoxjSerdeObject, VoxjSerdePositionBlock, VoxjSerdeSampleBlock};
7
8/// Encodes one [`VoxjCodecObject`]'s geometry into a [`VoxjSerdeObject`] with
9/// the given fixed position and sample encodings. `cell_counts[p]` is the cell
10/// count of the palette referenced by `object.palette_refs[p]`, used to derive
11/// the bit width of `packed-base64` samples;
12/// [`voxj_palette_cell_counts`](crate::voxj_palette_cell_counts) computes it
13/// from the document's palettes.
14pub fn encode_voxj_object(
15    object: &VoxjCodecObject,
16    cell_counts: &[usize],
17    position: PositionEncoding,
18    sample: SampleEncoding,
19) -> Result<VoxjSerdeObject> {
20    validate_object_shape(object)?;
21    let num_palettes = object.palette_refs.len();
22
23    let (voxel_positions, voxel_samples) = if object.positions.is_empty() {
24        (
25            VoxjSerdePositionBlock::RawJson(Vec::new()),
26            VoxjSerdeSampleBlock::RawJson(Vec::new()),
27        )
28    } else {
29        let (order, position_block) = encode_positions(object, position);
30        let channels = channels_in_order(&object.samples, &order, num_palettes);
31        let sample_block = encode_samples(&channels, sample, cell_counts, order.len());
32        (position_block, sample_block)
33    };
34
35    Ok(VoxjSerdeObject {
36        name: object.name.clone(),
37        palette_refs: object.palette_refs.clone(),
38        bounds: object.bounds,
39        voxel_positions,
40        voxel_samples,
41    })
42}
43
44/// Validates that `object`'s samples are rectangular: one row per voxel, each
45/// holding one cell index per referenced palette. The encoders index
46/// `samples[voxel][palette]` directly, so a ragged object would otherwise panic
47/// or silently drop values.
48fn validate_object_shape(object: &VoxjCodecObject) -> Result<()> {
49    if object.samples.len() != object.positions.len() {
50        return Err(Error::Invalid(format!(
51            "object \"{}\" has {} sample rows but {} positions",
52            object.name,
53            object.samples.len(),
54            object.positions.len()
55        )));
56    }
57    let num_palettes = object.palette_refs.len();
58    if let Some(row) = object.samples.iter().find(|row| row.len() != num_palettes) {
59        return Err(Error::Invalid(format!(
60            "object \"{}\" has a sample row of {} values but references {num_palettes} palettes",
61            object.name,
62            row.len()
63        )));
64    }
65    Ok(())
66}
67
68/// Encodes the voxel positions with `encoding`, returning the canonical voxel
69/// order and the block.
70fn encode_positions(
71    object: &VoxjCodecObject,
72    encoding: PositionEncoding,
73) -> (Vec<usize>, VoxjSerdePositionBlock) {
74    match encoding {
75        PositionEncoding::RawJson => raw_positions(&object.positions),
76        PositionEncoding::BitmapBase64 => bitmap_positions(&object.positions, object.bounds),
77        PositionEncoding::Hilbert => hilbert_positions(&object.positions, object.bounds),
78    }
79}
80
81/// Listing order `0..n` paired with the raw block. The raw encoding never
82/// reorders voxels, so positions pass through unchanged and the order is the
83/// identity permutation.
84fn raw_positions(positions: &[[u32; 3]]) -> (Vec<usize>, VoxjSerdePositionBlock) {
85    let order = (0..positions.len()).collect();
86    let block = VoxjSerdePositionBlock::RawJson(positions.to_vec());
87    (order, block)
88}
89
90/// Raster cell index `k = x*Y*Z + y*Z + z`.
91fn cell_index(pos: [u32; 3], bounds: [u32; 3]) -> u64 {
92    let [x, y, z] = pos;
93    x as u64 * bounds[1] as u64 * bounds[2] as u64 + y as u64 * bounds[2] as u64 + z as u64
94}
95
96/// Voxel order ascending by raster cell index, paired with a dense occupancy
97/// bitmap: bit `k` (MSB-first, 8 per byte) is set when raster cell `k` holds a
98/// voxel. Each cell index is computed exactly once, by sorting `(cell, voxel)`
99/// pairs, and shared between the order permutation and the packed bits.
100fn bitmap_positions(
101    positions: &[[u32; 3]],
102    bounds: [u32; 3],
103) -> (Vec<usize>, VoxjSerdePositionBlock) {
104    let mut indexed: Vec<(u64, usize)> = positions
105        .iter()
106        .enumerate()
107        .map(|(i, &pos)| (cell_index(pos, bounds), i))
108        .collect();
109    indexed.sort_unstable();
110
111    let order = indexed.iter().map(|&(_, i)| i).collect();
112
113    // Pack the bits directly instead of filling a one-u32-per-cell occupancy
114    // buffer and packing it afterward. Every position lies within bounds, so
115    // its cell index is < cells.
116    let cells = bounds[0] as usize * bounds[1] as usize * bounds[2] as usize;
117    let mut bytes = vec![0u8; cells.div_ceil(8)];
118    for &(cell, _) in &indexed {
119        let c = cell as usize;
120        debug_assert!(c < cells, "voxel cell {c} outside {cells}-cell bounds");
121        bytes[c / 8] |= 1 << (7 - (c % 8));
122    }
123    let block = VoxjSerdePositionBlock::BitmapBase64(BASE64.encode(bytes));
124    (order, block)
125}
126
127/// Voxel order ascending by Hilbert index, paired with the delta-varint
128/// position block. Each voxel's Hilbert index is computed exactly once and
129/// shared between the order permutation and the encoded deltas. Sorting
130/// `(index, original_voxel)` pairs yields both in a single pass.
131fn hilbert_positions(
132    positions: &[[u32; 3]],
133    bounds: [u32; 3],
134) -> (Vec<usize>, VoxjSerdePositionBlock) {
135    let bits = hilbert_bits(bounds);
136    let mut indexed: Vec<(u64, usize)> = positions
137        .iter()
138        .enumerate()
139        .map(|(i, &[x, y, z])| (encode_hilbert(x, y, z, bits), i))
140        .collect();
141    indexed.sort_unstable();
142
143    let order = indexed.iter().map(|&(_, i)| i).collect();
144    let mut prev = 0u64;
145    let deltas: Vec<u64> = indexed
146        .iter()
147        .map(|&(index, _)| {
148            let d = index - prev;
149            prev = index;
150            d
151        })
152        .collect();
153    let block = VoxjSerdePositionBlock::HilbertIndexDeltaVarintBase64(
154        BASE64.encode(encode_varint(&deltas)),
155    );
156    (order, block)
157}
158
159/// Reorders `samples[voxel][palette]` into one channel per palette, in the
160/// position block's voxel order.
161fn channels_in_order(samples: &[Vec<u32>], order: &[usize], num_palettes: usize) -> Vec<Vec<u32>> {
162    (0..num_palettes)
163        .map(|p| order.iter().map(|&i| samples[i][p]).collect())
164        .collect()
165}
166
167/// Encodes the per-palette sample `channels` (already in the position block's
168/// voxel order) with `encoding`. `n` is the voxel count.
169fn encode_samples(
170    channels: &[Vec<u32>],
171    encoding: SampleEncoding,
172    cell_counts: &[usize],
173    n: usize,
174) -> VoxjSerdeSampleBlock {
175    match encoding {
176        SampleEncoding::RawJson => samples_raw(channels, n),
177        SampleEncoding::RleJson => samples_rle(channels),
178        SampleEncoding::PackedBase64 => samples_packed(channels, cell_counts),
179    }
180}
181
182/// Builds one row per voxel, each holding that voxel's cell index per palette.
183/// `n` is the voxel count, sourced independently of `channels` so an object
184/// with voxels but zero palettes still emits `n` empty rows (matching the
185/// position block's voxel count).
186fn samples_raw(channels: &[Vec<u32>], n: usize) -> VoxjSerdeSampleBlock {
187    let rows = (0..n)
188        .map(|k| channels.iter().map(|ch| ch[k]).collect())
189        .collect();
190    VoxjSerdeSampleBlock::RawJson(rows)
191}
192
193/// Flat run-length encoding: `[value1, count1, value2, count2, ...]`.
194fn rle_encode(channel: &[u32]) -> Vec<u32> {
195    let mut out = Vec::new();
196    let mut iter = channel.iter().copied();
197    let Some(mut value) = iter.next() else {
198        return out;
199    };
200    let mut count = 1u32;
201    for v in iter {
202        if v == value {
203            count += 1;
204        } else {
205            out.push(value);
206            out.push(count);
207            value = v;
208            count = 1;
209        }
210    }
211    out.push(value);
212    out.push(count);
213    out
214}
215
216fn samples_rle(channels: &[Vec<u32>]) -> VoxjSerdeSampleBlock {
217    VoxjSerdeSampleBlock::RleJson(channels.iter().map(|ch| rle_encode(ch)).collect())
218}
219
220fn samples_packed(channels: &[Vec<u32>], cell_counts: &[usize]) -> VoxjSerdeSampleBlock {
221    let packed = channels
222        .iter()
223        .enumerate()
224        .map(|(p, ch)| {
225            let width = packed_width(cell_counts.get(p).copied().unwrap_or(1));
226            BASE64.encode(pack_bits(ch, width))
227        })
228        .collect();
229    VoxjSerdeSampleBlock::PackedBase64(packed)
230}
231
232#[cfg(test)]
233mod tests {
234    use crate::{PositionEncoding, SampleEncoding, encode_voxj_object};
235    use voxj::{VoxjCodecObject, VoxjSerdeObject, VoxjSerdePositionBlock, VoxjSerdeSampleBlock};
236
237    /// An object with voxels but zero palettes must still emit a sample block
238    /// whose arity matches the position block: raw-json carries one (empty) row
239    /// per voxel, and rle/packed carry zero channels.
240    fn assert_zero_palette_arity(object: &VoxjSerdeObject) {
241        match &object.voxel_samples {
242            VoxjSerdeSampleBlock::RawJson(rows) => assert_eq!(rows.len(), 3),
243            VoxjSerdeSampleBlock::RleJson(channels) => assert!(channels.is_empty()),
244            VoxjSerdeSampleBlock::PackedBase64(channels) => assert!(channels.is_empty()),
245        }
246    }
247
248    #[test]
249    fn zero_palette_object_keeps_sample_arity() {
250        assert_zero_palette_arity(
251            &encode_voxj_object(
252                &VoxjCodecObject {
253                    name: "o".to_owned(),
254                    palette_refs: Vec::new(),
255                    bounds: [3, 1, 1],
256                    positions: vec![[0, 0, 0], [1, 0, 0], [2, 0, 0]],
257                    samples: vec![Vec::new(), Vec::new(), Vec::new()],
258                },
259                &[],
260                PositionEncoding::RawJson,
261                SampleEncoding::RawJson,
262            )
263            .unwrap(),
264        );
265    }
266
267    /// A fixed encoding produces exactly the requested blocks.
268    #[test]
269    fn fixed_encoding_uses_requested_blocks() {
270        let object = VoxjCodecObject {
271            name: "o".to_owned(),
272            palette_refs: vec![0],
273            bounds: [2, 1, 1],
274            positions: vec![[0, 0, 0], [1, 0, 0]],
275            samples: vec![vec![1], vec![2]],
276        };
277        let object = encode_voxj_object(
278            &object,
279            &[4],
280            PositionEncoding::BitmapBase64,
281            SampleEncoding::PackedBase64,
282        )
283        .unwrap();
284        assert!(matches!(
285            object.voxel_positions,
286            VoxjSerdePositionBlock::BitmapBase64(_)
287        ));
288        assert!(matches!(
289            object.voxel_samples,
290            VoxjSerdeSampleBlock::PackedBase64(_)
291        ));
292    }
293
294    /// A ragged object (a sample row whose arity differs from the referenced
295    /// palette count, or a sample count that differs from the voxel count) is
296    /// rejected rather than panicking or dropping values mid-encode.
297    #[test]
298    fn rejects_ragged_object() {
299        let wrong_row_arity = VoxjCodecObject {
300            name: "o".to_owned(),
301            palette_refs: vec![0, 1],
302            bounds: [1, 1, 1],
303            positions: vec![[0, 0, 0]],
304            samples: vec![vec![1]],
305        };
306        assert!(
307            encode_voxj_object(
308                &wrong_row_arity,
309                &[4, 4],
310                PositionEncoding::RawJson,
311                SampleEncoding::RawJson,
312            )
313            .is_err()
314        );
315
316        let wrong_sample_count = VoxjCodecObject {
317            name: "o".to_owned(),
318            palette_refs: vec![0],
319            bounds: [2, 1, 1],
320            positions: vec![[0, 0, 0], [1, 0, 0]],
321            samples: vec![vec![1]],
322        };
323        assert!(
324            encode_voxj_object(
325                &wrong_sample_count,
326                &[4],
327                PositionEncoding::RawJson,
328                SampleEncoding::RawJson,
329            )
330            .is_err()
331        );
332    }
333}