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}