Skip to main content

fcb_core/reader/
geom_decoder.rs

1//! Rebuilding a CityJSON geometry from the flat arrays FlatCityBuf stores.
2//!
3//! **The nesting depth of everything here comes from the geometry type**, which
4//! is stored in the `Geometry` table alongside the arrays. Nothing infers depth
5//! from which of `solids`/`shells`/`surfaces`/`strings` happen to be populated.
6//!
7//! That inference is what produced finding #8: `material.values` on a `Solid`
8//! with exactly one shell, and `texture.values` on a single-string
9//! `MultiLineString`, came back one level deeper than they went in — because a
10//! `Solid` and a one-solid `MultiSolid` flatten to byte-identical arrays, and
11//! only the type tells them apart.
12//!
13//! The depths, from `geomprimitives.schema.json` and CityJSON 2.0 §6:
14//!
15//! | type                              | boundaries | semantics.values | material.values | texture.values |
16//! |-----------------------------------|-----------:|-----------------:|----------------:|---------------:|
17//! | `MultiPoint`                      |          1 |                1 |     *forbidden* |    *forbidden* |
18//! | `MultiLineString`                 |          2 |                1 |     *forbidden* |    *forbidden* |
19//! | `MultiSurface`, `CompositeSurface`|          3 |                1 |               1 |              3 |
20//! | `Solid`                           |          4 |                2 |               2 |              4 |
21//! | `MultiSolid`, `CompositeSolid`    |          5 |                3 |               3 |              5 |
22//!
23//! `MultiPoint` and `MultiLineString` name neither `material` nor `texture` in
24//! their schema and declare `additionalProperties: false`, so a material or
25//! texture on one of them is not valid CityJSON and has no depth to decode to.
26
27use cjseq::{
28    GeometryType as CjGeometryType, MaterialReference as CjMaterialReference,
29    MaterialValues as CjMaterialValues, Ring, SemanticSurfaceType, Semantics, SemanticsSurface,
30    SemanticsValues, Shell, Surface, TextureReference as CjTextureReference,
31    TextureValues as CjTextureValues, TexturedRing, TexturedShell, TexturedSurface,
32};
33
34use crate::error::Error;
35use crate::fb::{
36    Column, GeometryType, MaterialMapping, SemanticObject, SemanticSurfaceType as FbSurfaceType,
37    TextureMapping,
38};
39use std::collections::HashMap;
40
41use super::deserializer::decode_attributes;
42
43/// The wire spelling of "no value here"; see `writer::geom_encoder::NULL`.
44const NULL: u32 = u32::MAX;
45
46fn index(v: u32) -> Option<usize> {
47    if v == NULL {
48        None
49    } else {
50        Some(v as usize)
51    }
52}
53
54/// A cursor over the flattened boundary arrays. Each `take_*` consumes exactly
55/// as much as the level above asked for, so the same arrays rebuild any depth.
56struct BoundaryCursor<'a> {
57    shells: &'a [u32],
58    surfaces: &'a [u32],
59    strings: &'a [u32],
60    indices: &'a [u32],
61    shell_cursor: usize,
62    surface_cursor: usize,
63    string_cursor: usize,
64    index_cursor: usize,
65}
66
67impl<'a> BoundaryCursor<'a> {
68    fn new(shells: &'a [u32], surfaces: &'a [u32], strings: &'a [u32], indices: &'a [u32]) -> Self {
69        BoundaryCursor {
70            shells,
71            surfaces,
72            strings,
73            indices,
74            shell_cursor: 0,
75            surface_cursor: 0,
76            string_cursor: 0,
77            index_cursor: 0,
78        }
79    }
80
81    fn take_ring(&mut self) -> Ring {
82        let size = self.strings.get(self.string_cursor).copied().unwrap_or(0) as usize;
83        self.string_cursor += 1;
84        let end = (self.index_cursor + size).min(self.indices.len());
85        let ring = self.indices[self.index_cursor..end]
86            .iter()
87            .map(|&i| i as usize)
88            .collect();
89        self.index_cursor = end;
90        ring
91    }
92
93    fn take_surface(&mut self) -> Surface {
94        let rings = self.surfaces.get(self.surface_cursor).copied().unwrap_or(0);
95        self.surface_cursor += 1;
96        (0..rings).map(|_| self.take_ring()).collect()
97    }
98
99    fn take_shell(&mut self) -> Shell {
100        let surfaces = self.shells.get(self.shell_cursor).copied().unwrap_or(0);
101        self.shell_cursor += 1;
102        (0..surfaces).map(|_| self.take_surface()).collect()
103    }
104}
105
106/// `MultiPoint`: every index is a point of the one and only ring.
107pub(crate) fn decode_points(indices: &[u32]) -> Ring {
108    indices.iter().map(|&i| i as usize).collect()
109}
110
111/// `MultiLineString`: one ring per `strings` entry.
112pub(crate) fn decode_rings(strings: &[u32], indices: &[u32]) -> Vec<Ring> {
113    let mut cursor = BoundaryCursor::new(&[], &[], strings, indices);
114    (0..strings.len()).map(|_| cursor.take_ring()).collect()
115}
116
117/// `MultiSurface`, `CompositeSurface`: one surface per `surfaces` entry.
118pub(crate) fn decode_surfaces(surfaces: &[u32], strings: &[u32], indices: &[u32]) -> Vec<Surface> {
119    let mut cursor = BoundaryCursor::new(&[], surfaces, strings, indices);
120    (0..surfaces.len()).map(|_| cursor.take_surface()).collect()
121}
122
123/// `Solid`: one shell per `shells` entry.
124pub(crate) fn decode_shells(
125    shells: &[u32],
126    surfaces: &[u32],
127    strings: &[u32],
128    indices: &[u32],
129) -> Vec<Shell> {
130    let mut cursor = BoundaryCursor::new(shells, surfaces, strings, indices);
131    (0..shells.len()).map(|_| cursor.take_shell()).collect()
132}
133
134/// `MultiSolid`, `CompositeSolid`: `solids[i]` shells in the i-th solid.
135pub(crate) fn decode_solids(
136    solids: &[u32],
137    shells: &[u32],
138    surfaces: &[u32],
139    strings: &[u32],
140    indices: &[u32],
141) -> Vec<Vec<Shell>> {
142    let mut cursor = BoundaryCursor::new(shells, surfaces, strings, indices);
143    solids
144        .iter()
145        .map(|&n| (0..n).map(|_| cursor.take_shell()).collect())
146        .collect()
147}
148
149/// Converts FlatBuffers semantic surface objects into CityJSON semantic
150/// surfaces.
151pub(crate) fn decode_semantics_surfaces(
152    semantics_objects: &[SemanticObject],
153    semantic_attr_schema: Option<flatbuffers::Vector<'_, flatbuffers::ForwardsUOffset<Column<'_>>>>,
154) -> Vec<SemanticsSurface> {
155    let surfaces = semantics_objects.iter().map(|s| {
156        let thetype = to_cj_surface_type(s.type_(), s.extension_type());
157
158        let children = s
159            .children()
160            .map(|c| c.iter().map(|i| i as usize).collect::<Vec<_>>());
161
162        let attributes = semantic_attr_schema
163            .as_ref()
164            .and_then(|schema| s.attributes().map(|a| decode_attributes(schema, a)));
165
166        // `other` is the catch-all for members the schema does not name; the
167        // encoded attribute blob is exactly that set.
168        let other = attributes
169            .and_then(|v| match v {
170                serde_json::Value::Object(map) => Some(map.into_iter().collect()),
171                _ => None,
172            })
173            .unwrap_or_default();
174
175        SemanticsSurface {
176            thetype,
177            parent: s.parent().map(|p| p as usize),
178            children,
179            other,
180        }
181    });
182    surfaces.collect()
183}
184
185/// The CityJSON spelling of a FlatBuffers semantic surface type.
186///
187/// `ExtraSemanticSurface` carries its CityJSON name in `extension_type`, which
188/// the spec requires to start with `+`.
189///
190/// UNKNOWN-TAG POLICY, second of three sites (see [`GeometryType::to_cj`]).
191/// Unlike a geometry type, a semantic surface type DOES have an extension
192/// escape hatch -- CityJSON § 3.3 says "it is possible to define and use other
193/// semantics, but these have to start with a `+`" -- so a tag with no usable
194/// `extension_type` still has a schema-valid spelling available, and this
195/// emits one rather than erroring. `"+GenericSurface"` and not
196/// `"ExtraSemanticSurface"`: the latter is the FlatBuffers enumerator name,
197/// is not a CityJSON surface type, and carries no `+`, so a document
198/// containing it fails validation. The C++ reader emits the same string.
199pub(crate) fn to_cj_surface_type(
200    surface_type: FbSurfaceType,
201    extension_type: Option<&str>,
202) -> SemanticSurfaceType {
203    match surface_type {
204        FbSurfaceType::RoofSurface => SemanticSurfaceType::RoofSurface,
205        FbSurfaceType::GroundSurface => SemanticSurfaceType::GroundSurface,
206        FbSurfaceType::WallSurface => SemanticSurfaceType::WallSurface,
207        FbSurfaceType::ClosureSurface => SemanticSurfaceType::ClosureSurface,
208        FbSurfaceType::OuterCeilingSurface => SemanticSurfaceType::OuterCeilingSurface,
209        FbSurfaceType::OuterFloorSurface => SemanticSurfaceType::OuterFloorSurface,
210        FbSurfaceType::Window => SemanticSurfaceType::Window,
211        FbSurfaceType::Door => SemanticSurfaceType::Door,
212        FbSurfaceType::InteriorWallSurface => SemanticSurfaceType::InteriorWallSurface,
213        FbSurfaceType::CeilingSurface => SemanticSurfaceType::CeilingSurface,
214        FbSurfaceType::FloorSurface => SemanticSurfaceType::FloorSurface,
215        FbSurfaceType::WaterSurface => SemanticSurfaceType::WaterSurface,
216        FbSurfaceType::WaterGroundSurface => SemanticSurfaceType::WaterGroundSurface,
217        FbSurfaceType::WaterClosureSurface => SemanticSurfaceType::WaterClosureSurface,
218        FbSurfaceType::TrafficArea => SemanticSurfaceType::TrafficArea,
219        FbSurfaceType::AuxiliaryTrafficArea => SemanticSurfaceType::AuxiliaryTrafficArea,
220        FbSurfaceType::TransportationMarking => SemanticSurfaceType::TransportationMarking,
221        FbSurfaceType::TransportationHole => SemanticSurfaceType::TransportationHole,
222        // ExtraSemanticSurface, and any tag a newer writer may add.
223        _ => {
224            SemanticSurfaceType::Extension(extension_type.unwrap_or("+GenericSurface").to_string())
225        }
226    }
227}
228
229/// Regroups the flat run of semantic values at the depth `geometry_type`
230/// implies. `semantics.values` is nested one level less deeply than the
231/// geometry's boundaries, so a `Solid` groups by shell and the 5-deep types
232/// group by shell and then by solid.
233///
234/// The group sizes come from the *boundary* count arrays, because a semantics
235/// mapping carries none of its own — one semantic value per surface is the
236/// whole of its structure.
237pub(crate) fn decode_semantics(
238    solids: &[u32],
239    shells: &[u32],
240    geometry_type: GeometryType,
241    semantics_objects: Vec<SemanticObject>,
242    semantics_values: Option<Vec<u32>>,
243    semantic_attr_schema: Option<flatbuffers::Vector<'_, flatbuffers::ForwardsUOffset<Column<'_>>>>,
244) -> Semantics {
245    let surfaces = decode_semantics_surfaces(&semantics_objects, semantic_attr_schema);
246
247    // No values vector at all is `"values": null` -- a member whose value is
248    // null, which the schema requires to be present and permits to be null.
249    let Some(semantics_values) = semantics_values else {
250        return Semantics {
251            surfaces,
252            values: None,
253            other: HashMap::new(),
254        };
255    };
256
257    let mut cursor = 0usize;
258    let mut take_shell = |n: usize, values: &[u32]| -> Vec<Option<usize>> {
259        let end = (cursor + n).min(values.len());
260        let out = values[cursor..end].iter().map(|&v| index(v)).collect();
261        cursor = end;
262        out
263    };
264
265    let values = match geometry_type {
266        // One value per surface, flat.
267        GeometryType::MultiPoint
268        | GeometryType::MultiLineString
269        | GeometryType::MultiSurface
270        | GeometryType::CompositeSurface => {
271            SemanticsValues::Surfaces(semantics_values.iter().map(|&v| index(v)).collect())
272        }
273        // One array per shell.
274        GeometryType::Solid => SemanticsValues::Shells(
275            shells
276                .iter()
277                .map(|&n| Some(take_shell(n as usize, &semantics_values)))
278                .collect(),
279        ),
280        // One array per shell, per solid.
281        GeometryType::MultiSolid | GeometryType::CompositeSolid => {
282            let mut shell_cursor = 0usize;
283            SemanticsValues::Solids(
284                solids
285                    .iter()
286                    .map(|&shell_count| {
287                        Some(
288                            (0..shell_count)
289                                .map(|_| {
290                                    let n = shells.get(shell_cursor).copied().unwrap_or(0) as usize;
291                                    shell_cursor += 1;
292                                    Some(take_shell(n, &semantics_values))
293                                })
294                                .collect(),
295                        )
296                    })
297                    .collect(),
298            )
299        }
300        // A GeometryInstance carries no semantics; its template does.
301        _ => SemanticsValues::Surfaces(semantics_values.iter().map(|&v| index(v)).collect()),
302    };
303
304    Semantics {
305        surfaces,
306        values: Some(values),
307        other: HashMap::new(),
308    }
309}
310
311/// UNKNOWN-TAG POLICY, first of three sites. See `to_cj_surface_type` and
312/// `deserializer::to_cj_co_type` for the other two.
313///
314/// A geometry type is the one of the three that has NO extension escape
315/// hatch: CityJSON § 3 enumerates exactly eight `type` values and
316/// `geomprimitives.schema.json` admits no others, so unlike a City Object or
317/// a semantic surface there is no `"+Something"` a reader could legally emit
318/// for a tag it does not recognise. That leaves two options, and only two:
319/// mislabel the geometry as one of the eight, or refuse the file.
320///
321/// This errors. A tag outside the eight means the file was written by a newer
322/// or a broken encoder, and calling such a geometry a `Solid` -- which is
323/// what this used to do -- decodes its boundaries at the wrong depth and hands
324/// the caller a plausible-looking lie. That is the same reasoning behind
325/// [`crate::error::Error::UnknownEnumTag`] for `wrapMode` and `textureType`,
326/// and the C++ reader's `geometry_type_name` has always thrown here; the two
327/// readers now agree.
328impl GeometryType {
329    pub fn to_str(self) -> Result<&'static str, Error> {
330        Ok(match self {
331            Self::MultiPoint => "MultiPoint",
332            Self::MultiLineString => "MultiLineString",
333            Self::MultiSurface => "MultiSurface",
334            Self::CompositeSurface => "CompositeSurface",
335            Self::Solid => "Solid",
336            Self::MultiSolid => "MultiSolid",
337            Self::CompositeSolid => "CompositeSolid",
338            Self::GeometryInstance => "GeometryInstance",
339            other => return Err(Error::UnknownEnumTag("GeometryType", format!("{other:?}"))),
340        })
341    }
342
343    pub fn to_cj(self) -> Result<CjGeometryType, Error> {
344        Ok(match self {
345            Self::MultiPoint => CjGeometryType::MultiPoint,
346            Self::MultiLineString => CjGeometryType::MultiLineString,
347            Self::MultiSurface => CjGeometryType::MultiSurface,
348            Self::CompositeSurface => CjGeometryType::CompositeSurface,
349            Self::Solid => CjGeometryType::Solid,
350            Self::MultiSolid => CjGeometryType::MultiSolid,
351            Self::CompositeSolid => CjGeometryType::CompositeSolid,
352            Self::GeometryInstance => CjGeometryType::GeometryInstance,
353            other => return Err(Error::UnknownEnumTag("GeometryType", format!("{other:?}"))),
354        })
355    }
356}
357
358/// Rebuilds `material.values` at the depth `geometry_type` implies.
359///
360/// `material.values` is nested two levels less deeply than the boundaries, so
361/// a `MultiSurface` gets one index per surface, a `Solid` one array per shell,
362/// and a `MultiSolid`/`CompositeSolid` one array per shell per solid.
363///
364/// A `NULL` entry in `shells`/`solids` is a whole `null` shell or solid, which
365/// `material.values` permits at every level; it comes back as `None`, never as
366/// an empty array.
367pub(crate) fn decode_materials(
368    geometry_type: GeometryType,
369    material_mappings: &[MaterialMapping],
370) -> Option<HashMap<String, CjMaterialReference>> {
371    if material_mappings.is_empty() {
372        return None;
373    }
374
375    let mut materials = HashMap::new();
376
377    for mapping in material_mappings {
378        let theme = mapping.theme().unwrap_or("theme").to_string();
379
380        // A `value` colours the whole object and has no depth at all.
381        if let Some(value) = mapping.value() {
382            materials.insert(
383                theme,
384                CjMaterialReference {
385                    value: Some(value as usize),
386                    values: None,
387                    other: HashMap::new(),
388                },
389            );
390            continue;
391        }
392
393        let solids = mapping
394            .solids()
395            .map(|s| s.iter().collect::<Vec<_>>())
396            .unwrap_or_default();
397        let shells = mapping
398            .shells()
399            .map(|s| s.iter().collect::<Vec<_>>())
400            .unwrap_or_default();
401        // No `vertices` vector at all is `"values": null` — an explicit null,
402        // which the schema distinguishes from an absent `values`.
403        let Some(vertices) = mapping.vertices().map(|v| v.iter().collect::<Vec<_>>()) else {
404            materials.insert(
405                theme,
406                CjMaterialReference {
407                    value: None,
408                    values: Some(None),
409                    other: HashMap::new(),
410                },
411            );
412            continue;
413        };
414
415        let mut vertex_cursor = 0usize;
416        let mut take_shell = |n: usize| -> Vec<Option<usize>> {
417            let end = (vertex_cursor + n).min(vertices.len());
418            let out = vertices[vertex_cursor..end]
419                .iter()
420                .map(|&v| index(v))
421                .collect();
422            vertex_cursor = end;
423            out
424        };
425
426        let values = match geometry_type {
427            // One index per surface.
428            GeometryType::MultiSurface | GeometryType::CompositeSurface => {
429                CjMaterialValues::Surfaces(vertices.iter().map(|&v| index(v)).collect())
430            }
431            // One array per shell.
432            GeometryType::Solid => CjMaterialValues::Shells(
433                shells
434                    .iter()
435                    .map(|&n| {
436                        if n == NULL {
437                            None
438                        } else {
439                            Some(take_shell(n as usize))
440                        }
441                    })
442                    .collect(),
443            ),
444            // One array per shell, per solid.
445            GeometryType::MultiSolid | GeometryType::CompositeSolid => {
446                let mut shell_cursor = 0usize;
447                CjMaterialValues::Solids(
448                    solids
449                        .iter()
450                        .map(|&shell_count| {
451                            if shell_count == NULL {
452                                return None;
453                            }
454                            Some(
455                                (0..shell_count)
456                                    .map(|_| {
457                                        let n = shells.get(shell_cursor).copied().unwrap_or(0);
458                                        shell_cursor += 1;
459                                        if n == NULL {
460                                            None
461                                        } else {
462                                            Some(take_shell(n as usize))
463                                        }
464                                    })
465                                    .collect(),
466                            )
467                        })
468                        .collect(),
469                )
470            }
471            // MultiPoint, MultiLineString and GeometryInstance cannot carry a
472            // material; if one is somehow present it has no depth of its own,
473            // so it is read as the shallowest thing it could be.
474            _ => CjMaterialValues::Surfaces(vertices.iter().map(|&v| index(v)).collect()),
475        };
476
477        materials.insert(
478            theme,
479            CjMaterialReference {
480                value: None,
481                values: Some(Some(values)),
482                other: HashMap::new(),
483            },
484        );
485    }
486
487    Some(materials)
488}
489
490/// Rebuilds `texture.values` at the depth `geometry_type` implies.
491///
492/// `texture.values` is nested exactly as deeply as the geometry's boundaries,
493/// each ring becoming `[texture_index, uv_index, ...]`. Unlike a material, only
494/// the leaf is nullable — the schema types every intermediate level as a plain
495/// `"array"` — so nothing here decodes an intermediate `null`.
496pub(crate) fn decode_textures(
497    geometry_type: GeometryType,
498    texture_mappings: &[TextureMapping],
499) -> Option<HashMap<String, CjTextureReference>> {
500    if texture_mappings.is_empty() {
501        return None;
502    }
503
504    let mut textures = HashMap::new();
505
506    for mapping in texture_mappings {
507        let theme = mapping.theme().unwrap_or("theme").to_string();
508
509        // A theme may legally carry no `values` at all, and is then written
510        // with no arrays — distinct from a theme whose `values` is empty.
511        let Some(vertices) = mapping.vertices().map(|v| v.iter().collect::<Vec<_>>()) else {
512            textures.insert(
513                theme,
514                CjTextureReference {
515                    values: None,
516                    other: HashMap::new(),
517                },
518            );
519            continue;
520        };
521        let solids = mapping
522            .solids()
523            .map(|s| s.iter().collect::<Vec<_>>())
524            .unwrap_or_default();
525        let shells = mapping
526            .shells()
527            .map(|s| s.iter().collect::<Vec<_>>())
528            .unwrap_or_default();
529        let surfaces = mapping
530            .surfaces()
531            .map(|s| s.iter().collect::<Vec<_>>())
532            .unwrap_or_default();
533        let strings = mapping
534            .strings()
535            .map(|s| s.iter().collect::<Vec<_>>())
536            .unwrap_or_default();
537
538        let mut cursor = TextureCursor {
539            surfaces: &surfaces,
540            shells: &shells,
541            strings: &strings,
542            vertices: &vertices,
543            shell_cursor: 0,
544            surface_cursor: 0,
545            string_cursor: 0,
546            vertex_cursor: 0,
547        };
548
549        let values = match geometry_type {
550            // Per surface, per ring.
551            GeometryType::MultiSurface | GeometryType::CompositeSurface => {
552                CjTextureValues::Surface(
553                    (0..surfaces.len()).map(|_| cursor.take_surface()).collect(),
554                )
555            }
556            // ... per shell.
557            GeometryType::Solid => {
558                CjTextureValues::Shell((0..shells.len()).map(|_| cursor.take_shell()).collect())
559            }
560            // ... per solid.
561            GeometryType::MultiSolid | GeometryType::CompositeSolid => CjTextureValues::Solid(
562                solids
563                    .iter()
564                    .map(|&n| (0..n).map(|_| cursor.take_shell()).collect())
565                    .collect(),
566            ),
567            // MultiPoint, MultiLineString and GeometryInstance cannot carry a
568            // texture; read whatever is there at the shallowest legal depth.
569            _ => CjTextureValues::Surface(
570                (0..surfaces.len().max(1))
571                    .map(|_| cursor.take_surface())
572                    .collect(),
573            ),
574        };
575
576        textures.insert(
577            theme,
578            CjTextureReference {
579                values: Some(values),
580                other: HashMap::new(),
581            },
582        );
583    }
584
585    Some(textures)
586}
587
588/// The texture equivalent of [`BoundaryCursor`]: the same four count arrays,
589/// but the leaf holds `[texture_index, uv_index, ...]` rather than vertex
590/// indices, and is nullable.
591struct TextureCursor<'a> {
592    surfaces: &'a [u32],
593    shells: &'a [u32],
594    strings: &'a [u32],
595    vertices: &'a [u32],
596    shell_cursor: usize,
597    surface_cursor: usize,
598    string_cursor: usize,
599    vertex_cursor: usize,
600}
601
602impl TextureCursor<'_> {
603    fn take_ring(&mut self) -> TexturedRing {
604        let size = self.strings.get(self.string_cursor).copied().unwrap_or(0) as usize;
605        self.string_cursor += 1;
606        let end = (self.vertex_cursor + size).min(self.vertices.len());
607        let ring = self.vertices[self.vertex_cursor..end]
608            .iter()
609            .map(|&v| index(v))
610            .collect();
611        self.vertex_cursor = end;
612        ring
613    }
614
615    fn take_surface(&mut self) -> TexturedSurface {
616        let rings = self.surfaces.get(self.surface_cursor).copied().unwrap_or(0);
617        self.surface_cursor += 1;
618        (0..rings).map(|_| self.take_ring()).collect()
619    }
620
621    fn take_shell(&mut self) -> TexturedShell {
622        let surfaces = self.shells.get(self.shell_cursor).copied().unwrap_or(0);
623        self.shell_cursor += 1;
624        (0..surfaces).map(|_| self.take_surface()).collect()
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::fb::geometry_generated::{
632        MaterialMappingArgs, TextureMapping as FbTextureMapping, TextureMappingArgs,
633    };
634    use anyhow::Result;
635    use flatbuffers::FlatBufferBuilder;
636    use pretty_assertions::assert_eq;
637    use serde_json::json;
638
639    /// Builds one FlatBuffers `MaterialMapping` from the raw arrays and runs it
640    /// through `decode_materials` at `geometry_type`, returning the decoded
641    /// `values` as JSON so expectations read as CityJSON.
642    fn decode_material_values(
643        geometry_type: GeometryType,
644        solids: &[u32],
645        shells: &[u32],
646        vertices: &[u32],
647    ) -> serde_json::Value {
648        let mut fbb = FlatBufferBuilder::new();
649        let theme = fbb.create_string("t");
650        let solids_v = (!solids.is_empty()).then(|| fbb.create_vector(solids));
651        let shells_v = (!shells.is_empty()).then(|| fbb.create_vector(shells));
652        let vertices_v = fbb.create_vector(vertices);
653        let mapping = MaterialMapping::create(
654            &mut fbb,
655            &MaterialMappingArgs {
656                theme: Some(theme),
657                solids: solids_v,
658                shells: shells_v,
659                vertices: Some(vertices_v),
660                value: None,
661            },
662        );
663        fbb.finish(mapping, None);
664        let buf = fbb.finished_data().to_vec();
665        let mapping = flatbuffers::root::<MaterialMapping>(&buf).expect("valid mapping");
666        let decoded = decode_materials(geometry_type, &[mapping]).expect("one theme");
667        serde_json::to_value(&decoded["t"].values).expect("values serialize")
668    }
669
670    fn decode_texture_values(
671        geometry_type: GeometryType,
672        solids: &[u32],
673        shells: &[u32],
674        surfaces: &[u32],
675        strings: &[u32],
676        vertices: &[u32],
677    ) -> serde_json::Value {
678        let mut fbb = FlatBufferBuilder::new();
679        let theme = fbb.create_string("t");
680        let solids_v = fbb.create_vector(solids);
681        let shells_v = fbb.create_vector(shells);
682        let surfaces_v = fbb.create_vector(surfaces);
683        let strings_v = fbb.create_vector(strings);
684        let vertices_v = fbb.create_vector(vertices);
685        let mapping = FbTextureMapping::create(
686            &mut fbb,
687            &TextureMappingArgs {
688                theme: Some(theme),
689                solids: Some(solids_v),
690                shells: Some(shells_v),
691                surfaces: Some(surfaces_v),
692                strings: Some(strings_v),
693                vertices: Some(vertices_v),
694            },
695        );
696        fbb.finish(mapping, None);
697        let buf = fbb.finished_data().to_vec();
698        let mapping = flatbuffers::root::<FbTextureMapping>(&buf).expect("valid mapping");
699        let decoded = decode_textures(geometry_type, &[mapping]).expect("one theme");
700        serde_json::to_value(&decoded["t"].values).expect("values serialize")
701    }
702
703    #[test]
704    fn test_decode_boundaries() -> Result<()> {
705        // MultiPoint
706        assert_eq!(decode_points(&[2, 44, 0, 7]), vec![2, 44, 0, 7]);
707
708        // MultiLineString
709        assert_eq!(
710            serde_json::to_value(decode_rings(&[3, 3], &[2, 3, 5, 77, 55, 212]))?,
711            json!([[2, 3, 5], [77, 55, 212]])
712        );
713
714        // MultiSurface
715        assert_eq!(
716            serde_json::to_value(decode_surfaces(
717                &[1, 1, 1],
718                &[4, 4, 4],
719                &[0, 3, 2, 1, 4, 5, 6, 7, 0, 1, 5, 4]
720            ))?,
721            json!([[[0, 3, 2, 1]], [[4, 5, 6, 7]], [[0, 1, 5, 4]]])
722        );
723
724        // Solid
725        let indices = [
726            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,
727            246, 724, 34, 414, 45, 111, 246, 5,
728        ];
729        assert_eq!(
730            serde_json::to_value(decode_shells(
731                &[4, 4],
732                &[2, 1, 1, 1, 1, 1, 1, 1],
733                &[5, 4, 4, 4, 4, 3, 3, 3, 3],
734                &indices
735            ))?,
736            json!([
737                [
738                    [[0, 3, 2, 1, 22], [1, 2, 3, 4]],
739                    [[4, 5, 6, 7]],
740                    [[0, 1, 5, 4]],
741                    [[1, 2, 6, 5]]
742                ],
743                [
744                    [[240, 243, 124]],
745                    [[244, 246, 724]],
746                    [[34, 414, 45]],
747                    [[111, 246, 5]]
748                ]
749            ])
750        );
751
752        // CompositeSolid
753        let indices = [
754            0, 3, 2, 1, 22, 4, 5, 6, 7, 0, 1, 5, 4, 1, 2, 6, 5, 240, 243, 124, 244, 246, 724, 34,
755            414, 45, 111, 246, 5, 666, 667, 668, 74, 75, 76, 880, 881, 885, 111, 122, 226,
756        ];
757        assert_eq!(
758            serde_json::to_value(decode_solids(
759                &[2, 1],
760                &[4, 4, 4],
761                &[1; 12],
762                &[5, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 3],
763                &indices
764            ))?,
765            json!([
766                [
767                    [
768                        [[0, 3, 2, 1, 22]],
769                        [[4, 5, 6, 7]],
770                        [[0, 1, 5, 4]],
771                        [[1, 2, 6, 5]]
772                    ],
773                    [
774                        [[240, 243, 124]],
775                        [[244, 246, 724]],
776                        [[34, 414, 45]],
777                        [[111, 246, 5]]
778                    ]
779                ],
780                [[
781                    [[666, 667, 668]],
782                    [[74, 75, 76]],
783                    [[880, 881, 885]],
784                    [[111, 122, 226]]
785                ]]
786            ])
787        );
788
789        Ok(())
790    }
791
792    /// A `Solid` and a one-solid `MultiSolid` flatten to byte-identical
793    /// material arrays. Only the geometry type tells them apart, and it must:
794    /// this is finding #8 in one assertion.
795    #[test]
796    fn identical_material_arrays_decode_to_different_depths_per_type() {
797        let (solids, shells, vertices) = (&[1u32][..], &[2u32][..], &[0u32, 1][..]);
798
799        assert_eq!(
800            decode_material_values(GeometryType::Solid, solids, shells, vertices),
801            json!([[0, 1]]),
802            "a Solid's material values are one array per shell"
803        );
804        assert_eq!(
805            decode_material_values(GeometryType::MultiSolid, solids, shells, vertices),
806            json!([[[0, 1]]]),
807            "a MultiSolid's are one array per shell, per solid"
808        );
809        assert_eq!(
810            decode_material_values(GeometryType::CompositeSolid, solids, shells, vertices),
811            json!([[[0, 1]]]),
812            "a CompositeSolid decodes exactly as a MultiSolid does"
813        );
814    }
815
816    #[test]
817    fn test_decode_materials() -> Result<()> {
818        // A `value` colours the whole object, whatever the geometry type.
819        let mut fbb = FlatBufferBuilder::new();
820        let theme = fbb.create_string("theme1");
821        let mapping = MaterialMapping::create(
822            &mut fbb,
823            &MaterialMappingArgs {
824                theme: Some(theme),
825                value: Some(5),
826                ..Default::default()
827            },
828        );
829        fbb.finish(mapping, None);
830        let buf = fbb.finished_data().to_vec();
831        let mapping = flatbuffers::root::<MaterialMapping>(&buf)?;
832        let materials = decode_materials(GeometryType::Solid, &[mapping]).expect("one theme");
833        assert_eq!(materials["theme1"].value, Some(5));
834        assert!(materials["theme1"].values.is_none());
835
836        // MultiSurface: one index per surface.
837        assert_eq!(
838            decode_material_values(GeometryType::MultiSurface, &[], &[], &[0, 1, NULL, 2]),
839            json!([0, 1, null, 2])
840        );
841        assert_eq!(
842            decode_material_values(GeometryType::CompositeSurface, &[], &[], &[0, 1, NULL, 2]),
843            json!([0, 1, null, 2])
844        );
845
846        // Solid: one array per shell.
847        assert_eq!(
848            decode_material_values(GeometryType::Solid, &[2], &[3, 3], &[0, 1, NULL, 2, 3, 4]),
849            json!([[0, 1, null], [2, 3, 4]])
850        );
851
852        // CompositeSolid: one array per shell, per solid.
853        assert_eq!(
854            decode_material_values(
855                GeometryType::CompositeSolid,
856                &[2, 1],
857                &[3, 3, 3],
858                &[0, 1, NULL, 2, NULL, NULL, 3, 4, NULL]
859            ),
860            json!([[[0, 1, null], [2, null, null]], [[3, 4, null]]])
861        );
862
863        Ok(())
864    }
865
866    /// `material.values` is nullable at every level. A `NULL` count is a whole
867    /// `null` shell or solid and must come back as `null`, never as `[]`
868    /// (finding #7).
869    #[test]
870    fn a_null_material_shell_or_solid_decodes_as_null() {
871        assert_eq!(
872            decode_material_values(GeometryType::Solid, &[2], &[2, NULL], &[0, 1]),
873            json!([[0, 1], null])
874        );
875        assert_eq!(
876            decode_material_values(GeometryType::CompositeSolid, &[1, NULL], &[2], &[0, 1]),
877            json!([[[0, 1]], null])
878        );
879    }
880
881    #[test]
882    fn test_decode_textures() -> Result<()> {
883        // MultiSurface: per surface, per ring.
884        assert_eq!(
885            decode_texture_values(
886                GeometryType::MultiSurface,
887                &[],
888                &[3],
889                &[1, 1, 1],
890                &[4, 4, 4],
891                &[0, 10, 20, 30, 1, 11, 21, NULL, 2, 12, NULL, 32]
892            ),
893            json!([[[0, 10, 20, 30]], [[1, 11, 21, null]], [[2, 12, null, 32]]])
894        );
895
896        // Solid: ... per shell.
897        assert_eq!(
898            decode_texture_values(
899                GeometryType::Solid,
900                &[2],
901                &[3, 2],
902                &[1, 1, 1, 1, 1],
903                &[4, 4, 4, 4, 4],
904                &[0, 10, 20, 30, 1, 11, 21, NULL, 2, 12, NULL, 32, 3, 13, 23, 33, 4, 14, 24, NULL]
905            ),
906            json!([
907                [[[0, 10, 20, 30]], [[1, 11, 21, null]], [[2, 12, null, 32]]],
908                [[[3, 13, 23, 33]], [[4, 14, 24, null]]]
909            ])
910        );
911
912        // CompositeSolid: ... per solid.
913        assert_eq!(
914            decode_texture_values(
915                GeometryType::CompositeSolid,
916                &[2, 1],
917                &[2, 2, 2],
918                &[1; 6],
919                &[3; 6],
920                &[0, 10, 20, 1, 11, NULL, 2, 12, 22, 3, NULL, 23, 4, 14, 24, 5, 15, 25]
921            ),
922            json!([
923                [
924                    [[[0, 10, 20]], [[1, 11, null]]],
925                    [[[2, 12, 22]], [[3, null, 23]]]
926                ],
927                [[[[4, 14, 24]], [[5, 15, 25]]]]
928            ])
929        );
930
931        Ok(())
932    }
933
934    /// The texture equivalent of the material assertion above: a `Solid` and a
935    /// one-solid `MultiSolid` emit identical texture arrays.
936    #[test]
937    fn identical_texture_arrays_decode_to_different_depths_per_type() {
938        let args = (
939            &[1u32][..],
940            &[1u32][..],
941            &[1u32][..],
942            &[3u32][..],
943            &[0u32, 10, 20][..],
944        );
945        assert_eq!(
946            decode_texture_values(GeometryType::Solid, args.0, args.1, args.2, args.3, args.4),
947            json!([[[[0, 10, 20]]]])
948        );
949        assert_eq!(
950            decode_texture_values(
951                GeometryType::MultiSolid,
952                args.0,
953                args.1,
954                args.2,
955                args.3,
956                args.4
957            ),
958            json!([[[[[0, 10, 20]]]]])
959        );
960    }
961
962    // ---------------------------------------------------------------------
963    // UNKNOWN-TAG POLICY. One policy per tag, and the C++ reader agrees on
964    // all three (src/cpp/src/cityjson.cpp, src/cpp/src/geometry.cpp; pinned
965    // by test_cityjson.cpp and test_geometry.cpp).
966    // ---------------------------------------------------------------------
967
968    /// A geometry type has NO '+'-prefixed extension form -- CityJSON section 3
969    /// enumerates exactly eight `type` values -- so there is no schema-valid
970    /// string to fall back to and an unknown tag is an error. It used to
971    /// become a `Solid`, which reads the boundaries at the wrong depth and
972    /// hands the caller a plausible-looking lie.
973    #[test]
974    fn an_unknown_geometry_tag_is_an_error_and_never_a_solid() {
975        // every one of the eight still resolves
976        for (tag, name) in [
977            (GeometryType::MultiPoint, "MultiPoint"),
978            (GeometryType::MultiLineString, "MultiLineString"),
979            (GeometryType::MultiSurface, "MultiSurface"),
980            (GeometryType::CompositeSurface, "CompositeSurface"),
981            (GeometryType::Solid, "Solid"),
982            (GeometryType::MultiSolid, "MultiSolid"),
983            (GeometryType::CompositeSolid, "CompositeSolid"),
984            (GeometryType::GeometryInstance, "GeometryInstance"),
985        ] {
986            assert_eq!(tag.to_str().unwrap(), name);
987            assert!(tag.to_cj().is_ok());
988        }
989
990        // a tag past the eight is rejected, not silently renamed
991        let unknown = GeometryType(GeometryType::ENUM_MAX + 1);
992        assert!(
993            matches!(
994                unknown.to_str(),
995                Err(Error::UnknownEnumTag("GeometryType", _))
996            ),
997            "an unknown geometry tag must be reported, not spelled `Solid`"
998        );
999        assert!(matches!(
1000            unknown.to_cj(),
1001            Err(Error::UnknownEnumTag("GeometryType", _))
1002        ));
1003    }
1004
1005    /// A semantic surface type DOES have an extension form (section 3.3: "it
1006    /// is possible to define and use other semantics, but these have to start
1007    /// with a `+`"), so an unnameable tag gets a schema-valid placeholder
1008    /// rather than an error. Never `"ExtraSemanticSurface"`: that is the
1009    /// FlatBuffers enumerator name, is not a CityJSON surface type, and
1010    /// carries no `+`.
1011    #[test]
1012    fn an_unnameable_semantic_surface_tag_becomes_a_plus_prefixed_extension() {
1013        // the extension_type string wins whenever it is there
1014        assert_eq!(
1015            to_cj_surface_type(FbSurfaceType::ExtraSemanticSurface, Some("+ThermalSurface")),
1016            SemanticSurfaceType::Extension("+ThermalSurface".to_string())
1017        );
1018
1019        for tag in [
1020            FbSurfaceType::ExtraSemanticSurface,
1021            FbSurfaceType(FbSurfaceType::ENUM_MAX + 1),
1022        ] {
1023            let SemanticSurfaceType::Extension(name) = to_cj_surface_type(tag, None) else {
1024                panic!("an unnameable surface tag must become an Extension");
1025            };
1026            assert_eq!(name, "+GenericSurface");
1027            assert!(
1028                name.starts_with('+'),
1029                "{name} must be a valid Extension name"
1030            );
1031            assert_ne!(name, "ExtraSemanticSurface");
1032        }
1033    }
1034}