Skip to main content

fcb_core/writer/
geom_encoder.rs

1//! Flattening of a CityJSON geometry into the arrays FlatCityBuf stores.
2//!
3//! Every function here is **type-driven**: the nesting depth of `boundaries`,
4//! of `material.values`, of `texture.values` and of `semantics.values` is read
5//! off the `cjseq` variant, never inferred from which arrays happen to be
6//! non-empty. See `crate::reader::geom_decoder` for the matching decode side
7//! and for the table of depths per geometry type.
8
9use cjseq::{
10    Geometry as CjGeometry, MaterialReference as CjMaterialReference,
11    MaterialValues as CjMaterialValues, Ring, Semantics as CjSemantics,
12    SemanticsSurface as CjSemanticsSurface, SemanticsValues as CjSemanticsValues, Shell, Surface,
13    TextureReference as CjTextureReference, TextureValues as CjTextureValues, TexturedShell,
14    TexturedSurface,
15};
16use std::collections::HashMap;
17
18/// The wire spelling of "no value here": a `null` index in CityJSON, and — in
19/// the `solids`/`shells` count arrays of a material mapping — a whole `null`
20/// shell or solid, which `material.values` permits at every level.
21const NULL: u32 = u32::MAX;
22
23#[derive(Debug, Clone, Default)]
24pub(crate) struct GMBoundaries {
25    pub(crate) solids: Vec<u32>,   // Number of shells per solid
26    pub(crate) shells: Vec<u32>,   // Number of surfaces per shell
27    pub(crate) surfaces: Vec<u32>, // Number of rings per surface
28    pub(crate) strings: Vec<u32>,  // Number of indices per ring
29    pub(crate) indices: Vec<u32>,  // Flattened list of all indices
30}
31
32#[derive(Debug, Clone, Default)]
33pub struct MaterialValues {
34    pub(crate) theme: String,
35    /// One entry per solid, holding that solid's shell count; `NULL` for a
36    /// solid that is `null` in `material.values`.
37    pub(crate) solids: Vec<u32>,
38    /// One entry per shell, holding that shell's surface count; `NULL` for a
39    /// shell that is `null` in `material.values`.
40    pub(crate) shells: Vec<u32>,
41    /// One material index per surface, `NULL` for a surface with no material.
42    pub(crate) vertices: Vec<u32>,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct MaterialValue {
47    pub(crate) theme: String,
48    pub(crate) value: u32,
49}
50
51#[derive(Debug, Clone)]
52pub enum MaterialMapping {
53    Value(MaterialValue),
54    Values(MaterialValues),
55    /// `"values": null` — distinct from an absent `values`, which the schema
56    /// treats as a different (and, on its own, invalid) document. Stored as a
57    /// mapping that names its theme and carries no arrays at all.
58    NullValues(String),
59}
60
61#[derive(Debug, Clone, Default)]
62pub(crate) struct TextureMapping {
63    pub(crate) theme: String,
64    /// Whether the theme has a `values` member at all. A theme without one is
65    /// valid CityJSON (the per-theme texture object has no `required`), and is
66    /// stored as a mapping carrying no arrays.
67    pub(crate) has_values: bool,
68    pub(crate) solids: Vec<u32>,   // Number of shells per solid
69    pub(crate) shells: Vec<u32>,   // Number of surfaces per shell
70    pub(crate) surfaces: Vec<u32>, // Number of rings per surface
71    pub(crate) strings: Vec<u32>,  // Number of indices per ring
72    pub(crate) vertices: Vec<u32>, // Flattened list of all indices
73}
74
75#[derive(Debug, Clone, Default)]
76pub(crate) struct GMSemantics {
77    pub(crate) surfaces: Vec<CjSemanticsSurface>, // List of semantic surfaces
78    /// One semantic value per surface, or `None` for `"values": null`.
79    ///
80    /// `semantics.values` is required-but-nullable, so an explicit `null` is
81    /// valid CityJSON and is *not* the same as an empty array: re-emitting it
82    /// as `[]` (or, for a two-shell `Solid`, as `[[], []]`) produces a document
83    /// the schema rejects. `None` is stored as an absent vector, exactly as
84    /// `MaterialMapping::NullValues` and `TextureMapping::has_values` do.
85    pub(crate) values: Option<Vec<u32>>,
86}
87
88#[derive(Debug, Clone, Default)]
89#[doc(hidden)]
90pub(crate) struct EncodedGeometry {
91    pub(crate) boundaries: GMBoundaries,
92    pub(crate) semantics: Option<GMSemantics>,
93    pub(crate) textures: Option<Vec<TextureMapping>>,
94    pub(crate) materials: Option<Vec<MaterialMapping>>,
95}
96
97/// Flattens one geometry — its boundaries and whatever semantics, material and
98/// texture it carries — into the FlatCityBuf arrays.
99///
100/// A [`CjGeometry::GeometryInstance`] carries none of these; it is encoded by
101/// `serializer::to_geometry_instance` instead, and yields empty arrays here.
102pub(crate) fn encode(geometry: &CjGeometry) -> EncodedGeometry {
103    let boundaries = encode_boundaries(geometry);
104
105    let common = geometry.common();
106    let semantics = common
107        .and_then(|c| c.semantics.as_ref())
108        .map(|s| encode_semantics(s, &boundaries));
109    let textures = common.and_then(|c| c.texture.as_ref()).map(encode_texture);
110    let materials = common
111        .and_then(|c| c.material.as_ref())
112        .map(encode_material);
113
114    EncodedGeometry {
115        boundaries,
116        semantics,
117        materials,
118        textures,
119    }
120}
121
122// ---------------------------------------------------------------------------
123// boundaries
124// ---------------------------------------------------------------------------
125
126fn push_ring(ring: &Ring, b: &mut GMBoundaries) {
127    b.strings.push(ring.len() as u32);
128    b.indices.extend(ring.iter().map(|&i| i as u32));
129}
130
131fn push_surface(surface: &Surface, b: &mut GMBoundaries) {
132    for ring in surface {
133        push_ring(ring, b);
134    }
135    b.surfaces.push(surface.len() as u32);
136}
137
138fn push_shell(shell: &Shell, b: &mut GMBoundaries) {
139    for surface in shell {
140        push_surface(surface, b);
141    }
142    b.shells.push(shell.len() as u32);
143}
144
145fn push_solid(solid: &[Shell], b: &mut GMBoundaries) {
146    for shell in solid {
147        push_shell(shell, b);
148    }
149    b.solids.push(solid.len() as u32);
150}
151
152/// Flattens `boundaries` at exactly the depth the geometry's type implies.
153///
154/// The outermost level is recorded as a single count one array up from where
155/// the type sits — a `MultiSurface` writes its surface count into `shells`, a
156/// `Solid` its shell count into `solids` — except for the 5-deep types, whose
157/// outermost count is simply `solids.len()`. This is the shape the format has
158/// always had; it is reproduced here without the depth inference that used to
159/// produce it.
160pub(crate) fn encode_boundaries(geometry: &CjGeometry) -> GMBoundaries {
161    let mut b = GMBoundaries::default();
162    match geometry {
163        CjGeometry::MultiPoint { boundaries, .. } => push_ring(boundaries, &mut b),
164        CjGeometry::MultiLineString { boundaries, .. } => {
165            for ring in boundaries {
166                push_ring(ring, &mut b);
167            }
168            b.surfaces.push(boundaries.len() as u32);
169        }
170        CjGeometry::MultiSurface { boundaries, .. }
171        | CjGeometry::CompositeSurface { boundaries, .. } => {
172            for surface in boundaries {
173                push_surface(surface, &mut b);
174            }
175            b.shells.push(boundaries.len() as u32);
176        }
177        CjGeometry::Solid { boundaries, .. } => push_solid(boundaries, &mut b),
178        CjGeometry::MultiSolid { boundaries, .. }
179        | CjGeometry::CompositeSolid { boundaries, .. } => {
180            for solid in boundaries {
181                push_solid(solid, &mut b);
182            }
183        }
184        // Encoded by `to_geometry_instance`, which writes the single reference
185        // vertex index directly.
186        CjGeometry::GeometryInstance { .. } => {}
187    }
188    b
189}
190
191// ---------------------------------------------------------------------------
192// material
193// ---------------------------------------------------------------------------
194
195fn material_index(i: Option<usize>) -> u32 {
196    i.map_or(NULL, |v| v as u32)
197}
198
199/// Flattens one theme's `material.values` at the depth its variant declares.
200///
201/// A `null` shell or solid — legal at every level of `material.values` — is
202/// written as a `NULL` count in `shells`/`solids` rather than being dropped,
203/// so it decodes back as `null` and not as an empty array (finding #7).
204pub(crate) fn encode_material(
205    materials: &HashMap<String, CjMaterialReference>,
206) -> Vec<MaterialMapping> {
207    let mut material_mappings = Vec::new();
208    // cjseq hands us a `HashMap`, so fix the theme order: the mappings go out
209    // in iteration order and a randomly seeded one is a different file per run.
210    let mut themes: Vec<&String> = materials.keys().collect();
211    themes.sort_unstable();
212    for (theme, material) in themes
213        .into_iter()
214        .filter_map(|t| materials.get(t).map(|m| (t, m)))
215    {
216        if let Some(value) = material.value {
217            material_mappings.push(MaterialMapping::Value(MaterialValue {
218                theme: theme.clone(),
219                value: value as u32,
220            }));
221            continue;
222        }
223
224        // The outer `Option` is present-vs-absent, the inner `null`-vs-array.
225        let values = match material.values.as_ref() {
226            Some(Some(values)) => values,
227            Some(None) => {
228                material_mappings.push(MaterialMapping::NullValues(theme.clone()));
229                continue;
230            }
231            // Neither `value` nor `values`: nothing to store, and nothing the
232            // schema would accept either.
233            None => continue,
234        };
235
236        let mut mv = MaterialValues {
237            theme: theme.clone(),
238            ..Default::default()
239        };
240
241        match values {
242            // MultiSurface, CompositeSurface: one index per surface.
243            CjMaterialValues::Surfaces(surfaces) => {
244                mv.vertices
245                    .extend(surfaces.iter().copied().map(material_index));
246            }
247            // Solid: one index per surface, per shell.
248            CjMaterialValues::Shells(shells) => {
249                mv.solids.push(shells.len() as u32);
250                for shell in shells {
251                    push_material_shell(shell.as_deref(), &mut mv);
252                }
253            }
254            // MultiSolid, CompositeSolid: ... per solid.
255            CjMaterialValues::Solids(solids) => {
256                for solid in solids {
257                    match solid {
258                        Some(shells) => {
259                            mv.solids.push(shells.len() as u32);
260                            for shell in shells {
261                                push_material_shell(shell.as_deref(), &mut mv);
262                            }
263                        }
264                        None => mv.solids.push(NULL),
265                    }
266                }
267            }
268        }
269
270        material_mappings.push(MaterialMapping::Values(mv));
271    }
272    material_mappings
273}
274
275fn push_material_shell(shell: Option<&[Option<usize>]>, mv: &mut MaterialValues) {
276    match shell {
277        Some(indices) => {
278            mv.shells.push(indices.len() as u32);
279            mv.vertices
280                .extend(indices.iter().copied().map(material_index));
281        }
282        None => mv.shells.push(NULL),
283    }
284}
285
286// ---------------------------------------------------------------------------
287// texture
288// ---------------------------------------------------------------------------
289
290pub(crate) fn encode_texture(
291    texture_map: &HashMap<String, CjTextureReference>,
292) -> Vec<TextureMapping> {
293    let mut texture_mappings = Vec::new();
294
295    // Same fixed theme order as `encode_material`, and for the same reason.
296    let mut themes: Vec<&String> = texture_map.keys().collect();
297    themes.sort_unstable();
298    for (theme, texture) in themes
299        .into_iter()
300        .filter_map(|t| texture_map.get(t).map(|x| (t, x)))
301    {
302        let mut mapping = TextureMapping {
303            theme: theme.clone(),
304            ..Default::default()
305        };
306
307        // A theme may legally carry no `values` at all, which flattens to
308        // nothing but still records the theme.
309        if let Some(values) = texture.values.as_ref() {
310            mapping.has_values = true;
311            encode_texture_values(values, &mut mapping);
312        }
313
314        texture_mappings.push(mapping);
315    }
316
317    texture_mappings
318}
319
320fn push_textured_surface(surface: &TexturedSurface, m: &mut TextureMapping) {
321    for ring in surface {
322        m.strings.push(ring.len() as u32);
323        m.vertices.extend(ring.iter().copied().map(material_index));
324    }
325    m.surfaces.push(surface.len() as u32);
326}
327
328fn push_textured_shell(shell: &TexturedShell, m: &mut TextureMapping) {
329    for surface in shell {
330        push_textured_surface(surface, m);
331    }
332    m.shells.push(shell.len() as u32);
333}
334
335/// Flattens one theme's `texture.values` at the depth its variant declares.
336/// `texture.values` is nested exactly as deeply as the geometry's boundaries,
337/// so this mirrors [`encode_boundaries`] level for level.
338fn encode_texture_values(values: &CjTextureValues, m: &mut TextureMapping) {
339    match values {
340        CjTextureValues::Surface(surfaces) => {
341            for surface in surfaces {
342                push_textured_surface(surface, m);
343            }
344            m.shells.push(surfaces.len() as u32);
345        }
346        CjTextureValues::Shell(shells) => {
347            for shell in shells {
348                push_textured_shell(shell, m);
349            }
350            m.solids.push(shells.len() as u32);
351        }
352        CjTextureValues::Solid(solids) => {
353            for solid in solids {
354                for shell in solid {
355                    push_textured_shell(shell, m);
356                }
357                m.solids.push(solid.len() as u32);
358            }
359        }
360    }
361}
362
363// ---------------------------------------------------------------------------
364// semantics
365// ---------------------------------------------------------------------------
366
367fn semantics_index(i: Option<usize>) -> u32 {
368    i.map_or(NULL, |v| v as u32)
369}
370
371/// Flattens `semantics.values` into one entry per surface, in document order.
372///
373/// The depth comes from the `SemanticsValues` variant. Unlike a material
374/// mapping, a semantics mapping has no count arrays of its own — the decoder
375/// regroups the flat run using the *boundary* counts — so a `null` shell or
376/// solid cannot be stored as such. It is expanded to a run of `null` surface
377/// values of the right length instead, which says the same thing (nothing in
378/// here is semantically tagged) but does not round-trip its spelling.
379fn encode_semantics_values(
380    values: &CjSemanticsValues,
381    boundaries: &GMBoundaries,
382    flattened: &mut Vec<u32>,
383) {
384    match values {
385        CjSemanticsValues::Surfaces(surfaces) => {
386            flattened.extend(surfaces.iter().copied().map(semantics_index));
387        }
388        CjSemanticsValues::Shells(shells) => {
389            let mut shell_cursor = 0;
390            for shell in shells {
391                push_semantics_shell(shell.as_deref(), boundaries, &mut shell_cursor, flattened);
392            }
393        }
394        CjSemanticsValues::Solids(solids) => {
395            let mut shell_cursor = 0;
396            for (i, solid) in solids.iter().enumerate() {
397                let shell_count = boundaries.solids.get(i).copied().unwrap_or(0) as usize;
398                match solid {
399                    Some(shells) => {
400                        for shell in shells {
401                            push_semantics_shell(
402                                shell.as_deref(),
403                                boundaries,
404                                &mut shell_cursor,
405                                flattened,
406                            );
407                        }
408                    }
409                    None => {
410                        for _ in 0..shell_count {
411                            push_semantics_shell(None, boundaries, &mut shell_cursor, flattened);
412                        }
413                    }
414                }
415            }
416        }
417    }
418}
419
420fn push_semantics_shell(
421    shell: Option<&[Option<usize>]>,
422    boundaries: &GMBoundaries,
423    shell_cursor: &mut usize,
424    flattened: &mut Vec<u32>,
425) {
426    let surface_count = boundaries.shells.get(*shell_cursor).copied().unwrap_or(0) as usize;
427    *shell_cursor += 1;
428    match shell {
429        Some(indices) => flattened.extend(indices.iter().copied().map(semantics_index)),
430        None => flattened.extend(std::iter::repeat_n(NULL, surface_count)),
431    }
432}
433
434/// Flattens a `semantics` member: its surfaces verbatim, its values one entry
435/// per surface — or no vector at all for `"values": null`.
436pub(crate) fn encode_semantics(semantics: &CjSemantics, boundaries: &GMBoundaries) -> GMSemantics {
437    let values = semantics.values.as_ref().map(|v| {
438        let mut values = Vec::new();
439        encode_semantics_values(v, boundaries, &mut values);
440        values
441    });
442
443    GMSemantics {
444        surfaces: semantics.surfaces.to_vec(),
445        values,
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use anyhow::Result;
453    use cjseq::SemanticSurfaceType;
454    use pretty_assertions::assert_eq;
455    use serde_json::json;
456
457    fn theme_of(m: &MaterialMapping) -> &str {
458        match m {
459            MaterialMapping::Value(v) => &v.theme,
460            MaterialMapping::Values(v) => &v.theme,
461            MaterialMapping::NullValues(theme) => theme,
462        }
463    }
464
465    fn geom(v: serde_json::Value) -> CjGeometry {
466        serde_json::from_value(v).expect("test geometry must parse")
467    }
468
469    #[test]
470    fn test_encode_boundaries() -> Result<()> {
471        // MultiPoint
472        let encoded = encode(&geom(json!({
473            "type": "MultiPoint", "lod": "1", "boundaries": [2, 44, 0, 7]
474        })));
475        assert_eq!(vec![2, 44, 0, 7], encoded.boundaries.indices);
476        assert_eq!(vec![4], encoded.boundaries.strings);
477        assert!(encoded.boundaries.surfaces.is_empty());
478        assert!(encoded.boundaries.shells.is_empty());
479        assert!(encoded.boundaries.solids.is_empty());
480
481        // MultiLineString
482        let encoded = encode(&geom(json!({
483            "type": "MultiLineString", "lod": "1", "boundaries": [[2, 3, 5], [77, 55, 212]]
484        })));
485        assert_eq!(vec![2, 3, 5, 77, 55, 212], encoded.boundaries.indices);
486        assert_eq!(vec![3, 3], encoded.boundaries.strings);
487        assert_eq!(vec![2], encoded.boundaries.surfaces);
488        assert!(encoded.boundaries.shells.is_empty());
489        assert!(encoded.boundaries.solids.is_empty());
490
491        // MultiSurface
492        let encoded = encode(&geom(json!({
493            "type": "MultiSurface", "lod": "1",
494            "boundaries": [[[0, 3, 2, 1]], [[4, 5, 6, 7]], [[0, 1, 5, 4]]]
495        })));
496        assert_eq!(
497            vec![0, 3, 2, 1, 4, 5, 6, 7, 0, 1, 5, 4],
498            encoded.boundaries.indices
499        );
500        assert_eq!(vec![4, 4, 4], encoded.boundaries.strings);
501        assert_eq!(vec![1, 1, 1], encoded.boundaries.surfaces);
502        assert_eq!(vec![3], encoded.boundaries.shells);
503        assert!(encoded.boundaries.solids.is_empty());
504
505        // Solid
506        let encoded = encode(&geom(json!({
507            "type": "Solid", "lod": "1",
508            "boundaries": [
509                [
510                    [[0, 3, 2, 1, 22], [1, 2, 3, 4]],
511                    [[4, 5, 6, 7]],
512                    [[0, 1, 5, 4]],
513                    [[1, 2, 6, 5]]
514                ],
515                [
516                    [[240, 243, 124]],
517                    [[244, 246, 724]],
518                    [[34, 414, 45]],
519                    [[111, 246, 5]]
520                ]
521            ]
522        })));
523        assert_eq!(
524            vec![
525                0, 3, 2, 1, 22, 1, 2, 3, 4, 4, 5, 6, 7, 0, 1, 5, 4, 1, 2, 6, 5, 240, 243, 124, 244,
526                246, 724, 34, 414, 45, 111, 246, 5
527            ],
528            encoded.boundaries.indices
529        );
530        assert_eq!(vec![5, 4, 4, 4, 4, 3, 3, 3, 3], encoded.boundaries.strings);
531        assert_eq!(vec![2, 1, 1, 1, 1, 1, 1, 1], encoded.boundaries.surfaces);
532        assert_eq!(vec![4, 4], encoded.boundaries.shells);
533        assert_eq!(vec![2], encoded.boundaries.solids);
534
535        // CompositeSolid
536        let encoded = encode(&geom(json!({
537            "type": "CompositeSolid", "lod": "1",
538            "boundaries": [
539                [
540                    [
541                        [[0, 3, 2, 1, 22]],
542                        [[4, 5, 6, 7]],
543                        [[0, 1, 5, 4]],
544                        [[1, 2, 6, 5]]
545                    ],
546                    [
547                        [[240, 243, 124]],
548                        [[244, 246, 724]],
549                        [[34, 414, 45]],
550                        [[111, 246, 5]]
551                    ]
552                ],
553                [[
554                    [[666, 667, 668]],
555                    [[74, 75, 76]],
556                    [[880, 881, 885]],
557                    [[111, 122, 226]]
558                ]]
559            ]
560        })));
561        assert_eq!(
562            vec![
563                0, 3, 2, 1, 22, 4, 5, 6, 7, 0, 1, 5, 4, 1, 2, 6, 5, 240, 243, 124, 244, 246, 724,
564                34, 414, 45, 111, 246, 5, 666, 667, 668, 74, 75, 76, 880, 881, 885, 111, 122, 226
565            ],
566            encoded.boundaries.indices
567        );
568        assert_eq!(
569            encoded.boundaries.strings,
570            vec![5, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 3]
571        );
572        assert_eq!(
573            encoded.boundaries.surfaces,
574            vec![1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
575        );
576        assert_eq!(encoded.boundaries.shells, vec![4, 4, 4]);
577        assert_eq!(encoded.boundaries.solids, vec![2, 1]);
578
579        Ok(())
580    }
581
582    /// A `MultiSurface` and a `CompositeSurface` with identical boundaries
583    /// flatten identically, and so do a `MultiSolid` and a `CompositeSolid`:
584    /// nothing about the encoding distinguishes the members of a depth pair.
585    /// The type is what tells them apart, and it is stored separately.
586    #[test]
587    fn types_of_equal_depth_flatten_identically() {
588        let surface_boundaries = json!([[[0, 1, 2]], [[3, 4, 5]]]);
589        let ms = encode(&geom(
590            json!({"type": "MultiSurface", "boundaries": surface_boundaries}),
591        ));
592        let cs = encode(&geom(
593            json!({"type": "CompositeSurface", "boundaries": surface_boundaries}),
594        ));
595        assert_eq!(ms.boundaries.shells, cs.boundaries.shells);
596        assert_eq!(ms.boundaries.surfaces, cs.boundaries.surfaces);
597        assert_eq!(ms.boundaries.indices, cs.boundaries.indices);
598
599        let solid_boundaries = json!([[[[[0, 1, 2]]]], [[[[3, 4, 5]]]]]);
600        let msol = encode(&geom(
601            json!({"type": "MultiSolid", "boundaries": solid_boundaries}),
602        ));
603        let csol = encode(&geom(
604            json!({"type": "CompositeSolid", "boundaries": solid_boundaries}),
605        ));
606        assert_eq!(msol.boundaries.solids, csol.boundaries.solids);
607        assert_eq!(msol.boundaries.shells, csol.boundaries.shells);
608    }
609
610    #[test]
611    fn test_encode_semantics() -> Result<()> {
612        //MultiSurface
613        let multi_surface = geom(json!({
614            "type": "MultiSurface",
615            "lod": "2",
616            "boundaries": [
617              [[0, 3, 2, 1]],
618              [[4, 5, 6, 7]],
619              [[0, 1, 5, 4]],
620              [[0, 2, 3, 8]],
621              [[10, 12, 23, 48]]
622            ],
623            "semantics": {
624              "surfaces": [
625                {"type": "WallSurface", "slope": 33.4, "children": [2]},
626                {"type": "RoofSurface", "slope": 66.6},
627                {"type": "OuterCeilingSurface", "parent": 0, "colour": "blue"}
628              ],
629              "values": [0, 0, null, 1, 2]
630            }
631        }));
632        let encoded = encode(&multi_surface);
633        let encoded_semantics = encoded.semantics.expect("semantics must be encoded");
634
635        let expected_semantics_surfaces = vec![
636            CjSemanticsSurface {
637                thetype: SemanticSurfaceType::WallSurface,
638                parent: None,
639                children: Some(vec![2]),
640                other: HashMap::from([("slope".to_string(), json!(33.4))]),
641            },
642            CjSemanticsSurface {
643                thetype: SemanticSurfaceType::RoofSurface,
644                parent: None,
645                children: None,
646                other: HashMap::from([("slope".to_string(), json!(66.6))]),
647            },
648            CjSemanticsSurface {
649                thetype: SemanticSurfaceType::OuterCeilingSurface,
650                parent: Some(0),
651                children: None,
652                other: HashMap::from([("colour".to_string(), json!("blue"))]),
653            },
654        ];
655
656        assert_eq!(expected_semantics_surfaces, encoded_semantics.surfaces);
657        assert_eq!(Some(vec![0, 0, NULL, 1, 2]), encoded_semantics.values);
658
659        //CompositeSolid
660        let composite_solid = geom(json!({
661          "type": "CompositeSolid",
662          "lod": "2.2",
663          "boundaries": [
664            [[
665              [[0, 3, 2, 1, 22]],
666              [[4, 5, 6, 7]],
667              [[0, 1, 5, 4]],
668              [[1, 2, 6, 5]]
669            ]],
670            [[
671              [[666, 667, 668]],
672              [[74, 75, 76]],
673              [[880, 881, 885]]
674            ]]
675          ],
676          "semantics": {
677            "surfaces": [{"type": "RoofSurface"}, {"type": "WallSurface"}],
678            "values": [[[0, 1, 1, null]], [[null, null, null]]]
679          }
680        }));
681        let encoded = encode(&composite_solid);
682        let encoded_semantics = encoded.semantics.expect("semantics must be encoded");
683
684        let expected_semantics_surfaces = vec![
685            CjSemanticsSurface {
686                thetype: SemanticSurfaceType::RoofSurface,
687                parent: None,
688                children: None,
689                other: HashMap::new(),
690            },
691            CjSemanticsSurface {
692                thetype: SemanticSurfaceType::WallSurface,
693                parent: None,
694                children: None,
695                other: HashMap::new(),
696            },
697        ];
698
699        assert_eq!(expected_semantics_surfaces, encoded_semantics.surfaces);
700        assert_eq!(
701            Some(vec![0, 1, 1, NULL, NULL, NULL, NULL]),
702            encoded_semantics.values
703        );
704        Ok(())
705    }
706
707    /// A `null` shell in `semantics.values` cannot be stored as such — the
708    /// decoder regroups the flat run using the boundary counts — so it is
709    /// expanded to one `null` per surface of the corresponding boundary shell.
710    #[test]
711    fn a_null_semantics_shell_expands_to_one_null_per_surface() {
712        let solid = geom(json!({
713            "type": "Solid",
714            "boundaries": [
715                [[[0, 1, 2]], [[3, 4, 5]]],
716                [[[6, 7, 8]]]
717            ],
718            "semantics": {
719                "surfaces": [{"type": "RoofSurface"}],
720                "values": [[0, 0], null]
721            }
722        }));
723        let encoded = encode(&solid);
724        let semantics = encoded.semantics.expect("semantics must be encoded");
725        // Two surfaces in the first shell, one in the second.
726        assert_eq!(semantics.values, Some(vec![0, 0, NULL]));
727    }
728
729    #[test]
730    fn test_encode_material() -> Result<()> {
731        // Test case 1: Single material value
732        let materials = HashMap::from([(
733            "theme1".to_string(),
734            serde_json::from_value::<CjMaterialReference>(json!({"value": 5}))?,
735        )]);
736
737        let encoded = encode_material(&materials);
738        assert_eq!(encoded.len(), 1);
739        match &encoded[0] {
740            MaterialMapping::Value(value) => {
741                assert_eq!(value.theme, "theme1");
742                assert_eq!(value.value, 5);
743            }
744            _ => panic!("Expected MaterialMapping::Value"),
745        }
746
747        // Test case 2: MultiSurface material values
748        let materials = HashMap::from([(
749            "theme2".to_string(),
750            serde_json::from_value::<CjMaterialReference>(json!({"values": [0, 1, null, 2]}))?,
751        )]);
752
753        let encoded = encode_material(&materials);
754        assert_eq!(encoded.len(), 1);
755        match &encoded[0] {
756            MaterialMapping::Values(values) => {
757                assert_eq!(values.theme, "theme2");
758                assert_eq!(values.vertices, vec![0, 1, NULL, 2]);
759                assert!(values.shells.is_empty());
760                assert!(values.solids.is_empty());
761            }
762            _ => panic!("Expected MaterialMapping::Values"),
763        }
764
765        // Test case 3: Solid material values
766        let materials = HashMap::from([(
767            "theme3".to_string(),
768            serde_json::from_value::<CjMaterialReference>(
769                json!({"values": [[0, 1, null], [2, 3, 4]]}),
770            )?,
771        )]);
772
773        let encoded = encode_material(&materials);
774        assert_eq!(encoded.len(), 1);
775        match &encoded[0] {
776            MaterialMapping::Values(values) => {
777                assert_eq!(values.theme, "theme3");
778                assert_eq!(values.solids, vec![2]); // 1 solid with 2 shells
779                assert_eq!(values.shells, vec![3, 3]); // Each shell has 3 surfaces
780                assert_eq!(values.vertices, vec![0, 1, NULL, 2, 3, 4]);
781            }
782            _ => panic!("Expected MaterialMapping::Values"),
783        }
784
785        // Test case 4: Multiple themes
786        let materials = HashMap::from([
787            (
788                "theme4".to_string(),
789                serde_json::from_value::<CjMaterialReference>(json!({"value": 7}))?,
790            ),
791            (
792                "theme5".to_string(),
793                serde_json::from_value::<CjMaterialReference>(json!({"values": [8, 9]}))?,
794            ),
795        ]);
796
797        let encoded = encode_material(&materials);
798        assert_eq!(encoded.len(), 2);
799
800        // Find and verify each mapping by theme name instead of relying on order
801        let theme4_mapping = encoded
802            .iter()
803            .find(|m| theme_of(m) == "theme4")
804            .expect("Should have theme4 mapping");
805
806        let theme5_mapping = encoded
807            .iter()
808            .find(|m| theme_of(m) == "theme5")
809            .expect("Should have theme5 mapping");
810
811        match theme4_mapping {
812            MaterialMapping::Value(value) => {
813                assert_eq!(value.theme, "theme4");
814                assert_eq!(value.value, 7);
815            }
816            _ => panic!("Expected MaterialMapping::Value for theme4"),
817        }
818
819        match theme5_mapping {
820            MaterialMapping::Values(values) => {
821                assert_eq!(values.theme, "theme5");
822                assert_eq!(values.vertices, vec![8, 9]);
823                assert!(values.shells.is_empty());
824                assert!(values.solids.is_empty());
825            }
826            _ => panic!("Expected MaterialMapping::Values for theme5"),
827        }
828
829        // Test case 5: CompositeSolid material values
830        let materials = HashMap::from([(
831            "theme6".to_string(),
832            serde_json::from_value::<CjMaterialReference>(json!({
833                "values": [[[0, 1, null], [2, null, null]], [[3, 4, null]]]
834            }))?,
835        )]);
836
837        let encoded = encode_material(&materials);
838        assert_eq!(encoded.len(), 1);
839        match &encoded[0] {
840            MaterialMapping::Values(values) => {
841                assert_eq!(values.theme, "theme6");
842                assert_eq!(values.solids, vec![2, 1]); // Two solids: 2 shells, then 1
843                assert_eq!(values.shells, vec![3, 3, 3]); // Each shell has 3 surfaces
844                assert_eq!(values.vertices, vec![0, 1, NULL, 2, NULL, NULL, 3, 4, NULL]);
845            }
846            _ => panic!("Expected MaterialMapping::Values"),
847        }
848
849        Ok(())
850    }
851
852    /// `material.values` is nullable at *every* level, not only at the leaf.
853    /// A whole `null` shell or solid is recorded as a `NULL` count so that it
854    /// decodes back as `null` rather than as an empty array.
855    #[test]
856    fn a_null_material_shell_or_solid_is_recorded_as_a_null_count() -> Result<()> {
857        let materials = HashMap::from([(
858            "t".to_string(),
859            serde_json::from_value::<CjMaterialReference>(json!({"values": [[0, 1], null]}))?,
860        )]);
861        match &encode_material(&materials)[0] {
862            MaterialMapping::Values(v) => {
863                assert_eq!(v.solids, vec![2]);
864                assert_eq!(v.shells, vec![2, NULL]);
865                assert_eq!(v.vertices, vec![0, 1]);
866            }
867            _ => panic!("expected Values"),
868        }
869
870        let materials = HashMap::from([(
871            "t".to_string(),
872            serde_json::from_value::<CjMaterialReference>(json!({"values": [[[0, 1]], null]}))?,
873        )]);
874        match &encode_material(&materials)[0] {
875            MaterialMapping::Values(v) => {
876                assert_eq!(v.solids, vec![1, NULL]);
877                assert_eq!(v.shells, vec![2]);
878                assert_eq!(v.vertices, vec![0, 1]);
879            }
880            _ => panic!("expected Values"),
881        }
882        Ok(())
883    }
884
885    #[test]
886    fn test_encode_texture() -> Result<()> {
887        let theme = "test-theme".to_string();
888        let texture = |v: serde_json::Value| -> HashMap<String, CjTextureReference> {
889            HashMap::from([(
890                theme.clone(),
891                serde_json::from_value::<CjTextureReference>(json!({"values": v}))
892                    .expect("texture values must parse"),
893            )])
894        };
895
896        // MultiSurface-like texture values (the shallowest a texture can be)
897        let encoded = encode_texture(&texture(json!([
898            [[0, 10, 20, 30]],
899            [[1, 11, 21, null]],
900            [[2, 12, null, 32]]
901        ])));
902        assert_eq!(encoded.len(), 1);
903        assert_eq!(encoded[0].theme, theme);
904        assert_eq!(
905            encoded[0].vertices,
906            vec![0, 10, 20, 30, 1, 11, 21, NULL, 2, 12, NULL, 32]
907        );
908        assert_eq!(encoded[0].strings, vec![4, 4, 4]);
909        assert_eq!(encoded[0].surfaces, vec![1, 1, 1]);
910        assert_eq!(encoded[0].shells, vec![3]);
911        assert!(encoded[0].solids.is_empty());
912
913        // Solid-like texture values
914        let encoded = encode_texture(&texture(json!([
915            [[[0, 10, 20, 30]], [[1, 11, 21, null]], [[2, 12, null, 32]]],
916            [[[3, 13, 23, 33]], [[4, 14, 24, null]]]
917        ])));
918        assert_eq!(encoded.len(), 1);
919        assert_eq!(
920            encoded[0].vertices,
921            vec![0, 10, 20, 30, 1, 11, 21, NULL, 2, 12, NULL, 32, 3, 13, 23, 33, 4, 14, 24, NULL]
922        );
923        assert_eq!(encoded[0].strings, vec![4, 4, 4, 4, 4]);
924        assert_eq!(encoded[0].surfaces, vec![1, 1, 1, 1, 1]);
925        assert_eq!(encoded[0].shells, vec![3, 2]);
926        assert_eq!(encoded[0].solids, vec![2]);
927
928        // CompositeSolid-like texture values
929        let encoded = encode_texture(&texture(json!([
930            [
931                [[[0, 10, 20]], [[1, 11, null]]],
932                [[[2, 12, 22]], [[3, null, 23]]]
933            ],
934            [[[[4, 14, 24]], [[5, 15, 25]]]]
935        ])));
936        assert_eq!(encoded.len(), 1);
937        assert_eq!(
938            encoded[0].vertices,
939            vec![0, 10, 20, 1, 11, NULL, 2, 12, 22, 3, NULL, 23, 4, 14, 24, 5, 15, 25]
940        );
941        assert_eq!(encoded[0].strings, vec![3, 3, 3, 3, 3, 3]);
942        assert_eq!(encoded[0].surfaces, vec![1, 1, 1, 1, 1, 1]);
943        assert_eq!(encoded[0].shells, vec![2, 2, 2]);
944        assert_eq!(encoded[0].solids, vec![2, 1]);
945
946        // Multiple themes
947        let textures = HashMap::from([
948            (
949                "winter".to_string(),
950                serde_json::from_value::<CjTextureReference>(json!({"values": [[[0, 10, 20]]]}))?,
951            ),
952            (
953                "summer".to_string(),
954                serde_json::from_value::<CjTextureReference>(json!({"values": [[[1, 11, null]]]}))?,
955            ),
956        ]);
957
958        let encoded = encode_texture(&textures);
959        assert_eq!(encoded.len(), 2);
960
961        let winter_mapping = encoded
962            .iter()
963            .find(|m| m.theme == "winter")
964            .expect("Should have winter mapping");
965        let summer_mapping = encoded
966            .iter()
967            .find(|m| m.theme == "summer")
968            .expect("Should have summer mapping");
969
970        assert_eq!(winter_mapping.vertices, vec![0, 10, 20]);
971        assert_eq!(winter_mapping.strings, vec![3]);
972
973        assert_eq!(summer_mapping.vertices, vec![1, 11, NULL]);
974        assert_eq!(summer_mapping.strings, vec![3]);
975
976        Ok(())
977    }
978}