Skip to main content

cjseq/
appearance.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5/// One shell's worth of material indices, one per surface. `None` — a whole
6/// shell with no material — is `null` in JSON, which
7/// `geomprimitives.schema.json` permits at every level of `material.values`
8/// (`"type": ["array", "null"]`).
9pub type MaterialShell = Vec<Option<usize>>;
10/// One solid's worth of material indices, one per surface, per shell.
11pub type MaterialSolid = Vec<Option<MaterialShell>>;
12
13/// The `values` array of a [`MaterialReference`]: one index into the
14/// document's `materials` palette per *surface*, so it is nested exactly two
15/// levels less deeply than that geometry's `boundaries` (CityJSON 2.0,
16/// section 6.1).
17///
18/// `null` is permitted at *every* level, not only at the leaf: a surface, a
19/// whole shell, or a whole solid may have no material. Every one of those
20/// `None`s must serialize back as `null` and never as `[]` — that is
21/// finding #7, and it is pinned by test at each level.
22///
23/// The variants are ordered shallowest-first; serde tries them in declaration
24/// order, so a value only reaches a deeper variant when the shallower ones
25/// cannot describe it.
26#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
27#[serde(untagged)]
28pub enum MaterialValues {
29    /// `MultiSurface`, `CompositeSurface`: one index per surface.
30    Surfaces(Vec<Option<usize>>),
31    /// `Solid`: one index per surface, per shell.
32    Shells(Vec<Option<MaterialShell>>),
33    /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per
34    /// solid.
35    Solids(Vec<Option<MaterialSolid>>),
36}
37
38impl MaterialValues {
39    /// Every material index, in document order, whatever the depth. The depth
40    /// is known from the variant, so no runtime inspection is needed; the
41    /// `flatten()`s skip `None` sub-arrays as well as `null` leaves.
42    pub(crate) fn indices_mut(&mut self) -> Box<dyn Iterator<Item = &mut usize> + '_> {
43        match self {
44            MaterialValues::Surfaces(v) => Box::new(v.iter_mut().flatten()),
45            MaterialValues::Shells(v) => Box::new(v.iter_mut().flatten().flatten().flatten()),
46            MaterialValues::Solids(v) => Box::new(
47                v.iter_mut()
48                    .flatten()
49                    .flatten()
50                    .flatten()
51                    .flatten()
52                    .flatten(),
53            ),
54        }
55    }
56}
57
58/// One ring's texture entry: the texture index first, then one UV-vertex
59/// index per vertex of the ring — so one entry more than the ring has
60/// vertices. `[null]` means the ring is not textured.
61pub type TexturedRing = Vec<Option<usize>>;
62/// One face's rings, exterior first, mirroring [`crate::Surface`].
63pub type TexturedSurface = Vec<TexturedRing>;
64/// One volume's bounding faces, mirroring [`crate::Shell`].
65pub type TexturedShell = Vec<TexturedSurface>;
66
67/// The `values` array of a [`TextureReference`]: nested exactly as deeply as
68/// the geometry's `boundaries` (CityJSON 2.0, section 6.2), with each ring
69/// becoming `[texture_index, uv_index, ...]` — one more entry than the ring
70/// has vertices. An untextured ring is `[null]`.
71///
72/// Only surface-bearing geometries can carry a texture, so the ladder starts
73/// at `MultiSurface` depth: a `MultiPoint` or `MultiLineString` has no rings
74/// to texture, and both declare `additionalProperties: false` without a
75/// `texture` member.
76///
77/// Unlike [`MaterialValues`], the intermediate levels are *not* nullable.
78/// `geomprimitives.schema.json` types `texture.values` and every one of its
79/// intermediate `items` as a plain `"array"` — only the innermost items are
80/// `["integer", "null"]` — so `[null, [[0, 10, 11]]]` is not valid CityJSON
81/// and is rejected here.
82///
83/// The variants are ordered shallowest-first, as in [`MaterialValues`].
84#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
85#[serde(untagged)]
86pub enum TextureValues {
87    /// `MultiSurface`, `CompositeSurface`: per surface, per ring.
88    Surface(Vec<TexturedSurface>),
89    /// `Solid`: ... per shell.
90    Shell(Vec<TexturedShell>),
91    /// `MultiSolid`, `CompositeSolid`: ... per solid.
92    Solid(Vec<Vec<TexturedShell>>),
93}
94
95impl TextureValues {
96    /// Every ring, in document order, whatever the depth. A ring is
97    /// `[texture_index, uv_index, ...]`, so the caller renumbers its first
98    /// entry against a different map than the rest.
99    pub(crate) fn rings_mut(&mut self) -> Box<dyn Iterator<Item = &mut TexturedRing> + '_> {
100        match self {
101            TextureValues::Surface(v) => Box::new(v.iter_mut().flatten()),
102            TextureValues::Shell(v) => Box::new(v.iter_mut().flatten().flatten()),
103            TextureValues::Solid(v) => Box::new(v.iter_mut().flatten().flatten().flatten()),
104        }
105    }
106}
107
108/// Keep an explicit `null` distinct from an absent member.
109///
110/// `serde_json` maps a JSON `null` onto the *outer* `None` of a plain
111/// `Option<Option<T>>`, which collapses `{"values": null}` and `{}` onto the
112/// same value and re-emits the first as the second. Deserializing the inner
113/// `Option<T>` and wrapping that in `Some` keeps the two apart.
114///
115/// This delegates entirely to the derived `Deserialize` of `T` — it writes no
116/// visitor — and it does not touch serialization, which stays fully derived.
117fn present_even_if_null<'de, T, D>(de: D) -> std::result::Result<Option<T>, D::Error>
118where
119    T: Deserialize<'de>,
120    D: serde::Deserializer<'de>,
121{
122    T::deserialize(de).map(Some)
123}
124
125/// One theme's material assignment for a geometry: either a `value` colouring
126/// the whole object, or a depth-typed `values` array with one index per
127/// surface.
128///
129/// The schema requires exactly one of the two
130/// (`oneOf: [{required: ["value"]}, {required: ["values"]}]`) but separately
131/// permits `"values": null`. So `values` is a *double* option: the outer level
132/// is present-vs-absent, the inner is `null`-vs-array. Without that
133/// distinction a schema-valid `{"values": null}` re-emits as `{}`, which
134/// satisfies neither branch of the `oneOf` and is rejected by a validator.
135#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
136pub struct MaterialReference {
137    #[serde(
138        default,
139        deserialize_with = "present_even_if_null",
140        skip_serializing_if = "Option::is_none"
141    )]
142    pub values: Option<Option<MaterialValues>>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub value: Option<usize>,
145    /// The per-theme material object names `value` and `values` and declares
146    /// no `additionalProperties: false`, so anything further is legal
147    /// CityJSON and is kept rather than dropped.
148    #[serde(flatten)]
149    pub other: HashMap<String, Value>,
150}
151
152/// One theme's texture assignment for a geometry.
153///
154/// `values` is optional: the per-theme texture object in
155/// `geomprimitives.schema.json` carries no `required` keyword at all (in
156/// contrast to `material`, which requires exactly one of `value`/`values`), so
157/// a bare `{"theme": {}}` is valid CityJSON.
158#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
159pub struct TextureReference {
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub values: Option<TextureValues>,
162    /// As with [`MaterialReference`]: the per-theme texture object declares no
163    /// `additionalProperties: false`, so extra members are legal and kept.
164    #[serde(flatten)]
165    pub other: HashMap<String, Value>,
166}
167
168/// A material or border colour: three numbers, per
169/// `appearance.schema.json`'s `"minItems": 3, "maxItems": 3` on
170/// `diffuseColor`, `emissiveColor` and `specularColor`.
171///
172/// A fixed-size array, not a `Vec`, so the cardinality is checked by serde's
173/// derived `Deserialize` rather than by a downstream `if len == 3`. The same
174/// technique already types `GeometryInstance::transformation_matrix` as
175/// `[f64; 16]`.
176pub type Color = [f64; 3];
177
178/// A texture's `borderColor`, whose schema is the one place a CityJSON colour
179/// is *not* fixed at three numbers:
180///
181/// ```text
182/// "borderColor": {
183///   "type": "array", "items": {"type": "number"},
184///   "minItems": 3, "maxItems": 4
185/// }
186/// ```
187///
188/// Three or four, and nothing else. A `Vec<f64>` would accept two or five and
189/// leave the check to a consumer that may not make it; the untagged pair of
190/// fixed-size arrays makes serde do it. Serialization is derived, and an
191/// untagged enum of arrays serializes as the array itself, so `[0, 0, 0, 1]`
192/// comes back spelled exactly as it went in.
193#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
194#[serde(untagged)]
195pub enum BorderColor {
196    /// `minItems: 3`
197    Rgb(Color),
198    /// `maxItems: 4`
199    Rgba([f64; 4]),
200}
201
202/// A texture image's file format: `"type": {"enum": ["PNG", "JPG"]}`.
203///
204/// Note the case: this member is spelled upper case where its `wrapMode` and
205/// `textureType` siblings are lower. Getting that wrong in a hand-written
206/// `match` is precisely the defect this type removes.
207///
208/// A closed enumeration in the schema -- unlike `CityObjectType` and
209/// `SemanticSurfaceType`, which carry an `Extension(String)` case because
210/// CityJSON Extensions may add to those two sets. Nothing in the spec lets an
211/// Extension add a texture format, so there is no such case here and an
212/// unrecognised spelling is an error.
213#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
214pub enum TextureFormat {
215    PNG,
216    JPG,
217}
218
219/// How a texture repeats outside `[0, 1]`:
220/// `"wrapMode": {"enum": ["none", "wrap", "mirror", "clamp", "border"]}`.
221///
222/// All five are lower case. A texture written `"wrapMode": "wrap"` once came
223/// back as `"None"` from FlatCityBuf because a downstream `match` expected a
224/// different spelling and its `_` arm turned the miss into a default; with the
225/// spellings here, the same mistake is a `serde` error at the boundary and a
226/// non-exhaustive `match` at every consumer.
227#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
228#[serde(rename_all = "lowercase")]
229pub enum WrapMode {
230    None,
231    Wrap,
232    Mirror,
233    Clamp,
234    Border,
235}
236
237/// What a texture depicts:
238/// `"textureType": {"enum": ["unknown", "specific", "typical"]}`. Lower case,
239/// and a closed set.
240#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
241#[serde(rename_all = "lowercase")]
242pub enum TextureType {
243    Unknown,
244    Specific,
245    Typical,
246}
247
248/// One entry of the document-level `appearance.materials` palette, referred to
249/// by index from a [`MaterialReference`].
250///
251/// `appearance.schema.json#/Material`, in full:
252///
253/// ```text
254/// "Material": {
255///   "type": "object",
256///   "properties": {
257///     "name": {"type": "string"},
258///     "ambientIntensity": {"type": "number"},
259///     "diffuseColor":  {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
260///     "emissiveColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
261///     "specularColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
262///     "shininess": {"type": "number"},
263///     "transparency": {"type": "number"},
264///     "isSmooth": {"type": "boolean"}
265///   },
266///   "required": ["name"],
267///   "additionalProperties": false
268/// }
269/// ```
270///
271/// So `name` is a plain `String` and everything else an `Option`, and there is
272/// no `#[serde(flatten)] other` catch-all: `additionalProperties: false` means
273/// an unnamed member is not CityJSON at all. `deny_unknown_fields` says so out
274/// loud rather than dropping it, which matters because dropping is silent data
275/// loss and, for the all-optional [`TextureObject`] below, an object of
276/// nothing but mis-spelled members would otherwise parse as an empty one.
277#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
278#[serde(deny_unknown_fields)]
279pub struct MaterialObject {
280    /// The schema's one required member.
281    pub name: String,
282    #[serde(rename = "ambientIntensity", skip_serializing_if = "Option::is_none")]
283    pub ambient_intensity: Option<f64>,
284    #[serde(rename = "diffuseColor", skip_serializing_if = "Option::is_none")]
285    pub diffuse_color: Option<Color>,
286    #[serde(rename = "emissiveColor", skip_serializing_if = "Option::is_none")]
287    pub emissive_color: Option<Color>,
288    #[serde(rename = "specularColor", skip_serializing_if = "Option::is_none")]
289    pub specular_color: Option<Color>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub shininess: Option<f64>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub transparency: Option<f64>,
294    #[serde(rename = "isSmooth", skip_serializing_if = "Option::is_none")]
295    pub is_smooth: Option<bool>,
296}
297
298/// One entry of the document-level `appearance.textures` palette, referred to
299/// by the first entry of each ring in a [`TextureValues`].
300///
301/// `appearance.schema.json#/Texture`, in full:
302///
303/// ```text
304/// "Texture": {
305///   "type": "object",
306///   "properties": {
307///     "type": {"enum": ["PNG", "JPG"]},
308///     "image": {"type": "string"},
309///     "wrapMode": {"enum": ["none", "wrap", "mirror", "clamp", "border"]},
310///     "textureType": {"enum": ["unknown", "specific", "typical"]},
311///     "borderColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 4}
312///   },
313///   "additionalProperties": false
314/// }
315/// ```
316///
317/// There is no `required` keyword, so *every* member is optional and a bare
318/// `{}` is valid CityJSON — including `image`, which FlatCityBuf's `.fbs`
319/// currently makes mandatory.
320#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
321#[serde(deny_unknown_fields)]
322pub struct TextureObject {
323    /// The image file format. Named `thetype` because `type` is a Rust
324    /// keyword, as in [`crate::CityJSONFeature`].
325    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
326    pub thetype: Option<TextureFormat>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub image: Option<String>,
329    #[serde(rename = "wrapMode", skip_serializing_if = "Option::is_none")]
330    pub wrap_mode: Option<WrapMode>,
331    #[serde(rename = "textureType", skip_serializing_if = "Option::is_none")]
332    pub texture_type: Option<TextureType>,
333    #[serde(rename = "borderColor", skip_serializing_if = "Option::is_none")]
334    pub border_color: Option<BorderColor>,
335}
336
337/// The document-level appearance library.
338///
339/// `cityjson.schema.json#/properties/appearance` names five members and then
340/// declares `"additionalProperties": false`, exactly as the [`MaterialObject`]
341/// and [`TextureObject`] it contains do —
342/// `cityjsonfeature.schema.json#/properties/appearance` carries the same
343/// clause verbatim, so the rule holds at both document levels. Hence
344/// `deny_unknown_fields` here too: an extra member is not CityJSON, and
345/// dropping it silently at this level while rejecting it one level down would
346/// be inconsistent as well as lossy.
347#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
348#[serde(deny_unknown_fields)]
349pub struct Appearance {
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub materials: Option<Vec<MaterialObject>>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub textures: Option<Vec<TextureObject>>,
354    /// The (u, v) texture vertices. The schema pins each one to
355    /// `minItems: 2, maxItems: 2`, so it is `[f64; 2]` and not `Vec<f64>`:
356    /// a fixed-size array rejects a 1- or 3-element vertex on read and makes
357    /// one unwritable, exactly as `[f64; 3]` does for a colour and `[f64; 6]`
358    /// for a geographical extent.
359    #[serde(rename = "vertices-texture")]
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub vertices_texture: Option<Vec<[f64; 2]>>,
362    #[serde(rename = "default-theme-texture")]
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub default_theme_texture: Option<String>,
365    #[serde(rename = "default-theme-material")]
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub default_theme_material: Option<String>,
368}
369impl Appearance {
370    pub(crate) fn new() -> Self {
371        Appearance {
372            materials: None,
373            textures: None,
374            vertices_texture: None,
375            default_theme_texture: None,
376            default_theme_material: None,
377        }
378    }
379    pub(crate) fn add_material(&mut self, jm: MaterialObject) -> usize {
380        let ls = self.materials.get_or_insert_with(Vec::new);
381        match ls.iter().position(|e| *e == jm) {
382            Some(y) => y,
383            None => {
384                ls.push(jm);
385                ls.len() - 1
386            }
387        }
388    }
389    pub(crate) fn add_texture(&mut self, jm: TextureObject) -> usize {
390        let ls = self.textures.get_or_insert_with(Vec::new);
391        match ls.iter().position(|e| *e == jm) {
392            Some(y) => y,
393            None => {
394                ls.push(jm);
395                ls.len() - 1
396            }
397        }
398    }
399    pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec<[f64; 2]>) {
400        self.vertices_texture
401            .get_or_insert_with(Vec::new)
402            .append(&mut vs);
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    /// The per-theme `material` and `texture` objects in
411    /// `geomprimitives.schema.json` name their members and then stop: neither
412    /// declares `additionalProperties: false`, unlike the geometry object
413    /// enclosing them, which does. So an extra member is legal CityJSON, and
414    /// dropping it is silent data loss.
415    #[test]
416    fn a_material_or_texture_theme_keeps_members_the_schema_does_not_name() {
417        let m: MaterialReference =
418            serde_json::from_value(serde_json::json!({"value": 0, "vendorData": true})).unwrap();
419        assert_eq!(m.value, Some(0));
420        assert_eq!(
421            serde_json::to_value(&m).unwrap(),
422            serde_json::json!({"value": 0, "vendorData": true})
423        );
424
425        let t: TextureReference =
426            serde_json::from_value(serde_json::json!({"values": [[[0, 1, 2]]], "note": "x"}))
427                .unwrap();
428        assert_eq!(
429            serde_json::to_value(&t).unwrap(),
430            serde_json::json!({"values": [[[0, 1, 2]]], "note": "x"})
431        );
432    }
433
434    /// The catch-all must not disturb what was already pinned: an absent
435    /// member stays absent, and `{"values": null}` stays distinct from `{}`.
436    #[test]
437    fn the_catch_all_does_not_disturb_absent_versus_null() {
438        let empty: MaterialReference = serde_json::from_value(serde_json::json!({})).unwrap();
439        assert!(empty.other.is_empty());
440        assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!({}));
441
442        let null: MaterialReference =
443            serde_json::from_value(serde_json::json!({"values": null})).unwrap();
444        assert_eq!(null.values, Some(None));
445        assert_eq!(
446            serde_json::to_value(&null).unwrap(),
447            serde_json::json!({"values": null})
448        );
449    }
450
451    #[test]
452    fn material_indices_serialize_as_numbers_and_null() {
453        // cjseq2 0.1.0 emitted [[], [1]] here. That is invalid CityJSON.
454        let m: MaterialReference = serde_json::from_value(serde_json::json!({
455            "values": [null, 1]
456        }))
457        .unwrap();
458        assert_eq!(
459            serde_json::to_value(&m).unwrap(),
460            serde_json::json!({"values": [null, 1]})
461        );
462    }
463
464    #[test]
465    fn solid_material_values_keep_their_shell_level() {
466        // Finding #8: this shape came back as [[[0, 1]]] through FCB.
467        let m: MaterialReference = serde_json::from_value(serde_json::json!({
468            "values": [[0, 1]]
469        }))
470        .unwrap();
471        assert_eq!(
472            serde_json::to_value(&m).unwrap(),
473            serde_json::json!({"values": [[0, 1]]})
474        );
475    }
476
477    #[test]
478    fn texture_ring_is_index_then_uvs() {
479        let t: TextureReference = serde_json::from_value(serde_json::json!({
480            "values": [[[0, 10, 11, 12]]]
481        }))
482        .unwrap();
483        assert_eq!(
484            serde_json::to_value(&t).unwrap(),
485            serde_json::json!({"values": [[[0, 10, 11, 12]]]})
486        );
487    }
488
489    /// A `MaterialReference` may carry `value` instead of `values`; the absent
490    /// member must not be serialized at all (never as `null`, never as `[]`).
491    #[test]
492    fn material_value_is_a_bare_integer() {
493        let m: MaterialReference = serde_json::from_value(serde_json::json!({"value": 3})).unwrap();
494        assert_eq!(m.value, Some(3));
495        assert_eq!(
496            serde_json::to_value(&m).unwrap(),
497            serde_json::json!({"value": 3})
498        );
499    }
500
501    /// The untagged variant order is load-bearing: asserting only on the
502    /// re-serialized JSON would pass even if a value landed in the wrong
503    /// variant, so pin the variant identity itself.
504    ///
505    /// Material values are nested two levels less deeply than the boundaries
506    /// of the geometry that carries them (CityJSON 2.0, section 6.1).
507    #[test]
508    fn material_values_land_in_the_variant_their_depth_implies() {
509        let parse = |v: serde_json::Value| -> MaterialValues { serde_json::from_value(v).unwrap() };
510
511        //-- MultiSurface/CompositeSurface: one index per surface
512        assert!(matches!(
513            parse(serde_json::json!([0, null, 2])),
514            MaterialValues::Surfaces(_)
515        ));
516        //-- Solid: one index per surface, per shell
517        assert!(matches!(
518            parse(serde_json::json!([[0, null, 2]])),
519            MaterialValues::Shells(_)
520        ));
521        //-- MultiSolid/CompositeSolid: ... per solid
522        assert!(matches!(
523            parse(serde_json::json!([[[0, null, 2]]])),
524            MaterialValues::Solids(_)
525        ));
526
527        //-- the empty array is ambiguous; it must resolve to the shallowest
528        //-- variant, and stay `[]` on the way out
529        let empty = parse(serde_json::json!([]));
530        assert!(matches!(empty, MaterialValues::Surfaces(_)));
531        assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([]));
532
533        //-- deeper than the ladder is not silently accepted
534        assert!(serde_json::from_value::<MaterialValues>(serde_json::json!([[[[0]]]])).is_err());
535        //-- and neither is a bare scalar
536        assert!(serde_json::from_value::<MaterialValues>(serde_json::json!(0)).is_err());
537        //-- the untyped Option<Value> this replaced accepted six levels
538        assert!(
539            serde_json::from_value::<MaterialValues>(serde_json::json!([[[[[[0]]]]]])).is_err()
540        );
541    }
542
543    /// `geomprimitives.schema.json` types `material.values` and every one of
544    /// its intermediate `items` as `["array", "null"]`, so a whole null shell
545    /// or a whole null solid is valid CityJSON. Each of those `None`s must
546    /// come back as `null` — never as `[]`, which is finding #7, the bug this
547    /// whole rewrite exists to prevent.
548    #[test]
549    fn a_null_material_sub_array_round_trips_as_null_at_every_level() {
550        for input in [
551            //-- leaf: a surface with no material
552            serde_json::json!([0, null, 2]),
553            //-- intermediate: a whole shell with no material
554            serde_json::json!([[0, 1], null]),
555            serde_json::json!([null, [0, 1]]),
556            //-- intermediate: a whole solid, and a shell inside a solid
557            serde_json::json!([[[0, 1]], null]),
558            serde_json::json!([[[0, 1], null], null]),
559            //-- nothing but nulls, at each depth
560            serde_json::json!([null, null]),
561            serde_json::json!([[null], null]),
562        ] {
563            let v: MaterialValues = serde_json::from_value(input.clone())
564                .unwrap_or_else(|e| panic!("{input} is schema-valid CityJSON: {e}"));
565            assert_eq!(
566                serde_json::to_value(&v).unwrap(),
567                input,
568                "{input} must round-trip with its nulls intact, never as []"
569            );
570        }
571    }
572
573    /// The variant ladder must still resolve by depth now that the
574    /// intermediate levels are `Option`.
575    #[test]
576    fn null_sub_arrays_do_not_disturb_the_material_ladder() {
577        let parse = |v: serde_json::Value| -> MaterialValues { serde_json::from_value(v).unwrap() };
578
579        assert!(matches!(
580            parse(serde_json::json!([null, 1])),
581            MaterialValues::Surfaces(_)
582        ));
583        assert!(matches!(
584            parse(serde_json::json!([[0, 1], null])),
585            MaterialValues::Shells(_)
586        ));
587        assert!(matches!(
588            parse(serde_json::json!([[[0, 1]], null])),
589            MaterialValues::Solids(_)
590        ));
591
592        //-- the empty-shape edges: each is ambiguous and must settle on the
593        //-- shallowest variant that can hold it, and stay byte-identical
594        for (input, is_surfaces) in [
595            (serde_json::json!([]), true),
596            (serde_json::json!([[]]), false),
597            (serde_json::json!([[[]]]), false),
598        ] {
599            let v = parse(input.clone());
600            assert_eq!(serde_json::to_value(&v).unwrap(), input, "{input}");
601            assert_eq!(
602                matches!(v, MaterialValues::Surfaces(_)),
603                is_surfaces,
604                "{input} landed in {v:?}"
605            );
606        }
607        assert!(matches!(
608            parse(serde_json::json!([[]])),
609            MaterialValues::Shells(_)
610        ));
611        assert!(matches!(
612            parse(serde_json::json!([[[]]])),
613            MaterialValues::Solids(_)
614        ));
615    }
616
617    /// The texture ladder is deliberately *not* null-tolerant at its
618    /// intermediate levels: `geomprimitives.schema.json` types
619    /// `texture.values` and each intermediate `items` as a plain `"array"`,
620    /// with only the innermost items `["integer", "null"]`. An untextured ring
621    /// is spelled `[null]`, not `null`.
622    #[test]
623    fn texture_nulls_are_leaf_only() {
624        //-- what the schema allows: a null texture index inside a ring
625        let t: TextureValues = serde_json::from_value(serde_json::json!([[[null]]])).unwrap();
626        assert_eq!(
627            serde_json::to_value(&t).unwrap(),
628            serde_json::json!([[[null]]])
629        );
630
631        //-- what it does not: a null in place of a surface, ring, or shell
632        for bad in [
633            serde_json::json!([null, [[0, 10, 11]]]),
634            serde_json::json!([[null]]),
635            serde_json::json!([[[[0, 1]]], null]),
636        ] {
637            assert!(
638                serde_json::from_value::<TextureValues>(bad.clone()).is_err(),
639                "{bad} is not valid CityJSON and must be rejected"
640            );
641        }
642    }
643
644    /// The per-theme texture object has no `required` keyword in the schema,
645    /// so both of these are valid CityJSON and must parse.
646    #[test]
647    fn a_texture_reference_may_carry_no_values() {
648        let absent: TextureReference = serde_json::from_value(serde_json::json!({})).unwrap();
649        assert_eq!(absent.values, None);
650        //-- and an absent `values` must not reappear as `null` on the way out
651        assert_eq!(
652            serde_json::to_value(&absent).unwrap(),
653            serde_json::json!({})
654        );
655
656        //-- Documented leniency, NOT a schema claim: `texture.values` is typed
657        //-- `"type": "array"`, so `{"values": null}` is *invalid* CityJSON. A
658        //-- plain `Option` field accepts it anyway, and normalizes it to the
659        //-- valid `{}` on the way out. Pinned so the normalization is
660        //-- deliberate rather than incidental.
661        let null: TextureReference =
662            serde_json::from_value(serde_json::json!({"values": null})).unwrap();
663        assert_eq!(null.values, None);
664        assert_eq!(serde_json::to_value(&null).unwrap(), serde_json::json!({}));
665    }
666
667    /// A `material` theme has three distinct states, and the schema
668    /// distinguishes all three: absent (invalid on its own, but the `value`
669    /// sibling may be carrying the assignment), explicitly `null`, and an
670    /// array. Collapsing `null` onto absent re-emits `{"values": null}` as
671    /// `{}`, which satisfies neither branch of the schema's
672    /// `oneOf: [{required: ["value"]}, {required: ["values"]}]`.
673    #[test]
674    fn material_values_distinguishes_absent_from_null_from_array() {
675        //-- absent: only `value` carries the assignment
676        let absent: MaterialReference =
677            serde_json::from_value(serde_json::json!({"value": 3})).unwrap();
678        assert_eq!(absent.values, None);
679        assert_eq!(
680            serde_json::to_value(&absent).unwrap(),
681            serde_json::json!({"value": 3}),
682            "an absent `values` must stay absent"
683        );
684
685        //-- explicitly null: "no materials anywhere on this geometry"
686        let null: MaterialReference =
687            serde_json::from_value(serde_json::json!({"values": null})).unwrap();
688        assert_eq!(null.values, Some(None));
689        assert_eq!(
690            serde_json::to_value(&null).unwrap(),
691            serde_json::json!({"values": null}),
692            "an explicit `null` must be written back as `null`, not dropped"
693        );
694
695        //-- an array: the ordinary case
696        let array: MaterialReference =
697            serde_json::from_value(serde_json::json!({"values": [0, 1]})).unwrap();
698        assert_eq!(
699            array.values,
700            Some(Some(MaterialValues::Surfaces(vec![Some(0), Some(1)])))
701        );
702        assert_eq!(
703            serde_json::to_value(&array).unwrap(),
704            serde_json::json!({"values": [0, 1]})
705        );
706
707        //-- and a bare `{}` stays `{}` rather than gaining a null
708        let empty: MaterialReference = serde_json::from_value(serde_json::json!({})).unwrap();
709        assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!({}));
710    }
711
712    //-------------------------------------------------------------------
713    //-- the document-level appearance *library*: `materials`/`textures`
714    //-------------------------------------------------------------------
715
716    /// The bug that motivated typing this library: a texture written
717    /// `"wrapMode": "wrap"` came back as `"None"`, because the consumer's
718    /// `match` expected a different spelling and its `_` arm coerced the
719    /// mis-spelling to a valid-looking default.
720    ///
721    /// `appearance.schema.json` fixes the five spellings exactly, all lower
722    /// case:
723    /// ```text
724    /// "wrapMode": { "enum": ["none", "wrap", "mirror", "clamp", "border"] }
725    /// ```
726    /// so every one of them must survive a round trip byte-identical, and
727    /// anything else -- including a differently-cased spelling of a real one
728    /// -- must be rejected here rather than defaulted downstream.
729    #[test]
730    fn every_wrap_mode_round_trips_and_nothing_else_is_accepted() {
731        for mode in ["none", "wrap", "mirror", "clamp", "border"] {
732            let input = serde_json::json!({"textures": [{"wrapMode": mode}]});
733            let a: Appearance = serde_json::from_value(input.clone())
734                .unwrap_or_else(|e| panic!("{mode} is a schema-valid wrapMode: {e}"));
735            assert_eq!(
736                serde_json::to_value(&a).unwrap(),
737                input,
738                "wrapMode {mode:?} must come back exactly as it went in"
739            );
740        }
741
742        for bad in ["Wrap", "WRAP", "None", "repeat", ""] {
743            assert!(
744                serde_json::from_value::<Appearance>(
745                    serde_json::json!({"textures": [{"wrapMode": bad}]})
746                )
747                .is_err(),
748                "{bad:?} is not one of the schema's five wrapMode spellings"
749            );
750        }
751    }
752
753    /// `"textureType": { "enum": ["unknown", "specific", "typical"] }` and
754    /// `"type": { "enum": ["PNG", "JPG"] }` -- note the case difference
755    /// between the two members, which is exactly the kind of detail a string
756    /// `match` gets wrong.
757    #[test]
758    fn every_texture_type_and_format_round_trips_and_nothing_else_is_accepted() {
759        for t in ["unknown", "specific", "typical"] {
760            let input = serde_json::json!({"textures": [{"textureType": t}]});
761            let a: Appearance = serde_json::from_value(input.clone())
762                .unwrap_or_else(|e| panic!("{t} is a schema-valid textureType: {e}"));
763            assert_eq!(serde_json::to_value(&a).unwrap(), input);
764        }
765        for bad in ["Unknown", "SPECIFIC", "other"] {
766            assert!(
767                serde_json::from_value::<Appearance>(
768                    serde_json::json!({"textures": [{"textureType": bad}]})
769                )
770                .is_err(),
771                "{bad:?} is not one of the schema's three textureType spellings"
772            );
773        }
774
775        for f in ["PNG", "JPG"] {
776            let input = serde_json::json!({"textures": [{"type": f}]});
777            let a: Appearance = serde_json::from_value(input.clone())
778                .unwrap_or_else(|e| panic!("{f} is a schema-valid texture type: {e}"));
779            assert_eq!(serde_json::to_value(&a).unwrap(), input);
780        }
781        //-- the schema spells these upper case, and lists only these two;
782        //-- `JPEG` and `jpg` are both outside it
783        for bad in ["png", "jpg", "JPEG", "TIFF"] {
784            assert!(
785                serde_json::from_value::<Appearance>(
786                    serde_json::json!({"textures": [{"type": bad}]})
787                )
788                .is_err(),
789                "{bad:?} is not one of the schema's two texture formats"
790            );
791        }
792    }
793
794    /// A texture object carries all five members, or none of them: the
795    /// `Texture` schema has no `required` keyword at all, so `{}` is valid,
796    /// and an absent member must not reappear.
797    #[test]
798    fn a_texture_object_keeps_every_member_it_was_given_and_invents_none() {
799        let full = serde_json::json!({"textures": [{
800            "type": "JPG",
801            "image": "appearances/wood.jpg",
802            "wrapMode": "wrap",
803            "textureType": "unknown",
804            "borderColor": [0.0, 0.0, 0.0, 1.0]
805        }]});
806        let a: Appearance = serde_json::from_value(full.clone()).unwrap();
807        assert_eq!(serde_json::to_value(&a).unwrap(), full);
808
809        let bare = serde_json::json!({"textures": [{}]});
810        let a: Appearance = serde_json::from_value(bare.clone()).unwrap();
811        assert_eq!(
812            serde_json::to_value(&a).unwrap(),
813            bare,
814            "the Texture schema has no `required`, so an empty texture object \
815             is valid and must not gain members"
816        );
817    }
818
819    /// `"borderColor": {"type": "array", "items": {"type": "number"},
820    /// "minItems": 3, "maxItems": 4}` -- three *or* four, never two, never
821    /// five. This cardinality has already bitten this project once.
822    #[test]
823    fn border_color_is_three_or_four_numbers() {
824        for ok in [
825            serde_json::json!([0.0, 0.0, 0.0]),
826            serde_json::json!([0.0, 0.0, 0.0, 1.0]),
827        ] {
828            let input = serde_json::json!({"textures": [{"borderColor": ok}]});
829            let a: Appearance = serde_json::from_value(input.clone())
830                .unwrap_or_else(|e| panic!("{ok} is a schema-valid borderColor: {e}"));
831            assert_eq!(serde_json::to_value(&a).unwrap(), input);
832        }
833        for bad in [
834            serde_json::json!([]),
835            serde_json::json!([0.0]),
836            serde_json::json!([0.0, 0.0]),
837            serde_json::json!([0.0, 0.0, 0.0, 1.0, 1.0]),
838        ] {
839            assert!(
840                serde_json::from_value::<Appearance>(
841                    serde_json::json!({"textures": [{"borderColor": bad}]})
842                )
843                .is_err(),
844                "{bad} violates borderColor's minItems 3 / maxItems 4"
845            );
846        }
847    }
848
849    /// The `Material` schema is the mirror image of `Texture`: it *requires*
850    /// `name`, and pins each of its three colours at exactly three numbers
851    /// (`"minItems": 3, "maxItems": 3`).
852    #[test]
853    fn a_material_object_requires_a_name_and_three_number_colours() {
854        let full = serde_json::json!({"materials": [{
855            "name": "roof",
856            "ambientIntensity": 0.4,
857            "diffuseColor": [0.5, 0.4, 0.3],
858            "emissiveColor": [0.0, 0.0, 0.0],
859            "specularColor": [1.0, 1.0, 1.0],
860            "shininess": 0.2,
861            "transparency": 0.0,
862            "isSmooth": false
863        }]});
864        let a: Appearance = serde_json::from_value(full.clone()).unwrap();
865        assert_eq!(serde_json::to_value(&a).unwrap(), full);
866
867        //-- `name` is the schema's one required member
868        assert!(
869            serde_json::from_value::<Appearance>(
870                serde_json::json!({"materials": [{"diffuseColor": [0.5, 0.4, 0.3]}]})
871            )
872            .is_err(),
873            "`name` is required by the Material schema"
874        );
875
876        //-- and a colour is exactly three numbers, never two, never four
877        for bad in [
878            serde_json::json!([0.5, 0.4]),
879            serde_json::json!([0.5, 0.4, 0.3, 0.2]),
880        ] {
881            assert!(
882                serde_json::from_value::<Appearance>(
883                    serde_json::json!({"materials": [{"name": "m", "diffuseColor": bad}]})
884                )
885                .is_err(),
886                "{bad} violates diffuseColor's minItems 3 / maxItems 3"
887            );
888        }
889
890        //-- only `name` need be there
891        let bare = serde_json::json!({"materials": [{"name": "m"}]});
892        let a: Appearance = serde_json::from_value(bare.clone()).unwrap();
893        assert_eq!(serde_json::to_value(&a).unwrap(), bare);
894    }
895
896    /// Both `Material` and `Texture` declare `"additionalProperties": false`,
897    /// unlike the per-theme reference objects above. So an unnamed member is
898    /// *not* legal CityJSON, and quietly dropping it -- which is what a
899    /// catch-all-less struct does by default -- would let an invalid file
900    /// through and lose data on the way. Reject it instead.
901    #[test]
902    fn material_and_texture_objects_admit_no_members_the_schema_does_not_name() {
903        assert!(
904            serde_json::from_value::<Appearance>(
905                serde_json::json!({"materials": [{"name": "m", "vendorData": true}]})
906            )
907            .is_err(),
908            "the Material schema declares additionalProperties: false"
909        );
910        assert!(
911            serde_json::from_value::<Appearance>(
912                serde_json::json!({"textures": [{"image": "a.jpg", "vendorData": true}]})
913            )
914            .is_err(),
915            "the Texture schema declares additionalProperties: false"
916        );
917        //-- a near-miss spelling is an unnamed member, and so is caught by the
918        //-- same rule: this is the whole point of the exercise
919        assert!(
920            serde_json::from_value::<Appearance>(
921                serde_json::json!({"textures": [{"wrapmode": "wrap"}]})
922            )
923            .is_err(),
924            "`wrapmode` is not `wrapMode`"
925        );
926    }
927
928    /// The `appearance` object itself declares `additionalProperties: false`,
929    /// exactly as the `Material` and `Texture` objects inside it do.
930    /// `cityjson.schema.json#/properties/appearance`:
931    ///
932    /// ```text
933    /// "appearance": {
934    ///   "type": "object",
935    ///   "properties": {
936    ///     "default-theme-texture": {"type": "string"},
937    ///     "default-theme-material": {"type": "string"},
938    ///     "materials": {"type": "array", "items": {"$ref": "appearance.schema.json#/Material"}},
939    ///     "textures":  {"type": "array", "items": {"$ref": "appearance.schema.json#/Texture"}},
940    ///     "vertices-texture": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}}
941    ///   },
942    ///   "additionalProperties": false
943    /// }
944    /// ```
945    ///
946    /// `cityjsonfeature.schema.json#/properties/appearance` carries the same
947    /// clause verbatim, so the rule holds at both document levels. Dropping an
948    /// extra member here while rejecting one a level down, inside `Material`,
949    /// would be asymmetric: the schema forbids the member either way.
950    #[test]
951    fn the_appearance_object_admits_no_members_the_schema_does_not_name() {
952        for bad in [
953            serde_json::json!({"vendorData": true}),
954            //-- a near-miss on each of the two hyphenated names, which are the
955            //-- easiest of the five to mistype
956            serde_json::json!({"default_theme_texture": "winter"}),
957            serde_json::json!({"vertices_texture": [[0.0, 0.0]]}),
958            //-- and a near-miss alongside a member that is correct, so the
959            //-- rejection cannot be an accident of the object being otherwise
960            //-- empty
961            serde_json::json!({"materials": [], "texture": []}),
962        ] {
963            assert!(
964                serde_json::from_value::<Appearance>(bad.clone()).is_err(),
965                "{bad} carries a member `appearance` does not name, and the \
966                 schema declares additionalProperties: false"
967            );
968        }
969
970        //-- the control: all five named members together are accepted, so the
971        //-- loop above is not passing merely because everything is rejected
972        let all_five = serde_json::json!({
973            "materials": [{"name": "m"}],
974            "textures": [{"image": "a.jpg"}],
975            "vertices-texture": [[0.0, 0.0]],
976            "default-theme-texture": "winter",
977            "default-theme-material": "irradiation"
978        });
979        let a: Appearance = serde_json::from_value(all_five.clone()).unwrap();
980        assert_eq!(serde_json::to_value(&a).unwrap(), all_five);
981    }
982
983    /// `cityjson.schema.json` pins each texture vertex to
984    /// `{"type": "array", "items": {"type": "number"}, "minItems": 2,
985    /// "maxItems": 2}`, so `vertices-texture` is `Vec<[f64; 2]>`: a vertex
986    /// with any other length is not CityJSON and must not parse, nor be
987    /// constructible in Rust. This is the same principle `Color` (`[f64; 3]`),
988    /// `GeographicalExtent` (`[f64; 6]`) and `transformationMatrix`
989    /// (`[f64; 16]`) already apply.
990    #[test]
991    fn a_texture_vertex_is_exactly_two_numbers() {
992        for bad in [
993            serde_json::json!({"vertices-texture": [[0.5]]}),
994            serde_json::json!({"vertices-texture": [[0.5, 0.5, 0.5]]}),
995            serde_json::json!({"vertices-texture": [[]]}),
996            //-- one good vertex is not enough to rescue a bad one
997            serde_json::json!({"vertices-texture": [[0.0, 0.0], [1.0, 1.0, 1.0]]}),
998        ] {
999            assert!(
1000                serde_json::from_value::<Appearance>(bad.clone()).is_err(),
1001                "{bad} has a texture vertex that is not exactly two numbers"
1002            );
1003        }
1004
1005        //-- the control, round-tripping through the fixed-size array
1006        let good = serde_json::json!({"vertices-texture": [[0.0, 0.5], [1.0, 1.0]]});
1007        let a: Appearance = serde_json::from_value(good.clone()).unwrap();
1008        assert_eq!(a.vertices_texture, Some(vec![[0.0, 0.5], [1.0, 1.0]]));
1009        assert_eq!(serde_json::to_value(&a).unwrap(), good);
1010    }
1011
1012    /// An `appearance` with empty arrays in it is valid, and the arrays must
1013    /// stay empty rather than becoming absent (`tests/data/empty_appearance`).
1014    #[test]
1015    fn an_empty_appearance_library_stays_empty() {
1016        let input = serde_json::json!({"materials": [], "textures": []});
1017        let a: Appearance = serde_json::from_value(input.clone()).unwrap();
1018        assert_eq!(serde_json::to_value(&a).unwrap(), input);
1019    }
1020
1021    /// Texture values are nested exactly as deeply as the boundaries of the
1022    /// geometry that carries them (CityJSON 2.0, section 6.2), each ring
1023    /// becoming `[texture_index, uv_index, ...]`.
1024    #[test]
1025    fn texture_values_land_in_the_variant_their_depth_implies() {
1026        let parse = |v: serde_json::Value| -> TextureValues { serde_json::from_value(v).unwrap() };
1027
1028        //-- MultiSurface/CompositeSurface: per surface, per ring
1029        assert!(matches!(
1030            parse(serde_json::json!([[[0, 10, 11, 12]]])),
1031            TextureValues::Surface(_)
1032        ));
1033        //-- Solid: ... per shell
1034        assert!(matches!(
1035            parse(serde_json::json!([[[[0, 10, 11, 12]]]])),
1036            TextureValues::Shell(_)
1037        ));
1038        //-- MultiSolid/CompositeSolid: ... per solid
1039        assert!(matches!(
1040            parse(serde_json::json!([[[[[0, 10, 11, 12]]]]])),
1041            TextureValues::Solid(_)
1042        ));
1043        //-- an untextured ring is `[null]`, at every depth
1044        assert!(matches!(
1045            parse(serde_json::json!([[[null]]])),
1046            TextureValues::Surface(_)
1047        ));
1048
1049        let empty = parse(serde_json::json!([]));
1050        assert!(matches!(empty, TextureValues::Surface(_)));
1051        assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([]));
1052
1053        //-- too deep, and too shallow, are both rejected: the untyped
1054        //-- `Option<Value>` this replaces accepted either without a murmur
1055        assert!(serde_json::from_value::<TextureValues>(serde_json::json!([[[[[[0]]]]]])).is_err());
1056        assert!(serde_json::from_value::<TextureValues>(serde_json::json!([0, 1])).is_err());
1057    }
1058}