Skip to main content

cjseq/
semantics.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5/// The known surface types the spec assigns to Building-, WaterBody-, and
6/// Transportation-family City Objects (§ 3.3 Semantics of geometric
7/// primitives). Every variant is a unit variant already spelled exactly as
8/// the spec requires, so serde's default (non-untagged) derive collapses
9/// each one to its bare name string with no `#[serde(rename)]` needed.
10#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
11pub enum KnownSemanticSurfaceType {
12    RoofSurface,
13    GroundSurface,
14    WallSurface,
15    ClosureSurface,
16    OuterCeilingSurface,
17    OuterFloorSurface,
18    Window,
19    Door,
20    InteriorWallSurface,
21    CeilingSurface,
22    FloorSurface,
23    WaterSurface,
24    WaterGroundSurface,
25    WaterClosureSurface,
26    TrafficArea,
27    AuxiliaryTrafficArea,
28    TransportationMarking,
29    TransportationHole,
30}
31
32/// The `type` member of a Semantic Object (§ 3.3 Semantics of geometric
33/// primitives).
34///
35/// `Known` covers the surface types above. Any other value is a CityJSON
36/// Extension semantic surface, which the spec requires to start with `"+"`
37/// (eg `"+ThermalSurface"`, § 8.5); `Extension` carries that string verbatim
38/// so it round-trips byte for byte.
39///
40/// This nests the known names inside their own unit-only enum rather than
41/// mixing unit variants with `Extension(String)` directly in one flat
42/// `#[serde(untagged)]` enum -- see [`crate::CityObjectType`]'s doc comment
43/// for why a flat mix cannot round-trip (a unit variant under `untagged`
44/// (de)serializes as `null`, not its name; this was verified empirically
45/// while building `CityObjectType`, which has the identical shape).
46///
47/// # Match on the value, not on a reference
48///
49/// Each known name is also available flat, as an associated `const` (eg
50/// [`SemanticSurfaceType::RoofSurface`]) -- see the `impl` block below for
51/// the full list. They are `const`s, not variants, and that distinction bites
52/// exactly once, silently:
53///
54/// ```
55/// # use cjseq::SemanticSurfaceType;
56/// # let t = &SemanticSurfaceType::RoofSurface;
57/// match *t {
58///     SemanticSurfaceType::RoofSurface => { /* .. */ }
59///     SemanticSurfaceType::Extension(ref s) => { let _ = s; }
60///     _ => {}
61/// }
62/// ```
63///
64/// Note the `*`. `match t` where `t: &SemanticSurfaceType`, or `match &t`, is
65/// `E0308` ("mismatched types"), and rustc's fix-it suggests renaming the arm
66/// to a fresh binding -- which compiles, and silently turns that arm into a
67/// catch-all matching everything, because `RoofSurface` in pattern position
68/// reads as a new irrefutable binding rather than as the constant. The `_`
69/// arm is still required: `Known` and `Extension` are the real variants, so
70/// the known names alone are not exhaustive.
71#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
72#[serde(untagged)]
73pub enum SemanticSurfaceType {
74    Known(KnownSemanticSurfaceType),
75    /// Not a core CityJSON semantic surface type: a CityJSON Extension
76    /// surface type, always spelled with a leading `"+"`.
77    Extension(String),
78}
79
80/// Flat, spec-spelling access to each known variant (eg
81/// `SemanticSurfaceType::RoofSurface`); see [`crate::CityObjectType`]'s
82/// equivalent block for why these are associated `const`s rather than enum
83/// variants -- and, on
84/// [`SemanticSurfaceType`][SemanticSurfaceType#match-on-the-value-not-on-a-reference],
85/// for why they must be matched on a value rather than on a reference.
86#[allow(non_upper_case_globals)]
87impl SemanticSurfaceType {
88    pub const RoofSurface: SemanticSurfaceType =
89        SemanticSurfaceType::Known(KnownSemanticSurfaceType::RoofSurface);
90    pub const GroundSurface: SemanticSurfaceType =
91        SemanticSurfaceType::Known(KnownSemanticSurfaceType::GroundSurface);
92    pub const WallSurface: SemanticSurfaceType =
93        SemanticSurfaceType::Known(KnownSemanticSurfaceType::WallSurface);
94    pub const ClosureSurface: SemanticSurfaceType =
95        SemanticSurfaceType::Known(KnownSemanticSurfaceType::ClosureSurface);
96    pub const OuterCeilingSurface: SemanticSurfaceType =
97        SemanticSurfaceType::Known(KnownSemanticSurfaceType::OuterCeilingSurface);
98    pub const OuterFloorSurface: SemanticSurfaceType =
99        SemanticSurfaceType::Known(KnownSemanticSurfaceType::OuterFloorSurface);
100    pub const Window: SemanticSurfaceType =
101        SemanticSurfaceType::Known(KnownSemanticSurfaceType::Window);
102    pub const Door: SemanticSurfaceType =
103        SemanticSurfaceType::Known(KnownSemanticSurfaceType::Door);
104    pub const InteriorWallSurface: SemanticSurfaceType =
105        SemanticSurfaceType::Known(KnownSemanticSurfaceType::InteriorWallSurface);
106    pub const CeilingSurface: SemanticSurfaceType =
107        SemanticSurfaceType::Known(KnownSemanticSurfaceType::CeilingSurface);
108    pub const FloorSurface: SemanticSurfaceType =
109        SemanticSurfaceType::Known(KnownSemanticSurfaceType::FloorSurface);
110    pub const WaterSurface: SemanticSurfaceType =
111        SemanticSurfaceType::Known(KnownSemanticSurfaceType::WaterSurface);
112    pub const WaterGroundSurface: SemanticSurfaceType =
113        SemanticSurfaceType::Known(KnownSemanticSurfaceType::WaterGroundSurface);
114    pub const WaterClosureSurface: SemanticSurfaceType =
115        SemanticSurfaceType::Known(KnownSemanticSurfaceType::WaterClosureSurface);
116    pub const TrafficArea: SemanticSurfaceType =
117        SemanticSurfaceType::Known(KnownSemanticSurfaceType::TrafficArea);
118    pub const AuxiliaryTrafficArea: SemanticSurfaceType =
119        SemanticSurfaceType::Known(KnownSemanticSurfaceType::AuxiliaryTrafficArea);
120    pub const TransportationMarking: SemanticSurfaceType =
121        SemanticSurfaceType::Known(KnownSemanticSurfaceType::TransportationMarking);
122    pub const TransportationHole: SemanticSurfaceType =
123        SemanticSurfaceType::Known(KnownSemanticSurfaceType::TransportationHole);
124}
125
126/// One semantic surface: its type, its place in the parent/children hierarchy,
127/// and any further attributes the file chooses to carry.
128#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
129pub struct SemanticsSurface {
130    #[serde(rename = "type")]
131    pub thetype: SemanticSurfaceType,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub parent: Option<usize>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub children: Option<Vec<usize>>,
136    /// CityJSON lets a semantic surface carry arbitrary extra members
137    /// (`slope`, `direction`, ...); they are kept verbatim so that a file
138    /// round-trips without losing them.
139    #[serde(flatten)]
140    pub other: HashMap<String, Value>,
141}
142
143/// One shell's worth of semantic-surface indices, one per surface. `None` — a
144/// whole shell with no semantics — is `null` in JSON, which
145/// `geomprimitives.schema.json` permits at every level of `semantics.values`
146/// (`"type": ["array", "null"]`), exactly as it does for `material.values`.
147pub type SemanticsShell = Vec<Option<usize>>;
148/// One solid's worth of semantic-surface indices, per surface, per shell.
149pub type SemanticsSolid = Vec<Option<SemanticsShell>>;
150
151/// The `values` array of a [`Semantics`] object: one index into `surfaces` per
152/// *surface* of the geometry, so it is nested exactly one level less deeply
153/// than that geometry's `boundaries`.
154///
155/// As with [`crate::MaterialValues`], `null` is permitted at every level, not
156/// only at the leaf, and every `None` must serialize back as `null` rather
157/// than `[]` (finding #7).
158///
159/// The variants are ordered shallowest-first; serde tries them in declaration
160/// order, so a value only reaches a deeper variant when the shallower ones
161/// cannot describe it.
162#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
163#[serde(untagged)]
164pub enum SemanticsValues {
165    /// `MultiSurface`, `CompositeSurface`: one index per surface.
166    Surfaces(Vec<Option<usize>>),
167    /// `Solid`: one index per surface, per shell.
168    Shells(Vec<Option<SemanticsShell>>),
169    /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per
170    /// solid.
171    Solids(Vec<Option<SemanticsSolid>>),
172}
173
174/// The `semantics` member of a geometry.
175///
176/// `values` is a *required* member whose value may be `null`
177/// (`"type": ["array", "null"]`, and `"required": ["surfaces", "values"]`), so
178/// it is an `Option` that is serialized even when `None` — writing it out as
179/// `null` rather than dropping the member, which would produce a document the
180/// schema rejects.
181///
182/// As with [`SemanticsSurface`], the `semantics` object in
183/// `geomprimitives.schema.json` names `surfaces` and `values` and declares no
184/// `additionalProperties: false` -- unlike its enclosing geometry object,
185/// which does -- so any further member is legal and is kept verbatim in
186/// `other` rather than dropped.
187#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
188pub struct Semantics {
189    pub surfaces: Vec<SemanticsSurface>,
190    pub values: Option<SemanticsValues>,
191    #[serde(flatten)]
192    pub other: HashMap<String, Value>,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    /// The `semantics` object names `surfaces` and `values` and declares no
200    /// `additionalProperties: false` -- unlike the geometry object that
201    /// contains it, which does -- so a further member is legal CityJSON and
202    /// must not vanish. `SemanticsSurface` already kept its extras; the
203    /// enclosing `Semantics` did not.
204    #[test]
205    fn semantics_keeps_members_the_schema_does_not_name() {
206        let input = serde_json::json!({
207            "surfaces": [{"type": "RoofSurface"}],
208            "values": [0],
209            "vendorData": {"x": 1}
210        });
211        let s: Semantics = serde_json::from_value(input.clone()).unwrap();
212        assert_eq!(s.other["vendorData"], serde_json::json!({"x": 1}));
213        assert_eq!(serde_json::to_value(&s).unwrap(), input);
214    }
215
216    /// And with nothing extra, nothing extra appears.
217    #[test]
218    fn semantics_without_extras_serializes_unchanged() {
219        let input = serde_json::json!({
220            "surfaces": [{"type": "RoofSurface"}],
221            "values": null
222        });
223        let s: Semantics = serde_json::from_value(input.clone()).unwrap();
224        assert!(s.other.is_empty());
225        assert_eq!(serde_json::to_value(&s).unwrap(), input);
226    }
227
228    #[test]
229    fn solid_semantics_values_are_one_per_surface_per_shell() {
230        let s: Semantics = serde_json::from_value(serde_json::json!({
231            "surfaces": [{"type": "RoofSurface"}, {"type": "WallSurface"}],
232            "values": [[0, 1, null]]
233        }))
234        .unwrap();
235        assert_eq!(
236            serde_json::to_value(&s).unwrap()["values"],
237            serde_json::json!([[0, 1, null]])
238        );
239    }
240
241    #[test]
242    fn null_semantics_index_stays_null() {
243        let s: Semantics = serde_json::from_value(serde_json::json!({
244            "surfaces": [{"type": "RoofSurface"}], "values": [null, 0]
245        }))
246        .unwrap();
247        // Must be `null`, never `[]` -- see finding #7.
248        assert_eq!(
249            serde_json::to_value(&s).unwrap()["values"],
250            serde_json::json!([null, 0])
251        );
252    }
253
254    /// develop's 6917631 added `Eq` (alongside `PartialEq`) to
255    /// `SemanticsSurface`/`Semantics`. `SemanticsSurface.other` is
256    /// `HashMap<String, serde_json::Value>`, and this serde_json version
257    /// (1.0.151) implements `Eq` for both `Number` (`impl Eq for N {}`,
258    /// `src/number.rs:50` -- sound because a JSON number can never be NaN)
259    /// and `Value` (`#[derive(Clone, Eq, PartialEq, Hash)]`,
260    /// `src/value/mod.rs:115`), so nothing here actually blocks `Eq`.
261    #[test]
262    fn semantics_surface_and_semantics_implement_eq() {
263        fn assert_impls_eq<T: Eq>() {}
264        assert_impls_eq::<SemanticsSurface>();
265        assert_impls_eq::<SemanticsValues>();
266        assert_impls_eq::<Semantics>();
267    }
268
269    /// A semantic surface may carry a hierarchy and arbitrary extra members;
270    /// both must survive a round-trip byte for byte.
271    #[test]
272    fn surface_extras_and_hierarchy_round_trip() {
273        let input = serde_json::json!({
274            "surfaces": [
275                {"type": "RoofSurface", "slope": 33.4, "children": [1]},
276                {"type": "Window", "parent": 0, "direction": "north"}
277            ],
278            "values": [0, 1]
279        });
280        let s: Semantics = serde_json::from_value(input.clone()).unwrap();
281        assert_eq!(s.surfaces[0].children, Some(vec![1]));
282        assert_eq!(s.surfaces[1].parent, Some(0));
283        assert_eq!(
284            s.surfaces[1].other["direction"],
285            serde_json::json!("north"),
286            "extra members must be kept, not dropped"
287        );
288        assert_eq!(serde_json::to_value(&s).unwrap(), input);
289    }
290
291    /// develop's aff25a1 made `SemanticsSurface.other` an
292    /// `Option<serde_json::Value>` specifically so a surface with no extra
293    /// members serializes without an empty placeholder. Task 3 reaches the
294    /// same outcome a different way: `other` is a plain (non-`Option`)
295    /// `HashMap<String, Value>`, and `#[serde(flatten)]` on an empty map
296    /// contributes zero keys, so a surface with nothing extra round-trips
297    /// with no spurious members either way -- no `Option` wrapper needed.
298    #[test]
299    fn surface_with_no_extras_round_trips_without_spurious_members() {
300        let input = serde_json::json!({
301            "surfaces": [{"type": "RoofSurface"}],
302            "values": [0]
303        });
304        let s: Semantics = serde_json::from_value(input.clone()).unwrap();
305        assert!(s.surfaces[0].other.is_empty());
306        assert_eq!(serde_json::to_value(&s).unwrap(), input);
307    }
308
309    /// The untagged variant order is load-bearing: each shape must land in the
310    /// variant its depth implies, not merely re-serialize to the same JSON.
311    #[test]
312    fn each_shape_lands_in_the_variant_its_depth_implies() {
313        let parse =
314            |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() };
315
316        assert!(matches!(
317            parse(serde_json::json!([0, 1, null])),
318            SemanticsValues::Surfaces(_)
319        ));
320        assert!(matches!(
321            parse(serde_json::json!([[0, 1, null]])),
322            SemanticsValues::Shells(_)
323        ));
324        assert!(matches!(
325            parse(serde_json::json!([[[0, 1, null]]])),
326            SemanticsValues::Solids(_)
327        ));
328        //-- the empty array is ambiguous; it must resolve to the shallowest
329        //-- variant, and stay `[]` on the way out
330        let empty = parse(serde_json::json!([]));
331        assert!(matches!(empty, SemanticsValues::Surfaces(_)));
332        assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([]));
333
334        //-- a shape deeper than the ladder is not silently accepted
335        assert!(serde_json::from_value::<SemanticsValues>(serde_json::json!([[[[0]]]])).is_err());
336    }
337
338    /// `geomprimitives.schema.json` types `semantics.values` and every one of
339    /// its intermediate `items` as `["array", "null"]`, exactly as it does for
340    /// `material.values` — so a whole null shell is valid CityJSON here too,
341    /// and each `None` must come back as `null`, never as `[]` (finding #7).
342    #[test]
343    fn a_null_semantics_sub_array_round_trips_as_null_at_every_level() {
344        for input in [
345            serde_json::json!([0, null, 2]),
346            serde_json::json!([[0, 1], null]),
347            serde_json::json!([null, [0, 1]]),
348            serde_json::json!([[[0, 1]], null]),
349            serde_json::json!([[[0, 1], null], null]),
350            serde_json::json!([null, null]),
351            serde_json::json!([[null], null]),
352        ] {
353            let v: SemanticsValues = serde_json::from_value(input.clone())
354                .unwrap_or_else(|e| panic!("{input} is schema-valid CityJSON: {e}"));
355            assert_eq!(
356                serde_json::to_value(&v).unwrap(),
357                input,
358                "{input} must round-trip with its nulls intact, never as []"
359            );
360        }
361    }
362
363    /// `values` is required by the schema but may be `null`. It must parse,
364    /// and it must be written back out as `null` — dropping the member
365    /// entirely would violate `"required": ["surfaces", "values"]`.
366    #[test]
367    fn a_null_semantics_values_member_round_trips() {
368        let input = serde_json::json!({
369            "surfaces": [{"type": "RoofSurface"}], "values": null
370        });
371        let s: Semantics =
372            serde_json::from_value(input.clone()).expect("`values: null` is schema-valid CityJSON");
373        assert_eq!(s.values, None);
374        assert_eq!(serde_json::to_value(&s).unwrap(), input);
375    }
376
377    /// The ladder must still resolve by depth now that the intermediate levels
378    /// are `Option`, including the ambiguous empty shapes.
379    #[test]
380    fn null_sub_arrays_do_not_disturb_the_semantics_ladder() {
381        let parse =
382            |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() };
383
384        assert!(matches!(
385            parse(serde_json::json!([null, 1])),
386            SemanticsValues::Surfaces(_)
387        ));
388        assert!(matches!(
389            parse(serde_json::json!([[0, 1], null])),
390            SemanticsValues::Shells(_)
391        ));
392        assert!(matches!(
393            parse(serde_json::json!([[[0, 1]], null])),
394            SemanticsValues::Solids(_)
395        ));
396
397        for input in [
398            serde_json::json!([]),
399            serde_json::json!([[]]),
400            serde_json::json!([[[]]]),
401        ] {
402            let v = parse(input.clone());
403            assert_eq!(serde_json::to_value(&v).unwrap(), input, "{input}");
404        }
405        assert!(matches!(
406            parse(serde_json::json!([])),
407            SemanticsValues::Surfaces(_)
408        ));
409        assert!(matches!(
410            parse(serde_json::json!([[]])),
411            SemanticsValues::Shells(_)
412        ));
413        assert!(matches!(
414            parse(serde_json::json!([[[]]])),
415            SemanticsValues::Solids(_)
416        ));
417    }
418
419    /// Extension semantic surfaces (`"+ThermalSurface"`, `"+PatioDoor"`, ...)
420    /// must survive a round trip with their leading `+` intact -- mirrors the
421    /// `CityObjectType` extension-round-trip requirement, but for surfaces.
422    #[test]
423    fn extension_semantic_surface_type_roundtrips_with_its_plus() {
424        let t: SemanticSurfaceType =
425            serde_json::from_value(serde_json::json!("+ThermalSurface")).unwrap();
426        assert_eq!(t, SemanticSurfaceType::Extension("+ThermalSurface".into()));
427        assert_eq!(
428            serde_json::to_value(&t).unwrap(),
429            serde_json::json!("+ThermalSurface")
430        );
431    }
432
433    #[test]
434    fn plus_prefixed_known_surface_name_lands_in_extension() {
435        let t: SemanticSurfaceType =
436            serde_json::from_value(serde_json::json!("+RoofSurface")).unwrap();
437        assert_eq!(t, SemanticSurfaceType::Extension("+RoofSurface".into()));
438        assert_ne!(t, SemanticSurfaceType::RoofSurface);
439    }
440
441    /// Mirrors `city_object::tests::extension_typed_city_object_roundtrips_byte_for_byte_inside_a_feature`:
442    /// the bare-`SemanticSurfaceType` tests above never exercise the flatten
443    /// path an extension surface actually sits behind in practice -- a
444    /// `SemanticsSurface` nested inside a real `Geometry`'s `semantics`
445    /// member. Asserts on the serialized *string*, not a re-parsed `Value`,
446    /// so a change to `other`/flatten handling would fail this even with an
447    /// otherwise-green suite.
448    ///
449    /// `"azimuth"` (lifted from the spec's own `"+ThermalSurface"` example,
450    /// § 8.5) is the one extra, genuinely unrecognized member landing in
451    /// `#[serde(flatten)] other: HashMap<String, Value>`; kept to exactly
452    /// one key since that `HashMap` has no stable iteration order once it
453    /// holds two or more.
454    #[test]
455    fn extension_typed_semantics_surface_roundtrips_byte_for_byte_inside_a_geometry() {
456        let input = concat!(
457            r#"{"type":"MultiSurface","boundaries":[[[0,1,2]]],"#,
458            r#""semantics":{"surfaces":[{"type":"+ThermalSurface","azimuth":30.5}],"values":[0]}}"#
459        );
460        let parsed: crate::geometry::Geometry = serde_json::from_str(input).unwrap();
461        let reserialized = serde_json::to_string(&parsed).unwrap();
462        assert_eq!(
463            reserialized, input,
464            "an Extension-typed SemanticsSurface with an extra flattened member inside a \
465             Geometry must round-trip byte-for-byte"
466        );
467    }
468
469    /// Every known SemanticSurfaceType name round-trips through its own unit
470    /// variant, not through Extension.
471    #[test]
472    fn every_known_semantic_surface_type_round_trips_as_its_unit_variant() {
473        let known: &[(&str, SemanticSurfaceType)] = &[
474            ("RoofSurface", SemanticSurfaceType::RoofSurface),
475            ("GroundSurface", SemanticSurfaceType::GroundSurface),
476            ("WallSurface", SemanticSurfaceType::WallSurface),
477            ("ClosureSurface", SemanticSurfaceType::ClosureSurface),
478            (
479                "OuterCeilingSurface",
480                SemanticSurfaceType::OuterCeilingSurface,
481            ),
482            ("OuterFloorSurface", SemanticSurfaceType::OuterFloorSurface),
483            ("Window", SemanticSurfaceType::Window),
484            ("Door", SemanticSurfaceType::Door),
485            (
486                "InteriorWallSurface",
487                SemanticSurfaceType::InteriorWallSurface,
488            ),
489            ("CeilingSurface", SemanticSurfaceType::CeilingSurface),
490            ("FloorSurface", SemanticSurfaceType::FloorSurface),
491            ("WaterSurface", SemanticSurfaceType::WaterSurface),
492            (
493                "WaterGroundSurface",
494                SemanticSurfaceType::WaterGroundSurface,
495            ),
496            (
497                "WaterClosureSurface",
498                SemanticSurfaceType::WaterClosureSurface,
499            ),
500            ("TrafficArea", SemanticSurfaceType::TrafficArea),
501            (
502                "AuxiliaryTrafficArea",
503                SemanticSurfaceType::AuxiliaryTrafficArea,
504            ),
505            (
506                "TransportationMarking",
507                SemanticSurfaceType::TransportationMarking,
508            ),
509            (
510                "TransportationHole",
511                SemanticSurfaceType::TransportationHole,
512            ),
513        ];
514        for (name, expected) in known {
515            let parsed: SemanticSurfaceType =
516                serde_json::from_value(serde_json::json!(name)).unwrap();
517            assert_eq!(&parsed, expected, "{name} did not parse to its own variant");
518            assert_eq!(
519                serde_json::to_value(&parsed).unwrap(),
520                serde_json::json!(name),
521                "{name} did not round-trip back to its own string"
522            );
523        }
524    }
525}