From 3b11e4f7baee903bee3f496f34be5b5424b88cc9 Mon Sep 17 00:00:00 2001 From: HideBa <49897538+HideBa@users.noreply.github.com> Date: Fri, 12 Sep 2025 17:15:07 +0200 Subject: [PATCH 01/65] ci: update publish-crate.yml --- .github/workflows/publish-crate.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index a5f2572..8e518d7 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -38,7 +38,6 @@ jobs: - name: Bump version run: | - cd src/rust cargo workspaces version ${{ github.event.inputs.version_bump }} --no-git-commit --yes - name: Commit version bump From 00b02e41105d1b5f3de38182b935dc0c23951072 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Sep 2025 15:41:54 +0000 Subject: [PATCH 02/65] chore: bump crate version (patch) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5946fed..859b278 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cjseq" -version = "0.4.0" +version = "0.4.1" description = "Create+process+modify+convert CityJSONSeq" authors = ["Hugo Ledoux "] edition = "2021" From 8eddb02d03e91de6e6972e2ea35eb13644220b82 Mon Sep 17 00:00:00 2001 From: HideBa <49897538+HideBa@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:54:36 +0200 Subject: [PATCH 03/65] ci: update WASM build command in publish-npm workflow (#10) --- .github/workflows/publish-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index ae42c8e..580e9c2 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -30,7 +30,7 @@ jobs: - name: Build WASM run: | - wasm-pack build --target web --no-pack + wasm-pack build --target web - name: Publish to NPM env: From e3b198a24dd2961679df3d84ef6992c96aee306b Mon Sep 17 00:00:00 2001 From: HideBa <49897538+HideBa@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:42:07 +0200 Subject: [PATCH 04/65] ci: enhance WASM build command with scope in publish-npm workflow (#11) --- .github/workflows/publish-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 580e9c2..7cc0c86 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -30,7 +30,7 @@ jobs: - name: Build WASM run: | - wasm-pack build --target web + wasm-pack build --target web --scope cityjson - name: Publish to NPM env: From a97bc2d44bfb3ae02acf8f72b7e688dfd2efccf2 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 16:26:52 +0200 Subject: [PATCH 05/65] refactor: split lib.rs into per-concern modules Pure code motion off upstream main, no behaviour change, as the base for the typed rewrite. --- Cargo.toml | 7 +- src/appearance.rs | 92 ++++ src/city_object.rs | 52 ++ src/cityjson.rs | 521 ++++++++++++++++++ src/geometry.rs | 443 +++++++++++++++ src/lib.rs | 1274 ++------------------------------------------ src/metadata.rs | 150 ++++++ 7 files changed, 1292 insertions(+), 1247 deletions(-) create mode 100644 src/appearance.rs create mode 100644 src/city_object.rs create mode 100644 src/cityjson.rs create mode 100644 src/geometry.rs create mode 100644 src/metadata.rs diff --git a/Cargo.toml b/Cargo.toml index 859b278..1a08645 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "cjseq" -version = "0.4.1" -description = "Create+process+modify+convert CityJSONSeq" +name = "cjseq2" +version = "0.2.0-alpha.0" +description = "Typed fork of cjseq (create+process+modify+convert CityJSONSeq) pending upstream merge" authors = ["Hugo Ledoux "] edition = "2021" license = "MIT" @@ -10,6 +10,7 @@ homepage = "https://github.com/cityjson/cjseq" repository = "https://github.com/cityjson/cjseq" [lib] +name = "cjseq" crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/appearance.rs b/src/appearance.rs new file mode 100644 index 0000000..181bef6 --- /dev/null +++ b/src/appearance.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Material { + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Texture { + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Appearance { + #[serde(skip_serializing_if = "Option::is_none")] + pub materials: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub textures: Option>, + #[serde(rename = "vertices-texture")] + #[serde(skip_serializing_if = "Option::is_none")] + pub vertices_texture: Option>>, + #[serde(rename = "default-theme-texture")] + #[serde(skip_serializing_if = "Option::is_none")] + pub default_theme_texture: Option, + #[serde(rename = "default-theme-material")] + #[serde(skip_serializing_if = "Option::is_none")] + pub default_theme_material: Option, +} +impl Appearance { + pub(crate) fn new() -> Self { + Appearance { + materials: None, + textures: None, + vertices_texture: None, + default_theme_texture: None, + default_theme_material: None, + } + } + pub(crate) fn add_material(&mut self, jm: Value) -> usize { + let re = match &mut self.materials { + Some(x) => match x.iter().position(|e| *e == jm) { + Some(y) => y, + None => { + x.push(jm); + x.len() - 1 + } + }, + None => { + let mut ls: Vec = Vec::new(); + ls.push(jm); + self.materials = Some(ls); + 0 + } + }; + re + } + pub(crate) fn add_texture(&mut self, jm: Value) -> usize { + let re = match &mut self.textures { + Some(x) => match x.iter().position(|e| *e == jm) { + Some(y) => y, + None => { + x.push(jm); + x.len() - 1 + } + }, + None => { + let mut ls: Vec = Vec::new(); + ls.push(jm); + self.textures = Some(ls); + 0 + } + }; + re + } + pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec>) { + match &mut self.vertices_texture { + Some(x) => { + x.append(&mut vs); + } + None => { + let mut ls: Vec> = Vec::new(); + ls.append(&mut vs); + self.vertices_texture = Some(ls); + } + }; + } +} diff --git a/src/city_object.rs b/src/city_object.rs new file mode 100644 index 0000000..9d20fdf --- /dev/null +++ b/src/city_object.rs @@ -0,0 +1,52 @@ +use crate::geometry::Geometry; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CityObject { + #[serde(rename = "type")] + pub thetype: String, + #[serde(rename = "geographicalExtent")] + #[serde(skip_serializing_if = "Option::is_none")] + pub geographical_extent: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub attributes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parents: Option>, + #[serde(flatten)] + other: serde_json::Value, +} + +impl CityObject { + pub fn get_type(&self) -> String { + self.thetype.clone() + } + pub(crate) fn is_toplevel(&self) -> bool { + match &self.parents { + Some(x) => { + if x.is_empty() { + return true; + } else { + return false; + } + } + None => return true, + } + } + pub(crate) fn get_children_keys(&self) -> Vec { + let mut re: Vec = Vec::new(); + match &self.children { + Some(x) => { + for each in x { + re.push(each.to_string()); + } + } + None => (), + } + re + } +} diff --git a/src/cityjson.rs b/src/cityjson.rs new file mode 100644 index 0000000..4616371 --- /dev/null +++ b/src/cityjson.rs @@ -0,0 +1,521 @@ +use crate::appearance::Appearance; +use crate::city_object::CityObject; +use crate::geometry::GeometryTemplates; +use crate::metadata::{Metadata, Transform}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Error, Value}; +use std::collections::HashMap; + +#[derive(Clone)] +pub enum SortingStrategy { + Random, + Lexicographical, + Morton, //-- TODO implement Morton sorting + Hilbert, //-- TODO implement Hilbert sorting +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CityJSON { + #[serde(rename = "type")] + pub thetype: String, + pub version: String, + pub transform: Transform, + #[serde(rename = "CityObjects")] + pub city_objects: HashMap, + pub vertices: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, + #[serde(rename = "geometry-templates")] + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_templates: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, + #[serde(flatten)] + pub other: serde_json::Value, + #[serde(skip)] + sorted_ids: Vec, + #[serde(skip)] + transform_correction: Option, +} +impl CityJSON { + /// Create a new CityJSON instance with default values. + pub fn new() -> Self { + let co: HashMap = HashMap::new(); + let v: Vec> = Vec::new(); + let tr = Transform::new(); + CityJSON { + thetype: "CityJSON".to_string(), + version: "2.0".to_string(), + transform: tr, + city_objects: co, + vertices: v, + metadata: None, + appearance: None, + geometry_templates: None, + extensions: None, + other: json!(null), + sorted_ids: vec![], + transform_correction: None, + } + } + /// Create a new CityJSON instance from a string. + pub fn from_str(s: &str) -> Result { + let mut cjj: CityJSON = serde_json::from_str(s)?; + //-- check if CO exists, then add them to the sorted_ids + for (key, co) in &cjj.city_objects { + if co.is_toplevel() { + cjj.sorted_ids.push(key.clone()); + } + } + Ok(cjj) + } + + /// Get the "first line" (aka metadata or header) of a CityJSONSeq + pub fn get_metadata(&self) -> Self { + let co: HashMap = HashMap::new(); + let v: Vec> = Vec::new(); + let mut cj0 = CityJSON { + thetype: self.thetype.clone(), + version: self.version.clone(), + transform: self.transform.clone(), + metadata: self.metadata.clone(), + city_objects: co, + vertices: v, + appearance: None, + geometry_templates: self.geometry_templates.clone(), + other: self.other.clone(), + extensions: self.extensions.clone(), + sorted_ids: vec![], + transform_correction: None, + }; + //-- if geometry-templates have material/textures then these need to be + //-- added to 1st line (metadata) + match &self.geometry_templates { + Some(x) => { + let mut gts2: GeometryTemplates = x.clone(); + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); + for g in &mut gts2.templates { + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + } + //-- "slice" materials + if self.appearance.is_some() { + let a = self.appearance.as_ref().unwrap(); + let mut acjf: Appearance = Appearance::new(); + acjf.default_theme_material = a.default_theme_material.clone(); + acjf.default_theme_texture = a.default_theme_texture.clone(); + if a.materials.is_some() { + let am = a.materials.as_ref().unwrap(); + let mut mats2: Vec = Vec::new(); + mats2.resize(m_oldnew.len(), json!(null)); + for (old, new) in &m_oldnew { + mats2[*new] = am[*old].clone(); + } + acjf.materials = Some(mats2); + } + if a.textures.is_some() { + let at = a.textures.as_ref().unwrap(); + let mut texs2: Vec = Vec::new(); + texs2.resize(t_oldnew.len(), json!(null)); + for (old, new) in &t_oldnew { + texs2[*new] = at[*old].clone(); + } + acjf.textures = Some(texs2); + } + if a.vertices_texture.is_some() { + let atv = a.vertices_texture.as_ref().unwrap(); + let mut t_new_vertices: Vec> = Vec::new(); + t_new_vertices.resize(t_v_oldnew.len(), vec![]); + for (old, new) in &t_v_oldnew { + t_new_vertices[*new] = atv[*old].clone(); + } + acjf.vertices_texture = Some(t_new_vertices); + } + cj0.appearance = Some(acjf); + } + } + None => (), + } + cj0 + } + /// Getter for the features in a CityJSON dataset. + /// Starts at 0, and return Option::None if the index is out of bounds. + pub fn get_cjfeature(&self, i: usize) -> Option { + let i2 = self.sorted_ids.get(i); + if i2.is_none() { + return None; + } + let obj = self.city_objects.get(i2.unwrap()); + if obj.is_none() { + return None; + } + let co = obj.unwrap(); + //-- the other lines + let mut cjf = CityJSONFeature::new(); + let mut co2: CityObject = co.clone(); + let mut g_vi_oldnew: HashMap = HashMap::new(); + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); + match &mut co2.geometry { + Some(x) => { + for g in x.iter_mut() { + g.update_geometry_boundaries(&mut g_vi_oldnew); + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + } + } + None => (), + } + cjf.add_co(self.sorted_ids[i].clone(), co2); + cjf.id = self.sorted_ids[i].to_string(); + //-- TODO: to fix: children-of-children? + //-- process all the children (only one-level lower) + for childkey in co.get_children_keys() { + let coc = self.city_objects.get(&childkey).unwrap(); + let mut coc2: CityObject = coc.clone(); + match &mut coc2.geometry { + Some(x) => { + for g in x.iter_mut() { + g.update_geometry_boundaries(&mut g_vi_oldnew); + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + } + } + None => (), + } + cjf.add_co(childkey.clone(), coc2); + } + //-- "slice" geometry vertices + let allvertices = &self.vertices; + let mut g_new_vertices: Vec> = Vec::new(); + g_new_vertices.resize(g_vi_oldnew.len(), vec![]); + for (old, new) in &g_vi_oldnew { + g_new_vertices[*new] = allvertices[*old].clone(); + } + cjf.vertices = g_new_vertices; + //-- "slice" materials + if self.appearance.is_some() { + let a = self.appearance.as_ref().unwrap(); + let mut acjf: Appearance = Appearance::new(); + acjf.default_theme_material = a.default_theme_material.clone(); + acjf.default_theme_texture = a.default_theme_texture.clone(); + if a.materials.is_some() { + let am = a.materials.as_ref().unwrap(); + let mut mats2: Vec = Vec::new(); + mats2.resize(m_oldnew.len(), json!(null)); + for (old, new) in &m_oldnew { + mats2[*new] = am[*old].clone(); + } + acjf.materials = Some(mats2); + } + if a.textures.is_some() { + let at = a.textures.as_ref().unwrap(); + let mut texs2: Vec = Vec::new(); + texs2.resize(t_oldnew.len(), json!(null)); + for (old, new) in &t_oldnew { + texs2[*new] = at[*old].clone(); + } + acjf.textures = Some(texs2); + } + if a.vertices_texture.is_some() { + let atv = a.vertices_texture.as_ref().unwrap(); + let mut t_new_vertices: Vec> = Vec::new(); + t_new_vertices.resize(t_v_oldnew.len(), vec![]); + for (old, new) in &t_v_oldnew { + t_new_vertices[*new] = atv[*old].clone(); + } + acjf.vertices_texture = Some(t_new_vertices); + } + cjf.appearance = Some(acjf); + } + Some(cjf) + } + /// Used when many CityJSONSeq are used, the "transform" can + /// be modified (the new value is a "correction"). + pub fn add_transform_correction(&mut self, t: Transform) { + self.transform_correction = Some(t); + } + pub fn add_cjfeature(&mut self, cjf: &mut CityJSONFeature) { + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); + let g_offset = self.vertices.len(); + let mut t_offset = 0; + if let Some(cjf_app) = &cjf.appearance { + if let Some(cjf_mat) = &cjf_app.materials { + for (i, m) in cjf_mat.iter().enumerate() { + m_oldnew.insert(i, self.add_material(m.clone())); + } + } + if let Some(cjf_tex) = &cjf_app.textures { + for (i, m) in cjf_tex.iter().enumerate() { + t_oldnew.insert(i, self.add_texture(m.clone())); + } + } + if let Some(cjf_v_tex) = &cjf_app.vertices_texture { + t_offset = cjf_v_tex.len(); + self.add_vertices_texture(cjf_v_tex.clone()); + } + } + + for (key, co) in &mut cjf.city_objects { + //-- boundaries + if let Some(ref mut geoms) = &mut co.geometry { + for g in geoms.iter_mut() { + //-- boundaries + g.offset_geometry_boundaries(g_offset); + // g.update_geometry_boundaries(&mut g_oldnew, g_offset); + //-- material + g.update_material(&mut m_oldnew); + //-- texture + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, t_offset); + } + } + //-- update the collected json object by adding the CityObjects + self.add_co(key.to_string(), co.clone()); + } + //-- add the new vertices + self.add_vertices(&mut cjf.vertices); + //-- add the CO id to the list + self.sorted_ids.push(cjf.id.clone()); + } + pub fn remove_duplicate_vertices(&mut self) { + // let totalinput = self.vertices.len(); + let mut h: HashMap = HashMap::new(); + let mut newids: HashMap = HashMap::new(); + let mut newvertices: Vec> = Vec::new(); + for (i, v) in self.vertices.iter().enumerate() { + // println!("{:?}", v); + let k = format!("{} {} {}", v[0], v[1], v[2]); + match h.get(&k) { + Some(x) => { + let _ = newids.insert(i, *x); + } + None => { + newids.insert(i, newvertices.len()); + h.insert(k.clone(), newvertices.len()); + newvertices.push(v.clone()); + } + } + } + //-- update indices + let cos = &mut self.city_objects; + for (_key, co) in cos.iter_mut() { + match &mut co.geometry { + Some(x) => { + for g in x.iter_mut() { + g.update_geometry_boundaries(&mut newids); + } + } + None => (), + } + } + //-- replace the vertices, innit? + self.vertices = newvertices; + } + pub fn update_geographicalextent(&mut self) { + if let Some(m) = &mut self.metadata { + if let Some(ref mut ge) = m.geographical_extent { + let mut mins: Vec = vec![i64::MAX, i64::MAX, i64::MAX]; + let mut maxs: Vec = vec![i64::MIN, i64::MIN, i64::MIN]; + for v in &self.vertices { + for i in 0..3 { + if v[i] < mins[i] { + mins[i] = v[i]; + } + if v[i] > maxs[i] { + maxs[i] = v[i]; + } + } + } + *ge = [ + mins[0] as f64 * self.transform.scale[0] + self.transform.translate[0], + mins[1] as f64 * self.transform.scale[1] + self.transform.translate[1], + mins[2] as f64 * self.transform.scale[2] + self.transform.translate[2], + maxs[0] as f64 * self.transform.scale[0] + self.transform.translate[0], + maxs[1] as f64 * self.transform.scale[1] + self.transform.translate[1], + maxs[2] as f64 * self.transform.scale[2] + self.transform.translate[2], + ]; + } + } + } + pub fn update_transform(&mut self) { + let mut newvertices: Vec> = Vec::new(); + let mut mins: Vec = vec![i64::MAX, i64::MAX, i64::MAX]; + //-- find min-xyz + for v in &self.vertices { + for i in 0..3 { + if v[i] < mins[i] { + mins[i] = v[i]; + } + } + } + //-- subtract the mins from each vertex + for v in &self.vertices { + let v: Vec = vec![v[0] - mins[0], v[1] - mins[1], v[2] - mins[2]]; + newvertices.push(v); + } + //-- replace the vertices, innit? + self.vertices = newvertices; + //-- update the transform/translate + let ttx = (mins[0] as f64 * self.transform.scale[0]) + self.transform.translate[0]; + let tty = (mins[1] as f64 * self.transform.scale[1]) + self.transform.translate[1]; + let ttz = (mins[2] as f64 * self.transform.scale[2]) + self.transform.translate[2]; + self.transform.translate = vec![ttx, tty, ttz]; + } + pub fn number_of_city_objects(&self) -> usize { + let mut total: usize = 0; + for (_key, co) in &self.city_objects { + if co.is_toplevel() { + total += 1; + } + } + total + } + /// When getting the CityJSONFeatures, this controls the order in which + /// they are returned. By default they are returned in the order they were added. + pub fn sort_cjfeatures(&mut self, ss: SortingStrategy) { + self.sorted_ids.clear(); + match ss { + SortingStrategy::Random => { + for (key, co) in &self.city_objects { + if co.is_toplevel() { + self.sorted_ids.push(key.clone()); + } + } + } + SortingStrategy::Lexicographical => { + for (key, co) in &self.city_objects { + if co.is_toplevel() { + self.sorted_ids.push(key.clone()); + } + } + self.sorted_ids.sort(); + } + _ => todo!(), + } + } + fn add_co(&mut self, id: String, co: CityObject) { + self.city_objects.insert(id.clone(), co); + } + fn add_vertices(&mut self, vs: &mut Vec>) { + if self.transform_correction.is_none() { + self.vertices.append(vs); + } else { + //-- the transfrom correction needs to be applied + let c = self.transform_correction.as_ref().unwrap(); + for v in vs { + let cx: i64 = (((v[0] as f64 * c.scale[0]) + c.translate[0] + - self.transform.translate[0]) + / self.transform.scale[0]) + .round() as i64; + let cy: i64 = (((v[1] as f64 * c.scale[1]) + c.translate[1] + - self.transform.translate[1]) + / self.transform.scale[1]) + .round() as i64; + let cz: i64 = (((v[2] as f64 * c.scale[2]) + c.translate[2] + - self.transform.translate[2]) + / self.transform.scale[2]) + .round() as i64; + self.vertices.push(vec![cx, cy, cz]); + } + } + } + fn add_vertices_texture(&mut self, vs: Vec>) { + match &mut self.appearance { + Some(x) => x.add_vertices_texture(vs), + None => { + let mut a: Appearance = Appearance::new(); + a.add_vertices_texture(vs); + self.appearance = Some(a); + } + }; + } + fn add_material(&mut self, jm: Value) -> usize { + let re = match &mut self.appearance { + Some(x) => x.add_material(jm), + None => { + let mut a: Appearance = Appearance::new(); + let re = a.add_material(jm); + self.appearance = Some(a); + re + } + }; + re + } + fn add_texture(&mut self, jm: Value) -> usize { + let re = match &mut self.appearance { + Some(x) => x.add_texture(jm), + None => { + let mut a: Appearance = Appearance::new(); + let re = a.add_texture(jm); + self.appearance = Some(a); + re + } + }; + re + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CityJSONFeature { + #[serde(rename = "type")] + pub thetype: String, + pub id: String, + #[serde(rename = "CityObjects")] + pub city_objects: HashMap, + pub vertices: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, +} +impl CityJSONFeature { + pub fn new() -> Self { + let co: HashMap = HashMap::new(); + let v: Vec> = Vec::new(); + CityJSONFeature { + thetype: "CityJSONFeature".to_string(), + id: "".to_string(), + city_objects: co, + vertices: v, + appearance: None, + } + } + pub fn from_str(s: &str) -> Result { + let cjf: CityJSONFeature = serde_json::from_str(&s)?; + Ok(cjf) + } + pub fn add_co(&mut self, id: String, co: CityObject) { + self.city_objects.insert(id, co); + } + pub fn centroid(&self) -> Vec { + let mut totals: Vec = vec![0., 0., 0.]; + for v in &self.vertices { + for i in 0..3 { + totals[i] += v[i] as f64; + } + } + for i in 0..3 { + totals[i] /= self.vertices.len() as f64; + } + return totals; + } +} + +/// Collects a base CityJSON metadata and a vector of CityJSONFeatures +/// into a complete CityJSON object +pub fn cjseq_to_cj(mut base_cj: CityJSON, features: Vec) -> CityJSON { + for mut feature in features { + base_cj.add_cjfeature(&mut feature); + } + + base_cj.remove_duplicate_vertices(); + base_cj.update_transform(); + base_cj.update_geographicalextent(); + + base_cj +} diff --git a/src/geometry.rs b/src/geometry.rs new file mode 100644 index 0000000..5244c79 --- /dev/null +++ b/src/geometry.rs @@ -0,0 +1,443 @@ +use crate::appearance::{Material, Texture}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum GeometryType { + MultiPoint, + MultiLineString, + MultiSurface, + CompositeSurface, + Solid, + MultiSolid, + CompositeSolid, + GeometryInstance, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Geometry { + #[serde(rename = "type")] + pub thetype: GeometryType, + #[serde(skip_serializing_if = "Option::is_none")] + pub lod: Option, + pub boundaries: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub semantics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub material: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub texture: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + #[serde(rename = "transformationMatrix")] + #[serde(skip_serializing_if = "Option::is_none")] + pub transformation_matrix: Option, +} +impl Geometry { + pub(crate) fn update_geometry_boundaries(&mut self, violdnew: &mut HashMap) { + match self.thetype { + GeometryType::MultiPoint => { + let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + let kk = violdnew.get(&x); + if kk.is_none() { + let l = violdnew.len(); + violdnew.insert(*x, l); + a2[i] = l; + } else { + let kk = kk.unwrap(); + a2[i] = *kk; + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::MultiLineString => { + let a: Vec> = serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + // r.push(z); + let kk = violdnew.get(&y); + if kk.is_none() { + let l = violdnew.len(); + violdnew.insert(*y, l); + a2[i][j] = l; + } else { + let kk = kk.unwrap(); + a2[i][j] = *kk; + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::MultiSurface | GeometryType::CompositeSurface => { + let a: Vec>> = + serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + let kk = violdnew.get(&z); + if kk.is_none() { + let l = violdnew.len(); + violdnew.insert(*z, l); + a2[i][j][k] = l; + } else { + let kk = kk.unwrap(); + a2[i][j][k] = *kk; + } + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::Solid => { + let a: Vec>>> = + serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + for (l, zz) in z.iter().enumerate() { + let kk = violdnew.get(&zz); + if kk.is_none() { + let l2 = violdnew.len(); + violdnew.insert(*zz, l2); + a2[i][j][k][l] = l2; + } else { + let kk = kk.unwrap(); + a2[i][j][k][l] = *kk; + } + } + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::MultiSolid | GeometryType::CompositeSolid => { + let a: Vec>>>> = + serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + for (l, zz) in z.iter().enumerate() { + for (m, zzz) in zz.iter().enumerate() { + let kk = violdnew.get(&zzz); + if kk.is_none() { + let l2 = violdnew.len(); + violdnew.insert(*zzz, l2); + a2[i][j][k][l][m] = l2; + } else { + let kk = kk.unwrap(); + a2[i][j][k][l][m] = *kk; + } + } + } + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::GeometryInstance => { + let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + let kk = violdnew.get(&x); + if kk.is_none() { + let l = violdnew.len(); + violdnew.insert(*x, l); + a2[i] = l; + } else { + let kk = kk.unwrap(); + a2[i] = *kk; + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + } + } + + pub(crate) fn offset_geometry_boundaries(&mut self, offset: usize) { + match self.thetype { + GeometryType::MultiPoint => { + let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + a2[i] = *x + offset; + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::MultiLineString => { + let a: Vec> = serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + // r.push(z); + a2[i][j] = *y + offset; + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::MultiSurface | GeometryType::CompositeSurface => { + let a: Vec>> = + serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + a2[i][j][k] = *z + offset; + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::Solid => { + let a: Vec>>> = + serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + for (l, zz) in z.iter().enumerate() { + a2[i][j][k][l] = *zz + offset; + } + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::MultiSolid | GeometryType::CompositeSolid => { + let a: Vec>>>> = + serde_json::from_value(self.boundaries.take()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + for (l, zz) in z.iter().enumerate() { + for (m, zzz) in zz.iter().enumerate() { + a2[i][j][k][l][m] = *zzz + offset; + } + } + } + } + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + GeometryType::GeometryInstance => { + let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + a2[i] = *x + offset; + } + self.boundaries = serde_json::to_value(&a2).unwrap(); + } + } + } + + pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { + match &mut self.material { + Some(x) => { + for (_key, mat) in &mut *x { + //-- material.value + if mat.value.is_some() { + let thevalue: usize = mat.value.unwrap(); + let r = m_oldnew.get(&thevalue); + if r.is_none() { + let l = m_oldnew.len(); + m_oldnew.insert(thevalue, l); + mat.value = Some(l); + } else { + let r2 = r.unwrap(); + mat.value = Some(*r2); + } + continue; + } + //-- else it's material.values (which differs per geom type) + match self.thetype { + GeometryType::MultiPoint | GeometryType::MultiLineString => (), + GeometryType::MultiSurface | GeometryType::CompositeSurface => { + if mat.values.is_some() { + let a: Vec> = + serde_json::from_value(mat.values.take().into()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + if x.is_some() { + let y2 = m_oldnew.get(&x.unwrap()); + if y2.is_none() { + let l = m_oldnew.len(); + m_oldnew.insert(x.unwrap(), l); + a2[i] = Some(l); + } else { + let y2 = y2.unwrap(); + a2[i] = Some(*y2); + } + } + } + mat.values = Some(serde_json::to_value(&a2).unwrap()); + } + } + GeometryType::Solid => { + if mat.values.is_some() { + let a: Vec>> = + serde_json::from_value(mat.values.take().into()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + if y.is_some() { + let y2 = m_oldnew.get(&y.unwrap()); + if y2.is_none() { + let l = m_oldnew.len(); + m_oldnew.insert(y.unwrap(), l); + a2[i][j] = Some(l); + } else { + let y2 = y2.unwrap(); + a2[i][j] = Some(*y2); + } + } + } + } + mat.values = Some(serde_json::to_value(&a2).unwrap()); + } + } + GeometryType::MultiSolid | GeometryType::CompositeSolid => { + if mat.values.is_some() { + let a: Vec>>> = + serde_json::from_value(mat.values.take().into()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + if z.is_some() { + let y2 = m_oldnew.get(&z.unwrap()); + if y2.is_none() { + let l = m_oldnew.len(); + m_oldnew.insert(z.unwrap(), l); + a2[i][j][k] = Some(l); + } else { + let y2 = y2.unwrap(); + a2[i][j][k] = Some(*y2); + } + } + } + } + } + mat.values = Some(serde_json::to_value(&a2).unwrap()); + } + } + GeometryType::GeometryInstance => todo!(), + } + } + self.material = Some(x.clone()); + } + None => (), + } + } + pub(crate) fn update_texture( + &mut self, + t_oldnew: &mut HashMap, + t_v_oldnew: &mut HashMap, + offset: usize, + ) { + match &mut self.texture { + Some(x) => { + for (_key, tex) in &mut *x { + match self.thetype { + GeometryType::MultiSurface | GeometryType::CompositeSurface => { + let a: Vec>>> = + serde_json::from_value(tex.values.take().into()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + if z.is_some() { + let thevalue: usize = z.unwrap(); + if k == 0 { + let y2 = t_oldnew.get(&thevalue); + if y2.is_none() { + let l = t_oldnew.len(); + t_oldnew.insert(thevalue, l); + a2[i][j][k] = Some(l); + } else { + let y2 = y2.unwrap(); + a2[i][j][k] = Some(*y2); + } + } else { + let y2 = t_v_oldnew.get(&thevalue); + if y2.is_none() { + let l = t_v_oldnew.len(); + t_v_oldnew.insert(thevalue, l + offset); + a2[i][j][k] = Some(l); + } else { + let y2 = y2.unwrap(); + a2[i][j][k] = Some(*y2); + } + } + } + } + } + } + tex.values = Some(serde_json::to_value(&a2).unwrap()); + } + GeometryType::Solid => { + let a: Vec>>>> = + serde_json::from_value(tex.values.take().into()).unwrap(); + let mut a2 = a.clone(); + for (i, x) in a.iter().enumerate() { + for (j, y) in x.iter().enumerate() { + for (k, z) in y.iter().enumerate() { + for (l, zz) in z.iter().enumerate() { + if zz.is_some() { + let thevalue: usize = zz.unwrap(); + if l == 0 { + let y2 = t_oldnew.get(&thevalue); + if y2.is_none() { + let l2 = t_oldnew.len(); + t_oldnew.insert(thevalue, l2); + a2[i][j][k][l] = Some(l2); + } else { + let y2 = y2.unwrap(); + a2[i][j][k][l] = Some(*y2); + } + } else { + let y2 = t_v_oldnew.get(&thevalue); + if y2.is_none() { + let l2 = t_v_oldnew.len(); + t_v_oldnew.insert(thevalue, l2 + offset); + a2[i][j][k][l] = Some(l2); + } else { + let y2 = y2.unwrap(); + a2[i][j][k][l] = Some(*y2); + } + } + } + } + } + } + } + tex.values = Some(serde_json::to_value(&a2).unwrap()); + } + _ => todo!(), + } + } + } + None => (), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct Vertex { + x: i64, + y: i64, + z: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GeometryTemplates { + pub templates: Vec, + #[serde(rename = "vertices-templates")] + pub vertices_templates: Value, +} diff --git a/src/lib.rs b/src/lib.rs index bce0202..f7104f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,1247 +1,33 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Error, Value}; -use std::collections::HashMap; - -const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; - -#[derive(Clone)] -pub enum SortingStrategy { - Random, - Lexicographical, - Morton, //-- TODO implement Morton sorting - Hilbert, //-- TODO implement Hilbert sorting -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CityJSON { - #[serde(rename = "type")] - pub thetype: String, - pub version: String, - pub transform: Transform, - #[serde(rename = "CityObjects")] - pub city_objects: HashMap, - pub vertices: Vec>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub appearance: Option, - #[serde(rename = "geometry-templates")] - #[serde(skip_serializing_if = "Option::is_none")] - pub geometry_templates: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option, - #[serde(flatten)] - pub other: serde_json::Value, - #[serde(skip)] - sorted_ids: Vec, - #[serde(skip)] - transform_correction: Option, -} -impl CityJSON { - /// Create a new CityJSON instance with default values. - pub fn new() -> Self { - let co: HashMap = HashMap::new(); - let v: Vec> = Vec::new(); - let tr = Transform::new(); - CityJSON { - thetype: "CityJSON".to_string(), - version: "2.0".to_string(), - transform: tr, - city_objects: co, - vertices: v, - metadata: None, - appearance: None, - geometry_templates: None, - extensions: None, - other: json!(null), - sorted_ids: vec![], - transform_correction: None, - } - } - /// Create a new CityJSON instance from a string. - pub fn from_str(s: &str) -> Result { - let mut cjj: CityJSON = serde_json::from_str(s)?; - //-- check if CO exists, then add them to the sorted_ids - for (key, co) in &cjj.city_objects { - if co.is_toplevel() { - cjj.sorted_ids.push(key.clone()); - } - } - Ok(cjj) - } - - /// Get the "first line" (aka metadata or header) of a CityJSONSeq - pub fn get_metadata(&self) -> Self { - let co: HashMap = HashMap::new(); - let v: Vec> = Vec::new(); - let mut cj0 = CityJSON { - thetype: self.thetype.clone(), - version: self.version.clone(), - transform: self.transform.clone(), - metadata: self.metadata.clone(), - city_objects: co, - vertices: v, - appearance: None, - geometry_templates: self.geometry_templates.clone(), - other: self.other.clone(), - extensions: self.extensions.clone(), - sorted_ids: vec![], - transform_correction: None, - }; - //-- if geometry-templates have material/textures then these need to be - //-- added to 1st line (metadata) - match &self.geometry_templates { - Some(x) => { - let mut gts2: GeometryTemplates = x.clone(); - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); - for g in &mut gts2.templates { - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); - } - //-- "slice" materials - if self.appearance.is_some() { - let a = self.appearance.as_ref().unwrap(); - let mut acjf: Appearance = Appearance::new(); - acjf.default_theme_material = a.default_theme_material.clone(); - acjf.default_theme_texture = a.default_theme_texture.clone(); - if a.materials.is_some() { - let am = a.materials.as_ref().unwrap(); - let mut mats2: Vec = Vec::new(); - mats2.resize(m_oldnew.len(), json!(null)); - for (old, new) in &m_oldnew { - mats2[*new] = am[*old].clone(); - } - acjf.materials = Some(mats2); - } - if a.textures.is_some() { - let at = a.textures.as_ref().unwrap(); - let mut texs2: Vec = Vec::new(); - texs2.resize(t_oldnew.len(), json!(null)); - for (old, new) in &t_oldnew { - texs2[*new] = at[*old].clone(); - } - acjf.textures = Some(texs2); - } - if a.vertices_texture.is_some() { - let atv = a.vertices_texture.as_ref().unwrap(); - let mut t_new_vertices: Vec> = Vec::new(); - t_new_vertices.resize(t_v_oldnew.len(), vec![]); - for (old, new) in &t_v_oldnew { - t_new_vertices[*new] = atv[*old].clone(); - } - acjf.vertices_texture = Some(t_new_vertices); - } - cj0.appearance = Some(acjf); - } - } - None => (), - } - cj0 - } - /// Getter for the features in a CityJSON dataset. - /// Starts at 0, and return Option::None if the index is out of bounds. - pub fn get_cjfeature(&self, i: usize) -> Option { - let i2 = self.sorted_ids.get(i); - if i2.is_none() { - return None; - } - let obj = self.city_objects.get(i2.unwrap()); - if obj.is_none() { - return None; - } - let co = obj.unwrap(); - //-- the other lines - let mut cjf = CityJSONFeature::new(); - let mut co2: CityObject = co.clone(); - let mut g_vi_oldnew: HashMap = HashMap::new(); - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); - match &mut co2.geometry { - Some(x) => { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut g_vi_oldnew); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); - } - } - None => (), - } - cjf.add_co(self.sorted_ids[i].clone(), co2); - cjf.id = self.sorted_ids[i].to_string(); - //-- TODO: to fix: children-of-children? - //-- process all the children (only one-level lower) - for childkey in co.get_children_keys() { - let coc = self.city_objects.get(&childkey).unwrap(); - let mut coc2: CityObject = coc.clone(); - match &mut coc2.geometry { - Some(x) => { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut g_vi_oldnew); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); - } - } - None => (), - } - cjf.add_co(childkey.clone(), coc2); - } - //-- "slice" geometry vertices - let allvertices = &self.vertices; - let mut g_new_vertices: Vec> = Vec::new(); - g_new_vertices.resize(g_vi_oldnew.len(), vec![]); - for (old, new) in &g_vi_oldnew { - g_new_vertices[*new] = allvertices[*old].clone(); - } - cjf.vertices = g_new_vertices; - //-- "slice" materials - if self.appearance.is_some() { - let a = self.appearance.as_ref().unwrap(); - let mut acjf: Appearance = Appearance::new(); - acjf.default_theme_material = a.default_theme_material.clone(); - acjf.default_theme_texture = a.default_theme_texture.clone(); - if a.materials.is_some() { - let am = a.materials.as_ref().unwrap(); - let mut mats2: Vec = Vec::new(); - mats2.resize(m_oldnew.len(), json!(null)); - for (old, new) in &m_oldnew { - mats2[*new] = am[*old].clone(); - } - acjf.materials = Some(mats2); - } - if a.textures.is_some() { - let at = a.textures.as_ref().unwrap(); - let mut texs2: Vec = Vec::new(); - texs2.resize(t_oldnew.len(), json!(null)); - for (old, new) in &t_oldnew { - texs2[*new] = at[*old].clone(); - } - acjf.textures = Some(texs2); - } - if a.vertices_texture.is_some() { - let atv = a.vertices_texture.as_ref().unwrap(); - let mut t_new_vertices: Vec> = Vec::new(); - t_new_vertices.resize(t_v_oldnew.len(), vec![]); - for (old, new) in &t_v_oldnew { - t_new_vertices[*new] = atv[*old].clone(); - } - acjf.vertices_texture = Some(t_new_vertices); - } - cjf.appearance = Some(acjf); - } - Some(cjf) - } - /// Used when many CityJSONSeq are used, the "transform" can - /// be modified (the new value is a "correction"). - pub fn add_transform_correction(&mut self, t: Transform) { - self.transform_correction = Some(t); - } - pub fn add_cjfeature(&mut self, cjf: &mut CityJSONFeature) { - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); - let g_offset = self.vertices.len(); - let mut t_offset = 0; - if let Some(cjf_app) = &cjf.appearance { - if let Some(cjf_mat) = &cjf_app.materials { - for (i, m) in cjf_mat.iter().enumerate() { - m_oldnew.insert(i, self.add_material(m.clone())); - } - } - if let Some(cjf_tex) = &cjf_app.textures { - for (i, m) in cjf_tex.iter().enumerate() { - t_oldnew.insert(i, self.add_texture(m.clone())); - } - } - if let Some(cjf_v_tex) = &cjf_app.vertices_texture { - t_offset = cjf_v_tex.len(); - self.add_vertices_texture(cjf_v_tex.clone()); - } - } - - for (key, co) in &mut cjf.city_objects { - //-- boundaries - if let Some(ref mut geoms) = &mut co.geometry { - for g in geoms.iter_mut() { - //-- boundaries - g.offset_geometry_boundaries(g_offset); - // g.update_geometry_boundaries(&mut g_oldnew, g_offset); - //-- material - g.update_material(&mut m_oldnew); - //-- texture - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, t_offset); - } - } - //-- update the collected json object by adding the CityObjects - self.add_co(key.to_string(), co.clone()); - } - //-- add the new vertices - self.add_vertices(&mut cjf.vertices); - //-- add the CO id to the list - self.sorted_ids.push(cjf.id.clone()); - } - pub fn remove_duplicate_vertices(&mut self) { - // let totalinput = self.vertices.len(); - let mut h: HashMap = HashMap::new(); - let mut newids: HashMap = HashMap::new(); - let mut newvertices: Vec> = Vec::new(); - for (i, v) in self.vertices.iter().enumerate() { - // println!("{:?}", v); - let k = format!("{} {} {}", v[0], v[1], v[2]); - match h.get(&k) { - Some(x) => { - let _ = newids.insert(i, *x); - } - None => { - newids.insert(i, newvertices.len()); - h.insert(k.clone(), newvertices.len()); - newvertices.push(v.clone()); - } - } - } - //-- update indices - let cos = &mut self.city_objects; - for (_key, co) in cos.iter_mut() { - match &mut co.geometry { - Some(x) => { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut newids); - } - } - None => (), - } - } - //-- replace the vertices, innit? - self.vertices = newvertices; - } - pub fn update_geographicalextent(&mut self) { - if let Some(m) = &mut self.metadata { - if let Some(ref mut ge) = m.geographical_extent { - let mut mins: Vec = vec![i64::MAX, i64::MAX, i64::MAX]; - let mut maxs: Vec = vec![i64::MIN, i64::MIN, i64::MIN]; - for v in &self.vertices { - for i in 0..3 { - if v[i] < mins[i] { - mins[i] = v[i]; - } - if v[i] > maxs[i] { - maxs[i] = v[i]; - } - } - } - *ge = [ - mins[0] as f64 * self.transform.scale[0] + self.transform.translate[0], - mins[1] as f64 * self.transform.scale[1] + self.transform.translate[1], - mins[2] as f64 * self.transform.scale[2] + self.transform.translate[2], - maxs[0] as f64 * self.transform.scale[0] + self.transform.translate[0], - maxs[1] as f64 * self.transform.scale[1] + self.transform.translate[1], - maxs[2] as f64 * self.transform.scale[2] + self.transform.translate[2], - ]; - } - } - } - pub fn update_transform(&mut self) { - let mut newvertices: Vec> = Vec::new(); - let mut mins: Vec = vec![i64::MAX, i64::MAX, i64::MAX]; - //-- find min-xyz - for v in &self.vertices { - for i in 0..3 { - if v[i] < mins[i] { - mins[i] = v[i]; - } - } - } - //-- subtract the mins from each vertex - for v in &self.vertices { - let v: Vec = vec![v[0] - mins[0], v[1] - mins[1], v[2] - mins[2]]; - newvertices.push(v); - } - //-- replace the vertices, innit? - self.vertices = newvertices; - //-- update the transform/translate - let ttx = (mins[0] as f64 * self.transform.scale[0]) + self.transform.translate[0]; - let tty = (mins[1] as f64 * self.transform.scale[1]) + self.transform.translate[1]; - let ttz = (mins[2] as f64 * self.transform.scale[2]) + self.transform.translate[2]; - self.transform.translate = vec![ttx, tty, ttz]; - } - pub fn number_of_city_objects(&self) -> usize { - let mut total: usize = 0; - for (_key, co) in &self.city_objects { - if co.is_toplevel() { - total += 1; - } - } - total - } - /// When getting the CityJSONFeatures, this controls the order in which - /// they are returned. By default they are returned in the order they were added. - pub fn sort_cjfeatures(&mut self, ss: SortingStrategy) { - self.sorted_ids.clear(); - match ss { - SortingStrategy::Random => { - for (key, co) in &self.city_objects { - if co.is_toplevel() { - self.sorted_ids.push(key.clone()); - } - } - } - SortingStrategy::Lexicographical => { - for (key, co) in &self.city_objects { - if co.is_toplevel() { - self.sorted_ids.push(key.clone()); - } - } - self.sorted_ids.sort(); - } - _ => todo!(), - } - } - fn add_co(&mut self, id: String, co: CityObject) { - self.city_objects.insert(id.clone(), co); - } - fn add_vertices(&mut self, vs: &mut Vec>) { - if self.transform_correction.is_none() { - self.vertices.append(vs); - } else { - //-- the transfrom correction needs to be applied - let c = self.transform_correction.as_ref().unwrap(); - for v in vs { - let cx: i64 = (((v[0] as f64 * c.scale[0]) + c.translate[0] - - self.transform.translate[0]) - / self.transform.scale[0]) - .round() as i64; - let cy: i64 = (((v[1] as f64 * c.scale[1]) + c.translate[1] - - self.transform.translate[1]) - / self.transform.scale[1]) - .round() as i64; - let cz: i64 = (((v[2] as f64 * c.scale[2]) + c.translate[2] - - self.transform.translate[2]) - / self.transform.scale[2]) - .round() as i64; - self.vertices.push(vec![cx, cy, cz]); - } - } - } - fn add_vertices_texture(&mut self, vs: Vec>) { - match &mut self.appearance { - Some(x) => x.add_vertices_texture(vs), - None => { - let mut a: Appearance = Appearance::new(); - a.add_vertices_texture(vs); - self.appearance = Some(a); - } - }; - } - fn add_material(&mut self, jm: Value) -> usize { - let re = match &mut self.appearance { - Some(x) => x.add_material(jm), - None => { - let mut a: Appearance = Appearance::new(); - let re = a.add_material(jm); - self.appearance = Some(a); - re - } - }; - re - } - fn add_texture(&mut self, jm: Value) -> usize { - let re = match &mut self.appearance { - Some(x) => x.add_texture(jm), - None => { - let mut a: Appearance = Appearance::new(); - let re = a.add_texture(jm); - self.appearance = Some(a); - re - } - }; - re - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CityJSONFeature { - #[serde(rename = "type")] - pub thetype: String, - pub id: String, - #[serde(rename = "CityObjects")] - pub city_objects: HashMap, - pub vertices: Vec>, - #[serde(skip_serializing_if = "Option::is_none")] - pub appearance: Option, -} -impl CityJSONFeature { - pub fn new() -> Self { - let co: HashMap = HashMap::new(); - let v: Vec> = Vec::new(); - CityJSONFeature { - thetype: "CityJSONFeature".to_string(), - id: "".to_string(), - city_objects: co, - vertices: v, - appearance: None, - } - } - pub fn from_str(s: &str) -> Result { - let cjf: CityJSONFeature = serde_json::from_str(&s)?; - Ok(cjf) - } - pub fn add_co(&mut self, id: String, co: CityObject) { - self.city_objects.insert(id, co); - } - pub fn centroid(&self) -> Vec { - let mut totals: Vec = vec![0., 0., 0.]; - for v in &self.vertices { - for i in 0..3 { - totals[i] += v[i] as f64; - } - } - for i in 0..3 { - totals[i] /= self.vertices.len() as f64; - } - return totals; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CityObject { - #[serde(rename = "type")] - pub thetype: String, - #[serde(rename = "geographicalExtent")] - #[serde(skip_serializing_if = "Option::is_none")] - pub geographical_extent: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub attributes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub geometry: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub children: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub parents: Option>, - #[serde(flatten)] - other: serde_json::Value, -} - -impl CityObject { - pub fn get_type(&self) -> String { - self.thetype.clone() - } - fn is_toplevel(&self) -> bool { - match &self.parents { - Some(x) => { - if x.is_empty() { - return true; - } else { - return false; - } - } - None => return true, - } - } - fn get_children_keys(&self) -> Vec { - let mut re: Vec = Vec::new(); - match &self.children { - Some(x) => { - for each in x { - re.push(each.to_string()); - } - } - None => (), - } - re - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub enum GeometryType { - MultiPoint, - MultiLineString, - MultiSurface, - CompositeSurface, - Solid, - MultiSolid, - CompositeSolid, - GeometryInstance, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Geometry { - #[serde(rename = "type")] - pub thetype: GeometryType, - #[serde(skip_serializing_if = "Option::is_none")] - pub lod: Option, - pub boundaries: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub semantics: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub material: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub texture: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub template: Option, - #[serde(rename = "transformationMatrix")] - #[serde(skip_serializing_if = "Option::is_none")] - pub transformation_matrix: Option, -} -impl Geometry { - fn update_geometry_boundaries(&mut self, violdnew: &mut HashMap) { - match self.thetype { - GeometryType::MultiPoint => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - let kk = violdnew.get(&x); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*x, l); - a2[i] = l; - } else { - let kk = kk.unwrap(); - a2[i] = *kk; - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiLineString => { - let a: Vec> = serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - // r.push(z); - let kk = violdnew.get(&y); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*y, l); - a2[i][j] = l; - } else { - let kk = kk.unwrap(); - a2[i][j] = *kk; - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let a: Vec>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - let kk = violdnew.get(&z); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*z, l); - a2[i][j][k] = l; - } else { - let kk = kk.unwrap(); - a2[i][j][k] = *kk; - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::Solid => { - let a: Vec>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - let kk = violdnew.get(&zz); - if kk.is_none() { - let l2 = violdnew.len(); - violdnew.insert(*zz, l2); - a2[i][j][k][l] = l2; - } else { - let kk = kk.unwrap(); - a2[i][j][k][l] = *kk; - } - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - let a: Vec>>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - for (m, zzz) in zz.iter().enumerate() { - let kk = violdnew.get(&zzz); - if kk.is_none() { - let l2 = violdnew.len(); - violdnew.insert(*zzz, l2); - a2[i][j][k][l][m] = l2; - } else { - let kk = kk.unwrap(); - a2[i][j][k][l][m] = *kk; - } - } - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::GeometryInstance => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - let kk = violdnew.get(&x); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*x, l); - a2[i] = l; - } else { - let kk = kk.unwrap(); - a2[i] = *kk; - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - } - } - - fn offset_geometry_boundaries(&mut self, offset: usize) { - match self.thetype { - GeometryType::MultiPoint => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - a2[i] = *x + offset; - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiLineString => { - let a: Vec> = serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - // r.push(z); - a2[i][j] = *y + offset; - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let a: Vec>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - a2[i][j][k] = *z + offset; - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::Solid => { - let a: Vec>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - a2[i][j][k][l] = *zz + offset; - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - let a: Vec>>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - for (m, zzz) in zz.iter().enumerate() { - a2[i][j][k][l][m] = *zzz + offset; - } - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::GeometryInstance => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - a2[i] = *x + offset; - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - } - } - - fn update_material(&mut self, m_oldnew: &mut HashMap) { - match &mut self.material { - Some(x) => { - for (_key, mat) in &mut *x { - //-- material.value - if mat.value.is_some() { - let thevalue: usize = mat.value.unwrap(); - let r = m_oldnew.get(&thevalue); - if r.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(thevalue, l); - mat.value = Some(l); - } else { - let r2 = r.unwrap(); - mat.value = Some(*r2); - } - continue; - } - //-- else it's material.values (which differs per geom type) - match self.thetype { - GeometryType::MultiPoint | GeometryType::MultiLineString => (), - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - if mat.values.is_some() { - let a: Vec> = - serde_json::from_value(mat.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - if x.is_some() { - let y2 = m_oldnew.get(&x.unwrap()); - if y2.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(x.unwrap(), l); - a2[i] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i] = Some(*y2); - } - } - } - mat.values = Some(serde_json::to_value(&a2).unwrap()); - } - } - GeometryType::Solid => { - if mat.values.is_some() { - let a: Vec>> = - serde_json::from_value(mat.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - if y.is_some() { - let y2 = m_oldnew.get(&y.unwrap()); - if y2.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(y.unwrap(), l); - a2[i][j] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j] = Some(*y2); - } - } - } - } - mat.values = Some(serde_json::to_value(&a2).unwrap()); - } - } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - if mat.values.is_some() { - let a: Vec>>> = - serde_json::from_value(mat.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - if z.is_some() { - let y2 = m_oldnew.get(&z.unwrap()); - if y2.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(z.unwrap(), l); - a2[i][j][k] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j][k] = Some(*y2); - } - } - } - } - } - mat.values = Some(serde_json::to_value(&a2).unwrap()); - } - } - GeometryType::GeometryInstance => todo!(), - } - } - self.material = Some(x.clone()); - } - None => (), - } - } - fn update_texture( - &mut self, - t_oldnew: &mut HashMap, - t_v_oldnew: &mut HashMap, - offset: usize, - ) { - match &mut self.texture { - Some(x) => { - for (_key, tex) in &mut *x { - match self.thetype { - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let a: Vec>>> = - serde_json::from_value(tex.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - if z.is_some() { - let thevalue: usize = z.unwrap(); - if k == 0 { - let y2 = t_oldnew.get(&thevalue); - if y2.is_none() { - let l = t_oldnew.len(); - t_oldnew.insert(thevalue, l); - a2[i][j][k] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j][k] = Some(*y2); - } - } else { - let y2 = t_v_oldnew.get(&thevalue); - if y2.is_none() { - let l = t_v_oldnew.len(); - t_v_oldnew.insert(thevalue, l + offset); - a2[i][j][k] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j][k] = Some(*y2); - } - } - } - } - } - } - tex.values = Some(serde_json::to_value(&a2).unwrap()); - } - GeometryType::Solid => { - let a: Vec>>>> = - serde_json::from_value(tex.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - if zz.is_some() { - let thevalue: usize = zz.unwrap(); - if l == 0 { - let y2 = t_oldnew.get(&thevalue); - if y2.is_none() { - let l2 = t_oldnew.len(); - t_oldnew.insert(thevalue, l2); - a2[i][j][k][l] = Some(l2); - } else { - let y2 = y2.unwrap(); - a2[i][j][k][l] = Some(*y2); - } - } else { - let y2 = t_v_oldnew.get(&thevalue); - if y2.is_none() { - let l2 = t_v_oldnew.len(); - t_v_oldnew.insert(thevalue, l2 + offset); - a2[i][j][k][l] = Some(l2); - } else { - let y2 = y2.unwrap(); - a2[i][j][k][l] = Some(*y2); - } - } - } - } - } - } - } - tex.values = Some(serde_json::to_value(&a2).unwrap()); - } - _ => todo!(), - } - } - } - None => (), - } - } -} - -#[derive(Serialize, Deserialize, Debug)] -struct Vertex { - x: i64, - y: i64, - z: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Transform { - pub scale: Vec, - pub translate: Vec, -} -impl Transform { - fn new() -> Self { - Transform { - scale: vec![1.0, 1.0, 1.0], - translate: vec![0., 0., 0.], - } - } -} - -pub type GeographicalExtent = [f64; 6]; - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Address { - #[serde(rename = "thoroughfareNumber")] - pub thoroughfare_number: i64, - #[serde(rename = "thoroughfareName")] - pub thoroughfare_name: String, - pub locality: String, - #[serde(rename = "postalCode")] - pub postal_code: String, - pub country: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct PointOfContact { - #[serde(rename = "contactName")] - pub contact_name: String, - #[serde(rename = "contactType")] - #[serde(skip_serializing_if = "Option::is_none")] - pub contact_type: Option, - #[serde(rename = "role")] - #[serde(skip_serializing_if = "Option::is_none")] - pub role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub phone: Option, - #[serde(rename = "emailAddress")] - pub email_address: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub website: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub address: Option
, -} - -/// A reference system following the OGC Name Type Specification. -/// -/// The format follows: `http://www.opengis.net/def/crs/{authority}/{version}/{code}` -/// where: -/// - `{authority}` designates the authority responsible for the definition of this CRS -/// (usually "EPSG" or "OGC") -/// - `{version}` designates the specific version of the CRS -/// (use "0" if there is no version) -/// - `{code}` is the identifier for the specific coordinate reference system -#[derive(Debug, Clone)] -pub struct ReferenceSystem { - pub base_url: String, - pub authority: String, - pub version: String, - pub code: String, -} - -impl ReferenceSystem { - pub fn new(base_url: Option, authority: String, version: String, code: String) -> Self { - let base_url = base_url.unwrap_or(DEFAULT_CRS_BASE_URL.to_string()); - ReferenceSystem { - base_url, - authority, - version, - code, - } - } - - pub fn to_url(&self) -> String { - format!( - "{}/{}/{}/{}", - self.base_url, self.authority, self.version, self.code - ) - } - - // OGC Name Type Specification: - // http://www.opengis.net/def/crs/{authority}/{version}/{code} - // where {authority} designates the authority responsible for the definition of this CRS (usually "EPSG" or "OGC"), and where {version} designates the specific version of the CRS ("0" (zero) is used if there is no version). - pub fn from_url(url: &str) -> Result { - if !url.contains("//www.opengis.net/def/crs") { - return Err("Invalid reference system URL"); - } - - let i = url.find("crs").unwrap(); - let s = &url[i + 4..]; - - let parts: Vec<&str> = s.split("/").collect(); - if parts.len() != 3 { - return Err("Invalid reference system URL"); - } - - Ok(ReferenceSystem { - base_url: url[..i + 3].to_string(), - authority: parts[0].to_string(), - version: parts[1].to_string(), - code: parts[2].to_string(), - }) - } -} - -impl Serialize for ReferenceSystem { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.to_url().serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for ReferenceSystem { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let url = String::deserialize(deserializer)?; - ReferenceSystem::from_url(&url).map_err(serde::de::Error::custom) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Metadata { - #[serde(rename = "geographicalExtent")] - #[serde(skip_serializing_if = "Option::is_none")] - pub geographical_extent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option, - #[serde(rename = "pointOfContact")] - #[serde(skip_serializing_if = "Option::is_none")] - pub point_of_contact: Option, - #[serde(rename = "referenceDate")] - #[serde(skip_serializing_if = "Option::is_none")] - pub reference_date: Option, - #[serde(rename = "referenceSystem")] - #[serde(skip_serializing_if = "Option::is_none")] - pub reference_system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GeometryTemplates { - pub templates: Vec, - #[serde(rename = "vertices-templates")] - pub vertices_templates: Value, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Material { - #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub value: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Texture { - #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Appearance { - #[serde(skip_serializing_if = "Option::is_none")] - pub materials: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub textures: Option>, - #[serde(rename = "vertices-texture")] - #[serde(skip_serializing_if = "Option::is_none")] - pub vertices_texture: Option>>, - #[serde(rename = "default-theme-texture")] - #[serde(skip_serializing_if = "Option::is_none")] - pub default_theme_texture: Option, - #[serde(rename = "default-theme-material")] - #[serde(skip_serializing_if = "Option::is_none")] - pub default_theme_material: Option, -} -impl Appearance { - fn new() -> Self { - Appearance { - materials: None, - textures: None, - vertices_texture: None, - default_theme_texture: None, - default_theme_material: None, - } - } - fn add_material(&mut self, jm: Value) -> usize { - let re = match &mut self.materials { - Some(x) => match x.iter().position(|e| *e == jm) { - Some(y) => y, - None => { - x.push(jm); - x.len() - 1 - } - }, - None => { - let mut ls: Vec = Vec::new(); - ls.push(jm); - self.materials = Some(ls); - 0 - } - }; - re - } - fn add_texture(&mut self, jm: Value) -> usize { - let re = match &mut self.textures { - Some(x) => match x.iter().position(|e| *e == jm) { - Some(y) => y, - None => { - x.push(jm); - x.len() - 1 - } - }, - None => { - let mut ls: Vec = Vec::new(); - ls.push(jm); - self.textures = Some(ls); - 0 - } - }; - re - } - fn add_vertices_texture(&mut self, mut vs: Vec>) { - match &mut self.vertices_texture { - Some(x) => { - x.append(&mut vs); - } - None => { - let mut ls: Vec> = Vec::new(); - ls.append(&mut vs); - self.vertices_texture = Some(ls); - } - }; - } -} - -/// Collects a base CityJSON metadata and a vector of CityJSONFeatures -/// into a complete CityJSON object -pub fn cjseq_to_cj(mut base_cj: CityJSON, features: Vec) -> CityJSON { - for mut feature in features { - base_cj.add_cjfeature(&mut feature); - } - - base_cj.remove_duplicate_vertices(); - base_cj.update_transform(); - base_cj.update_geographicalextent(); - - base_cj -} +//! `cjseq2` is a typed fork of [`cjseq`](https://github.com/cityjson/cjseq), +//! for reading, writing, and converting between CityJSON and CityJSONSeq. +//! +//! The library crate is still named `cjseq` (see `Cargo.toml`'s `[lib]` +//! section), so existing code that does `use cjseq::CityJSON` keeps working +//! unchanged. The package itself is published as `cjseq2` pending an +//! eventual merge upstream. +//! +//! Internally the crate is organised into one module per concern: +//! - `geometry` — `Geometry`, `GeometryType`, `GeometryTemplates` +//! - `appearance` — `Appearance`, `Material`, `Texture` +//! - `city_object` — `CityObject` +//! - `metadata` — `Metadata`, `PointOfContact`, `Address`, `ReferenceSystem`, `Transform`, `GeographicalExtent` +//! - `cityjson` — `CityJSON`, `CityJSONFeature`, `SortingStrategy`, and the sequencing/collect/filter logic +//! +//! Every type is re-exported at the crate root, so the module boundaries +//! above are an internal organisational detail, not part of the public +//! import paths. + +mod appearance; +mod city_object; +mod cityjson; +mod geometry; +mod metadata; + +pub use appearance::*; +pub use city_object::*; +pub use cityjson::*; +pub use geometry::*; +pub use metadata::*; // WASM bindings module #[cfg(target_arch = "wasm32")] diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 0000000..36107e1 --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,150 @@ +use serde::{Deserialize, Serialize}; + +const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Transform { + pub scale: Vec, + pub translate: Vec, +} +impl Transform { + pub(crate) fn new() -> Self { + Transform { + scale: vec![1.0, 1.0, 1.0], + translate: vec![0., 0., 0.], + } + } +} + +pub type GeographicalExtent = [f64; 6]; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Address { + #[serde(rename = "thoroughfareNumber")] + pub thoroughfare_number: i64, + #[serde(rename = "thoroughfareName")] + pub thoroughfare_name: String, + pub locality: String, + #[serde(rename = "postalCode")] + pub postal_code: String, + pub country: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PointOfContact { + #[serde(rename = "contactName")] + pub contact_name: String, + #[serde(rename = "contactType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_type: Option, + #[serde(rename = "role")] + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[serde(rename = "emailAddress")] + pub email_address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option
, +} + +/// A reference system following the OGC Name Type Specification. +/// +/// The format follows: `http://www.opengis.net/def/crs/{authority}/{version}/{code}` +/// where: +/// - `{authority}` designates the authority responsible for the definition of this CRS +/// (usually "EPSG" or "OGC") +/// - `{version}` designates the specific version of the CRS +/// (use "0" if there is no version) +/// - `{code}` is the identifier for the specific coordinate reference system +#[derive(Debug, Clone)] +pub struct ReferenceSystem { + pub base_url: String, + pub authority: String, + pub version: String, + pub code: String, +} + +impl ReferenceSystem { + pub fn new(base_url: Option, authority: String, version: String, code: String) -> Self { + let base_url = base_url.unwrap_or(DEFAULT_CRS_BASE_URL.to_string()); + ReferenceSystem { + base_url, + authority, + version, + code, + } + } + + pub fn to_url(&self) -> String { + format!( + "{}/{}/{}/{}", + self.base_url, self.authority, self.version, self.code + ) + } + + // OGC Name Type Specification: + // http://www.opengis.net/def/crs/{authority}/{version}/{code} + // where {authority} designates the authority responsible for the definition of this CRS (usually "EPSG" or "OGC"), and where {version} designates the specific version of the CRS ("0" (zero) is used if there is no version). + pub fn from_url(url: &str) -> Result { + if !url.contains("//www.opengis.net/def/crs") { + return Err("Invalid reference system URL"); + } + + let i = url.find("crs").unwrap(); + let s = &url[i + 4..]; + + let parts: Vec<&str> = s.split("/").collect(); + if parts.len() != 3 { + return Err("Invalid reference system URL"); + } + + Ok(ReferenceSystem { + base_url: url[..i + 3].to_string(), + authority: parts[0].to_string(), + version: parts[1].to_string(), + code: parts[2].to_string(), + }) + } +} + +impl Serialize for ReferenceSystem { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_url().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ReferenceSystem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let url = String::deserialize(deserializer)?; + ReferenceSystem::from_url(&url).map_err(serde::de::Error::custom) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Metadata { + #[serde(rename = "geographicalExtent")] + #[serde(skip_serializing_if = "Option::is_none")] + pub geographical_extent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, + #[serde(rename = "pointOfContact")] + #[serde(skip_serializing_if = "Option::is_none")] + pub point_of_contact: Option, + #[serde(rename = "referenceDate")] + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_date: Option, + #[serde(rename = "referenceSystem")] + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} From 633b7e3469c84e90dd3e9c9d17c53b9e111031d4 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 16:47:15 +0200 Subject: [PATCH 06/65] feat!: put nesting depth in the Geometry type BREAKING: Geometry is now an internally-tagged enum whose variants carry depth-correct boundaries. A document whose boundaries do not match its declared type no longer deserializes. --- src/appearance.rs | 6 +- src/geometry.rs | 750 +++++++++++++++++++-------------------- tests/depth_rejection.rs | 34 ++ 3 files changed, 409 insertions(+), 381 deletions(-) create mode 100644 tests/depth_rejection.rs diff --git a/src/appearance.rs b/src/appearance.rs index 181bef6..ad4c78b 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Material { #[serde(skip_serializing_if = "Option::is_none")] pub values: Option, @@ -9,13 +9,13 @@ pub struct Material { pub value: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Texture { #[serde(skip_serializing_if = "Option::is_none")] pub values: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Appearance { #[serde(skip_serializing_if = "Option::is_none")] pub materials: Option>, diff --git a/src/geometry.rs b/src/geometry.rs index 5244c79..421cf0e 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -3,7 +3,16 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +/// A closed sequence of vertex indices bounding a face. +pub type Ring = Vec; +/// A face: the exterior ring first, then any interior rings. +pub type Surface = Vec; +/// A closed collection of surfaces bounding a volume. +pub type Shell = Vec; + +/// The tag of a [`Geometry`], for callers that need the kind without matching +/// on the whole enum. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum GeometryType { MultiPoint, MultiLineString, @@ -15,429 +24,414 @@ pub enum GeometryType { GeometryInstance, } -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Geometry { - #[serde(rename = "type")] - pub thetype: GeometryType, - #[serde(skip_serializing_if = "Option::is_none")] - pub lod: Option, - pub boundaries: Value, +/// The members every geometry may carry that are neither its type, its lod, +/// nor its boundaries. +/// +/// `semantics` and the appearance values stay untyped for now; typing them is +/// the subject of later work. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +pub struct GeometryCommon { #[serde(skip_serializing_if = "Option::is_none")] pub semantics: Option, #[serde(skip_serializing_if = "Option::is_none")] pub material: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub texture: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub template: Option, - #[serde(rename = "transformationMatrix")] - #[serde(skip_serializing_if = "Option::is_none")] - pub transformation_matrix: Option, } + +/// A CityJSON geometry object. +/// +/// The nesting depth of `boundaries` is part of the type: a `MultiSurface` +/// whose boundaries are nested one level too deep does not deserialize, where +/// previously it decoded happily into an untyped `serde_json::Value` and left +/// every consumer to guess the depth. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "type")] +pub enum Geometry { + MultiPoint { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Ring, + #[serde(flatten)] + common: GeometryCommon, + }, + MultiLineString { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Vec, + #[serde(flatten)] + common: GeometryCommon, + }, + MultiSurface { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Vec, + #[serde(flatten)] + common: GeometryCommon, + }, + CompositeSurface { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Vec, + #[serde(flatten)] + common: GeometryCommon, + }, + Solid { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Vec, + #[serde(flatten)] + common: GeometryCommon, + }, + MultiSolid { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Vec>, + #[serde(flatten)] + common: GeometryCommon, + }, + CompositeSolid { + #[serde(skip_serializing_if = "Option::is_none")] + lod: Option, + boundaries: Vec>, + #[serde(flatten)] + common: GeometryCommon, + }, + GeometryInstance { + boundaries: Ring, + template: usize, + #[serde(rename = "transformationMatrix")] + transformation_matrix: [f64; 16], + }, +} + impl Geometry { - pub(crate) fn update_geometry_boundaries(&mut self, violdnew: &mut HashMap) { - match self.thetype { - GeometryType::MultiPoint => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - let kk = violdnew.get(&x); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*x, l); - a2[i] = l; - } else { - let kk = kk.unwrap(); - a2[i] = *kk; - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiLineString => { - let a: Vec> = serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - // r.push(z); - let kk = violdnew.get(&y); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*y, l); - a2[i][j] = l; - } else { - let kk = kk.unwrap(); - a2[i][j] = *kk; - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let a: Vec>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - let kk = violdnew.get(&z); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*z, l); - a2[i][j][k] = l; - } else { - let kk = kk.unwrap(); - a2[i][j][k] = *kk; - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::Solid => { - let a: Vec>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - let kk = violdnew.get(&zz); - if kk.is_none() { - let l2 = violdnew.len(); - violdnew.insert(*zz, l2); - a2[i][j][k][l] = l2; - } else { - let kk = kk.unwrap(); - a2[i][j][k][l] = *kk; - } - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); + pub fn geometry_type(&self) -> GeometryType { + match self { + Geometry::MultiPoint { .. } => GeometryType::MultiPoint, + Geometry::MultiLineString { .. } => GeometryType::MultiLineString, + Geometry::MultiSurface { .. } => GeometryType::MultiSurface, + Geometry::CompositeSurface { .. } => GeometryType::CompositeSurface, + Geometry::Solid { .. } => GeometryType::Solid, + Geometry::MultiSolid { .. } => GeometryType::MultiSolid, + Geometry::CompositeSolid { .. } => GeometryType::CompositeSolid, + Geometry::GeometryInstance { .. } => GeometryType::GeometryInstance, + } + } + + /// The lod of this geometry; a [`Geometry::GeometryInstance`] has none. + pub fn lod(&self) -> Option<&str> { + match self { + Geometry::MultiPoint { lod, .. } + | Geometry::MultiLineString { lod, .. } + | Geometry::MultiSurface { lod, .. } + | Geometry::CompositeSurface { lod, .. } + | Geometry::Solid { lod, .. } + | Geometry::MultiSolid { lod, .. } + | Geometry::CompositeSolid { lod, .. } => lod.as_deref(), + Geometry::GeometryInstance { .. } => None, + } + } + + /// The semantics/material/texture members, if this variant has them. + /// A [`Geometry::GeometryInstance`] carries none: its appearance lives on + /// the template it refers to. + pub fn common(&self) -> Option<&GeometryCommon> { + match self { + Geometry::MultiPoint { common, .. } + | Geometry::MultiLineString { common, .. } + | Geometry::MultiSurface { common, .. } + | Geometry::CompositeSurface { common, .. } + | Geometry::Solid { common, .. } + | Geometry::MultiSolid { common, .. } + | Geometry::CompositeSolid { common, .. } => Some(common), + Geometry::GeometryInstance { .. } => None, + } + } + + pub fn common_mut(&mut self) -> Option<&mut GeometryCommon> { + match self { + Geometry::MultiPoint { common, .. } + | Geometry::MultiLineString { common, .. } + | Geometry::MultiSurface { common, .. } + | Geometry::CompositeSurface { common, .. } + | Geometry::Solid { common, .. } + | Geometry::MultiSolid { common, .. } + | Geometry::CompositeSolid { common, .. } => Some(common), + Geometry::GeometryInstance { .. } => None, + } + } + + /// Every vertex index in `boundaries`, in document order, whatever the + /// nesting depth of this variant. The depth is known statically per + /// variant, so no runtime inspection of the boundaries is needed. + fn boundary_indices_mut(&mut self) -> Box + '_> { + match self { + Geometry::MultiPoint { boundaries, .. } + | Geometry::GeometryInstance { boundaries, .. } => Box::new(boundaries.iter_mut()), + Geometry::MultiLineString { boundaries, .. } => { + Box::new(boundaries.iter_mut().flatten()) } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - let a: Vec>>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - for (m, zzz) in zz.iter().enumerate() { - let kk = violdnew.get(&zzz); - if kk.is_none() { - let l2 = violdnew.len(); - violdnew.insert(*zzz, l2); - a2[i][j][k][l][m] = l2; - } else { - let kk = kk.unwrap(); - a2[i][j][k][l][m] = *kk; - } - } - } - } - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); + Geometry::MultiSurface { boundaries, .. } + | Geometry::CompositeSurface { boundaries, .. } => { + Box::new(boundaries.iter_mut().flatten().flatten()) } - GeometryType::GeometryInstance => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - let kk = violdnew.get(&x); - if kk.is_none() { - let l = violdnew.len(); - violdnew.insert(*x, l); - a2[i] = l; - } else { - let kk = kk.unwrap(); - a2[i] = *kk; - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); + Geometry::Solid { boundaries, .. } => { + Box::new(boundaries.iter_mut().flatten().flatten().flatten()) } + Geometry::MultiSolid { boundaries, .. } + | Geometry::CompositeSolid { boundaries, .. } => Box::new( + boundaries + .iter_mut() + .flatten() + .flatten() + .flatten() + .flatten(), + ), + } + } + + pub(crate) fn update_geometry_boundaries(&mut self, violdnew: &mut HashMap) { + for vi in self.boundary_indices_mut() { + let next = violdnew.len(); + *vi = *violdnew.entry(*vi).or_insert(next); } } pub(crate) fn offset_geometry_boundaries(&mut self, offset: usize) { - match self.thetype { - GeometryType::MultiPoint => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - a2[i] = *x + offset; - } - self.boundaries = serde_json::to_value(&a2).unwrap(); + for vi in self.boundary_indices_mut() { + *vi += offset; + } + } + + pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { + let thetype = self.geometry_type(); + let Some(common) = self.common_mut() else { + return; + }; + let Some(materials) = common.material.as_mut() else { + return; + }; + for mat in materials.values_mut() { + //-- material.value + if let Some(thevalue) = mat.value { + let l = m_oldnew.len(); + mat.value = Some(*m_oldnew.entry(thevalue).or_insert(l)); + continue; } - GeometryType::MultiLineString => { - let a: Vec> = serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - // r.push(z); - a2[i][j] = *y + offset; - } - } - self.boundaries = serde_json::to_value(&a2).unwrap(); + //-- else it's material.values (which differs per geom type) + if mat.values.is_none() { + continue; } - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let a: Vec>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - a2[i][j][k] = *z + offset; - } + match thetype { + GeometryType::MultiPoint | GeometryType::MultiLineString => (), + GeometryType::MultiSurface | GeometryType::CompositeSurface => { + let mut a: Vec> = + serde_json::from_value(mat.values.take().into()).unwrap(); + for x in a.iter_mut().flatten() { + let l = m_oldnew.len(); + *x = *m_oldnew.entry(*x).or_insert(l); } + mat.values = Some(serde_json::to_value(&a).unwrap()); } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::Solid => { - let a: Vec>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - a2[i][j][k][l] = *zz + offset; - } - } + GeometryType::Solid => { + let mut a: Vec>> = + serde_json::from_value(mat.values.take().into()).unwrap(); + for x in a.iter_mut().flatten().flatten() { + let l = m_oldnew.len(); + *x = *m_oldnew.entry(*x).or_insert(l); } + mat.values = Some(serde_json::to_value(&a).unwrap()); } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - let a: Vec>>>> = - serde_json::from_value(self.boundaries.take()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - for (m, zzz) in zz.iter().enumerate() { - a2[i][j][k][l][m] = *zzz + offset; - } - } - } + GeometryType::MultiSolid | GeometryType::CompositeSolid => { + let mut a: Vec>>> = + serde_json::from_value(mat.values.take().into()).unwrap(); + for x in a.iter_mut().flatten().flatten().flatten() { + let l = m_oldnew.len(); + *x = *m_oldnew.entry(*x).or_insert(l); } + mat.values = Some(serde_json::to_value(&a).unwrap()); } - self.boundaries = serde_json::to_value(&a2).unwrap(); - } - GeometryType::GeometryInstance => { - let a: Vec = serde_json::from_value(self.boundaries.clone()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - a2[i] = *x + offset; - } - self.boundaries = serde_json::to_value(&a2).unwrap(); + GeometryType::GeometryInstance => unreachable!( + "GeometryInstance carries no material; common_mut() returned None above" + ), } } } - pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { - match &mut self.material { - Some(x) => { - for (_key, mat) in &mut *x { - //-- material.value - if mat.value.is_some() { - let thevalue: usize = mat.value.unwrap(); - let r = m_oldnew.get(&thevalue); - if r.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(thevalue, l); - mat.value = Some(l); - } else { - let r2 = r.unwrap(); - mat.value = Some(*r2); - } - continue; - } - //-- else it's material.values (which differs per geom type) - match self.thetype { - GeometryType::MultiPoint | GeometryType::MultiLineString => (), - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - if mat.values.is_some() { - let a: Vec> = - serde_json::from_value(mat.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - if x.is_some() { - let y2 = m_oldnew.get(&x.unwrap()); - if y2.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(x.unwrap(), l); - a2[i] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i] = Some(*y2); - } - } - } - mat.values = Some(serde_json::to_value(&a2).unwrap()); - } - } - GeometryType::Solid => { - if mat.values.is_some() { - let a: Vec>> = - serde_json::from_value(mat.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - if y.is_some() { - let y2 = m_oldnew.get(&y.unwrap()); - if y2.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(y.unwrap(), l); - a2[i][j] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j] = Some(*y2); - } - } - } - } - mat.values = Some(serde_json::to_value(&a2).unwrap()); - } - } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - if mat.values.is_some() { - let a: Vec>>> = - serde_json::from_value(mat.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - if z.is_some() { - let y2 = m_oldnew.get(&z.unwrap()); - if y2.is_none() { - let l = m_oldnew.len(); - m_oldnew.insert(z.unwrap(), l); - a2[i][j][k] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j][k] = Some(*y2); - } - } - } - } - } - mat.values = Some(serde_json::to_value(&a2).unwrap()); - } - } - GeometryType::GeometryInstance => todo!(), - } - } - self.material = Some(x.clone()); - } - None => (), - } - } pub(crate) fn update_texture( &mut self, t_oldnew: &mut HashMap, t_v_oldnew: &mut HashMap, offset: usize, ) { - match &mut self.texture { - Some(x) => { - for (_key, tex) in &mut *x { - match self.thetype { - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let a: Vec>>> = - serde_json::from_value(tex.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - if z.is_some() { - let thevalue: usize = z.unwrap(); - if k == 0 { - let y2 = t_oldnew.get(&thevalue); - if y2.is_none() { - let l = t_oldnew.len(); - t_oldnew.insert(thevalue, l); - a2[i][j][k] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j][k] = Some(*y2); - } - } else { - let y2 = t_v_oldnew.get(&thevalue); - if y2.is_none() { - let l = t_v_oldnew.len(); - t_v_oldnew.insert(thevalue, l + offset); - a2[i][j][k] = Some(l); - } else { - let y2 = y2.unwrap(); - a2[i][j][k] = Some(*y2); - } - } - } - } - } + let thetype = self.geometry_type(); + let Some(common) = self.common_mut() else { + return; + }; + let Some(textures) = common.texture.as_mut() else { + return; + }; + //-- the first index of the innermost array is a texture, the rest are + //-- texture vertices; they are renumbered against different maps. + let mut remap = |is_texture: bool, thevalue: usize| -> usize { + if is_texture { + let l = t_oldnew.len(); + *t_oldnew.entry(thevalue).or_insert(l) + } else { + match t_v_oldnew.get(&thevalue) { + Some(y) => *y, + None => { + let l = t_v_oldnew.len(); + t_v_oldnew.insert(thevalue, l + offset); + l + } + } + } + }; + for tex in textures.values_mut() { + match thetype { + GeometryType::MultiSurface | GeometryType::CompositeSurface => { + let mut a: Vec>>> = + serde_json::from_value(tex.values.take().into()).unwrap(); + for y in a.iter_mut().flatten() { + for (k, z) in y.iter_mut().enumerate() { + if let Some(thevalue) = *z { + *z = Some(remap(k == 0, thevalue)); } - tex.values = Some(serde_json::to_value(&a2).unwrap()); } - GeometryType::Solid => { - let a: Vec>>>> = - serde_json::from_value(tex.values.take().into()).unwrap(); - let mut a2 = a.clone(); - for (i, x) in a.iter().enumerate() { - for (j, y) in x.iter().enumerate() { - for (k, z) in y.iter().enumerate() { - for (l, zz) in z.iter().enumerate() { - if zz.is_some() { - let thevalue: usize = zz.unwrap(); - if l == 0 { - let y2 = t_oldnew.get(&thevalue); - if y2.is_none() { - let l2 = t_oldnew.len(); - t_oldnew.insert(thevalue, l2); - a2[i][j][k][l] = Some(l2); - } else { - let y2 = y2.unwrap(); - a2[i][j][k][l] = Some(*y2); - } - } else { - let y2 = t_v_oldnew.get(&thevalue); - if y2.is_none() { - let l2 = t_v_oldnew.len(); - t_v_oldnew.insert(thevalue, l2 + offset); - a2[i][j][k][l] = Some(l2); - } else { - let y2 = y2.unwrap(); - a2[i][j][k][l] = Some(*y2); - } - } - } - } - } - } + } + tex.values = Some(serde_json::to_value(&a).unwrap()); + } + GeometryType::Solid => { + let mut a: Vec>>>> = + serde_json::from_value(tex.values.take().into()).unwrap(); + for z in a.iter_mut().flatten().flatten() { + for (l, zz) in z.iter_mut().enumerate() { + if let Some(thevalue) = *zz { + *zz = Some(remap(l == 0, thevalue)); } - tex.values = Some(serde_json::to_value(&a2).unwrap()); } - _ => todo!(), } + tex.values = Some(serde_json::to_value(&a).unwrap()); } + _ => todo!(), } - None => (), } } } -#[derive(Serialize, Deserialize, Debug)] -struct Vertex { - x: i64, - y: i64, - z: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct GeometryTemplates { pub templates: Vec, #[serde(rename = "vertices-templates")] pub vertices_templates: Value, } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(v: serde_json::Value) -> Result { + serde_json::from_value(v) + } + + #[test] + fn each_variant_has_its_own_depth() { + let cases = [ + (GeometryType::MultiPoint, serde_json::json!([0, 1])), + (GeometryType::MultiLineString, serde_json::json!([[0, 1]])), + (GeometryType::MultiSurface, serde_json::json!([[[0, 1, 2]]])), + ( + GeometryType::CompositeSurface, + serde_json::json!([[[0, 1, 2]]]), + ), + (GeometryType::Solid, serde_json::json!([[[[0, 1, 2]]]])), + (GeometryType::MultiSolid, serde_json::json!([[[[[0, 1, 2]]]]])), + ( + GeometryType::CompositeSolid, + serde_json::json!([[[[[0, 1, 2]]]]]), + ), + ]; + for (thetype, boundaries) in cases { + let name = serde_json::to_value(thetype).unwrap(); + let g = parse(serde_json::json!({ + "type": name, "lod": "1", "boundaries": boundaries.clone() + })) + .unwrap_or_else(|e| panic!("{name} must accept its own depth: {e}")); + assert_eq!(g.geometry_type(), thetype); + assert_eq!(g.lod(), Some("1")); + + //-- one level too deep must be rejected + let too_deep = serde_json::json!([boundaries]); + assert!( + parse(serde_json::json!({ + "type": name, "lod": "1", "boundaries": too_deep + })) + .is_err(), + "{name} must reject boundaries one level too deep" + ); + } + } + + #[test] + fn geometry_instance_roundtrips() { + let input = serde_json::json!({ + "type": "GeometryInstance", + "boundaries": [372], + "template": 0, + "transformationMatrix": [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ] + }); + let g: Geometry = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(g.geometry_type(), GeometryType::GeometryInstance); + assert!(g.common().is_none()); + assert_eq!(serde_json::to_value(&g).unwrap(), input); + } + + #[test] + fn offset_walks_every_depth() { + let mut g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "CompositeSolid", "lod": "2", + "boundaries": [[[[[0, 1]]]], [[[[2, 3]]]]] + })) + .unwrap(); + g.offset_geometry_boundaries(10); + assert_eq!( + serde_json::to_value(&g).unwrap()["boundaries"], + serde_json::json!([[[[[10, 11]]]], [[[[12, 13]]]]]) + ); + } + + #[test] + fn update_boundaries_compacts_indices() { + let mut g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[7, 9, 7]], [[9, 4, 4]]] + })) + .unwrap(); + let mut map: HashMap = HashMap::new(); + g.update_geometry_boundaries(&mut map); + assert_eq!( + serde_json::to_value(&g).unwrap()["boundaries"], + serde_json::json!([[[0, 1, 0]], [[1, 2, 2]]]) + ); + assert_eq!(map.len(), 3); + } + + #[test] + fn absent_members_are_not_serialized_as_null() { + let g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "MultiSurface", "boundaries": [[[0, 1, 2]]] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(&g).unwrap(), + serde_json::json!({"type": "MultiSurface", "boundaries": [[[0, 1, 2]]]}) + ); + } +} diff --git a/tests/depth_rejection.rs b/tests/depth_rejection.rs new file mode 100644 index 0000000..0d0e7e5 --- /dev/null +++ b/tests/depth_rejection.rs @@ -0,0 +1,34 @@ +use cjseq::Geometry; + +#[test] +fn multisurface_accepts_three_levels() { + let g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[0, 3, 2, 1]], [[4, 5, 6, 7]]] + })) + .expect("valid MultiSurface must deserialize"); + match g { + Geometry::MultiSurface { ref boundaries, .. } => assert_eq!(boundaries.len(), 2), + other => panic!("wrong variant: {other:?}"), + } +} + +#[test] +fn multisurface_rejects_solid_depth() { + // One level too deep for a MultiSurface. Previously decoded happily. + let r: Result = serde_json::from_value(serde_json::json!({ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[[0, 3, 2, 1]]]] + })); + assert!(r.is_err(), "wrong-depth boundaries must not deserialize"); +} + +#[test] +fn solid_roundtrips_through_json() { + let input = serde_json::json!({ + "type": "Solid", "lod": "2", + "boundaries": [[[[0, 3, 2, 1]], [[4, 5, 6, 7]]]] + }); + let g: Geometry = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&g).unwrap(), input); +} From f1ba2dcbc43ea5959593045c43fdb14b4606656e Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 16:57:04 +0200 Subject: [PATCH 07/65] test: pin update_material/update_texture against the pre-rewrite behaviour The two most intricate rewrites in the depth-typing change had no unit tests. Expected values for the two new appearance tests were produced by running the old implementation (a97bc2d) on the same inputs, so they prove equivalence rather than restate the new code. Includes the asymmetric texture-vertex numbering quirk, which is upstream behaviour and now pinned. Also extend the depth test to reject boundaries one level too shallow and to round-trip every variant's boundaries, and pin first-encounter renumbering at the deepest (5-level) traversal. --- src/geometry.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/src/geometry.rs b/src/geometry.rs index 421cf0e..0ecab95 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -361,6 +361,12 @@ mod tests { .unwrap_or_else(|e| panic!("{name} must accept its own depth: {e}")); assert_eq!(g.geometry_type(), thetype); assert_eq!(g.lod(), Some("1")); + //-- and it must serialize back to exactly the boundaries it was given + assert_eq!( + serde_json::to_value(&g).unwrap()["boundaries"], + boundaries, + "{name} must round-trip its boundaries" + ); //-- one level too deep must be rejected let too_deep = serde_json::json!([boundaries]); @@ -371,6 +377,16 @@ mod tests { .is_err(), "{name} must reject boundaries one level too deep" ); + + //-- and one level too shallow must be rejected too + let too_shallow = boundaries[0].clone(); + assert!( + parse(serde_json::json!({ + "type": name, "lod": "1", "boundaries": too_shallow + })) + .is_err(), + "{name} must reject boundaries one level too shallow" + ); } } @@ -423,6 +439,110 @@ mod tests { assert_eq!(map.len(), 3); } + /// The same first-encounter renumbering, but at the deepest traversal the + /// enum has, so the flattening order of the 5-level variants is pinned too. + /// Expected values produced by the pre-rewrite implementation at a97bc2d. + #[test] + fn update_boundaries_compacts_indices_at_max_depth() { + let mut g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "CompositeSolid", "lod": "2", + "boundaries": [[[[[7, 9, 7]]]], [[[[9, 4, 4]]]]] + })) + .unwrap(); + let mut map: HashMap = HashMap::new(); + g.update_geometry_boundaries(&mut map); + assert_eq!( + serde_json::to_value(&g).unwrap()["boundaries"], + serde_json::json!([[[[[0, 1, 0]]]], [[[[1, 2, 2]]]]]) + ); + let mut entries: Vec<(usize, usize)> = map.into_iter().collect(); + entries.sort(); + assert_eq!(entries, vec![(4, 2), (7, 0), (9, 1)]); + } + + /// `update_texture` renumbers the first entry of each innermost array + /// against the texture map and the rest against the texture-vertex map. + /// + /// Every expected value below was produced by running the pre-rewrite + /// implementation (commit a97bc2d) on this exact input; the test exists to + /// prove the rewrite is behaviour-preserving. Note in particular that the + /// texture-vertex numbering is asymmetric: the *first* occurrence of a + /// vertex is written as `l` while later occurrences are written as the + /// stored `l + offset` (see 20 -> 0 then 20 -> 100 below). That is upstream + /// behaviour, quirk included, and it is pinned here deliberately. + #[test] + fn update_texture_renumbers_textures_and_texture_vertices() { + let mut g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[0, 1, 2, 3]], [[4, 5, 6, 7]], [[8, 9, 10, 11]]], + "texture": {"winter": {"values": [ + [[5, 20, 21, 22, 20]], + [[5, 22, 21, 30, 22]], + [[null]] + ]}} + })) + .unwrap(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 100); + + let values = g.common().unwrap().texture.as_ref().unwrap()["winter"] + .values + .clone() + .unwrap(); + assert_eq!( + values, + serde_json::json!([ + [[0, 0, 1, 2, 100]], + [[0, 102, 101, 3, 102]], + [[null]] + ]), + "texture values must renumber exactly as the old implementation did" + ); + + let mut t: Vec<(usize, usize)> = t_oldnew.into_iter().collect(); + t.sort(); + assert_eq!(t, vec![(5, 0)], "the repeated texture index 5 maps once"); + + let mut tv: Vec<(usize, usize)> = t_v_oldnew.into_iter().collect(); + tv.sort(); + assert_eq!( + tv, + vec![(20, 100), (21, 101), (22, 102), (30, 103)], + "texture vertices are stored offset by `offset`" + ); + } + + /// `update_material` renumbers `material.values` at the depth the geometry + /// type implies, and `null` holes (surfaces with no material) must survive + /// untouched. Expected values produced by the pre-rewrite implementation at + /// a97bc2d on this exact input. + #[test] + fn update_material_preserves_null_holes() { + let mut g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "Solid", "lod": "2", + "boundaries": [[[[0, 1, 2, 3]], [[4, 5, 6, 7]], [[8, 9, 10, 11]]]], + "material": {"irradiation": {"values": [[0, null, 2], [2, null, 0]]}} + })) + .unwrap(); + let mut m_oldnew: HashMap = HashMap::new(); + g.update_material(&mut m_oldnew); + + let values = g.common().unwrap().material.as_ref().unwrap()["irradiation"] + .values + .clone() + .unwrap(); + assert_eq!( + values, + serde_json::json!([[0, null, 1], [1, null, 0]]), + "null holes must survive and indices must compact in first-encounter order" + ); + + let mut m: Vec<(usize, usize)> = m_oldnew.into_iter().collect(); + m.sort(); + assert_eq!(m, vec![(0, 0), (2, 1)]); + } + #[test] fn absent_members_are_not_serialized_as_null() { let g: Geometry = serde_json::from_value(serde_json::json!({ From 279f9b71009fe7662dfbacb2ae7838e055d510fd Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 17:08:11 +0200 Subject: [PATCH 08/65] feat!: depth-type semantics values Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geometry.rs | 7 +-- src/lib.rs | 3 ++ src/semantics.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/semantics.rs diff --git a/src/geometry.rs b/src/geometry.rs index 0ecab95..f502af9 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,4 +1,5 @@ use crate::appearance::{Material, Texture}; +use crate::semantics::Semantics; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -27,12 +28,12 @@ pub enum GeometryType { /// The members every geometry may carry that are neither its type, its lod, /// nor its boundaries. /// -/// `semantics` and the appearance values stay untyped for now; typing them is -/// the subject of later work. +/// The appearance values stay untyped for now; typing them is the subject of +/// later work. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct GeometryCommon { #[serde(skip_serializing_if = "Option::is_none")] - pub semantics: Option, + pub semantics: Option, #[serde(skip_serializing_if = "Option::is_none")] pub material: Option>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/lib.rs b/src/lib.rs index f7104f1..169dd04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ //! //! Internally the crate is organised into one module per concern: //! - `geometry` — `Geometry`, `GeometryType`, `GeometryTemplates` +//! - `semantics` — `Semantics`, `SemanticsSurface`, `SemanticsValues` //! - `appearance` — `Appearance`, `Material`, `Texture` //! - `city_object` — `CityObject` //! - `metadata` — `Metadata`, `PointOfContact`, `Address`, `ReferenceSystem`, `Transform`, `GeographicalExtent` @@ -22,12 +23,14 @@ mod city_object; mod cityjson; mod geometry; mod metadata; +mod semantics; pub use appearance::*; pub use city_object::*; pub use cityjson::*; pub use geometry::*; pub use metadata::*; +pub use semantics::*; // WASM bindings module #[cfg(target_arch = "wasm32")] diff --git a/src/semantics.rs b/src/semantics.rs new file mode 100644 index 0000000..193b171 --- /dev/null +++ b/src/semantics.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// One semantic surface: its type, its place in the parent/children hierarchy, +/// and any further attributes the file chooses to carry. +/// +/// `thetype` stays a `String` for now; giving it a closed set of variants is +/// the subject of later work. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct SemanticsSurface { + #[serde(rename = "type")] + pub thetype: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + /// CityJSON lets a semantic surface carry arbitrary extra members + /// (`slope`, `direction`, ...); they are kept verbatim so that a file + /// round-trips without losing them. + #[serde(flatten)] + pub other: HashMap, +} + +/// The `values` array of a [`Semantics`] object: one index into `surfaces` per +/// *surface* of the geometry, so it is nested exactly one level less deeply +/// than that geometry's `boundaries`. +/// +/// The variants are ordered shallowest-first; serde tries them in declaration +/// order, so a value only reaches a deeper variant when the shallower ones +/// cannot describe it. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum SemanticsValues { + /// `MultiSurface`, `CompositeSurface`: one index per surface. + Surfaces(Vec>), + /// `Solid`: one index per surface, per shell. + Shells(Vec>>), + /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per + /// solid. + Solids(Vec>>>), +} + +/// The `semantics` member of a geometry. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Semantics { + pub surfaces: Vec, + pub values: SemanticsValues, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn solid_semantics_values_are_one_per_surface_per_shell() { + let s: Semantics = serde_json::from_value(serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}, {"type": "WallSurface"}], + "values": [[0, 1, null]] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(&s).unwrap()["values"], + serde_json::json!([[0, 1, null]]) + ); + } + + #[test] + fn null_semantics_index_stays_null() { + let s: Semantics = serde_json::from_value(serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}], "values": [null, 0] + })) + .unwrap(); + // Must be `null`, never `[]` -- see finding #7. + assert_eq!( + serde_json::to_value(&s).unwrap()["values"], + serde_json::json!([null, 0]) + ); + } + + /// A semantic surface may carry a hierarchy and arbitrary extra members; + /// both must survive a round-trip byte for byte. + #[test] + fn surface_extras_and_hierarchy_round_trip() { + let input = serde_json::json!({ + "surfaces": [ + {"type": "RoofSurface", "slope": 33.4, "children": [1]}, + {"type": "Window", "parent": 0, "direction": "north"} + ], + "values": [0, 1] + }); + let s: Semantics = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(s.surfaces[0].children, Some(vec![1])); + assert_eq!(s.surfaces[1].parent, Some(0)); + assert_eq!( + s.surfaces[1].other["direction"], + serde_json::json!("north"), + "extra members must be kept, not dropped" + ); + assert_eq!(serde_json::to_value(&s).unwrap(), input); + } + + /// The untagged variant order is load-bearing: each shape must land in the + /// variant its depth implies, not merely re-serialize to the same JSON. + #[test] + fn each_shape_lands_in_the_variant_its_depth_implies() { + let parse = |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() }; + + assert!(matches!( + parse(serde_json::json!([0, 1, null])), + SemanticsValues::Surfaces(_) + )); + assert!(matches!( + parse(serde_json::json!([[0, 1, null]])), + SemanticsValues::Shells(_) + )); + assert!(matches!( + parse(serde_json::json!([[[0, 1, null]]])), + SemanticsValues::Solids(_) + )); + //-- the empty array is ambiguous; it must resolve to the shallowest + //-- variant, and stay `[]` on the way out + let empty = parse(serde_json::json!([])); + assert!(matches!(empty, SemanticsValues::Surfaces(_))); + assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([])); + + //-- a shape deeper than the ladder is not silently accepted + assert!(serde_json::from_value::(serde_json::json!([[[[0]]]])).is_err()); + } +} From 066c66cc9d4f82767856828d60fca5701a53b2f1 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 17:25:54 +0200 Subject: [PATCH 09/65] feat!: depth-type appearance values, drop NestedArray Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appearance.rs | 219 +++++++++++++++++++++++++++++++++++++++++++- src/error.rs | 58 ++++++++++++ src/geometry.rs | 228 ++++++++++++++++++++++++++++++---------------- src/lib.rs | 5 +- 4 files changed, 426 insertions(+), 84 deletions(-) create mode 100644 src/error.rs diff --git a/src/appearance.rs b/src/appearance.rs index ad4c78b..315ba47 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,18 +1,96 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +/// The `values` array of a [`MaterialReference`]: one index into the +/// document's `materials` palette per *surface*, so it is nested exactly two +/// levels less deeply than that geometry's `boundaries` (CityJSON 2.0, +/// section 6.1). A surface with no material is `null`, never `[]`. +/// +/// The variants are ordered shallowest-first; serde tries them in declaration +/// order, so a value only reaches a deeper variant when the shallower ones +/// cannot describe it. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct Material { +#[serde(untagged)] +pub enum MaterialValues { + /// `MultiSurface`, `CompositeSurface`: one index per surface. + Surfaces(Vec>), + /// `Solid`: one index per surface, per shell. + Shells(Vec>>), + /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per + /// solid. + Solids(Vec>>>), +} + +impl MaterialValues { + /// Every material index, in document order, whatever the depth. The depth + /// is known from the variant, so no runtime inspection is needed. + pub(crate) fn indices_mut(&mut self) -> Box + '_> { + match self { + MaterialValues::Surfaces(v) => Box::new(v.iter_mut().flatten()), + MaterialValues::Shells(v) => Box::new(v.iter_mut().flatten().flatten()), + MaterialValues::Solids(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), + } + } +} + +/// One ring's texture entry: the texture index first, then one UV-vertex +/// index per vertex of the ring — so one entry more than the ring has +/// vertices. `[null]` means the ring is not textured. +pub type TexturedRing = Vec>; +/// One face's rings, exterior first, mirroring [`crate::Surface`]. +pub type TexturedSurface = Vec; +/// One volume's bounding faces, mirroring [`crate::Shell`]. +pub type TexturedShell = Vec; + +/// The `values` array of a [`TextureReference`]: nested exactly as deeply as +/// the geometry's `boundaries` (CityJSON 2.0, section 6.2), with each ring +/// becoming `[texture_index, uv_index, ...]` — one more entry than the ring +/// has vertices. An untextured ring is `[null]`. +/// +/// Only surface-bearing geometries can carry a texture, so the ladder starts +/// at `MultiSurface` depth: a `MultiPoint` or `MultiLineString` has no rings +/// to texture. +/// +/// The variants are ordered shallowest-first, as in [`MaterialValues`]. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum TextureValues { + /// `MultiSurface`, `CompositeSurface`: per surface, per ring. + Surface(Vec), + /// `Solid`: ... per shell. + Shell(Vec), + /// `MultiSolid`, `CompositeSolid`: ... per solid. + Solid(Vec>), +} + +impl TextureValues { + /// Every ring, in document order, whatever the depth. A ring is + /// `[texture_index, uv_index, ...]`, so the caller renumbers its first + /// entry against a different map than the rest. + pub(crate) fn rings_mut(&mut self) -> Box + '_> { + match self { + TextureValues::Surface(v) => Box::new(v.iter_mut().flatten()), + TextureValues::Shell(v) => Box::new(v.iter_mut().flatten().flatten()), + TextureValues::Solid(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), + } + } +} + +/// One theme's material assignment for a geometry: either a `value` colouring +/// the whole object, or a depth-typed `values` array with one index per +/// surface. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct MaterialReference { #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, + pub values: Option, #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, } +/// One theme's texture assignment for a geometry. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct Texture { - #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, +pub struct TextureReference { + pub values: TextureValues, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -90,3 +168,134 @@ impl Appearance { }; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn material_indices_serialize_as_numbers_and_null() { + // cjseq2 0.1.0 emitted [[], [1]] here. That is invalid CityJSON. + let m: MaterialReference = serde_json::from_value(serde_json::json!({ + "values": [null, 1] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(&m).unwrap(), + serde_json::json!({"values": [null, 1]}) + ); + } + + #[test] + fn solid_material_values_keep_their_shell_level() { + // Finding #8: this shape came back as [[[0, 1]]] through FCB. + let m: MaterialReference = serde_json::from_value(serde_json::json!({ + "values": [[0, 1]] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(&m).unwrap(), + serde_json::json!({"values": [[0, 1]]}) + ); + } + + #[test] + fn texture_ring_is_index_then_uvs() { + let t: TextureReference = serde_json::from_value(serde_json::json!({ + "values": [[[0, 10, 11, 12]]] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!({"values": [[[0, 10, 11, 12]]]}) + ); + } + + /// A `MaterialReference` may carry `value` instead of `values`; the absent + /// member must not be serialized at all (never as `null`, never as `[]`). + #[test] + fn material_value_is_a_bare_integer() { + let m: MaterialReference = serde_json::from_value(serde_json::json!({"value": 3})).unwrap(); + assert_eq!(m.value, Some(3)); + assert_eq!( + serde_json::to_value(&m).unwrap(), + serde_json::json!({"value": 3}) + ); + } + + /// The untagged variant order is load-bearing: asserting only on the + /// re-serialized JSON would pass even if a value landed in the wrong + /// variant, so pin the variant identity itself. + /// + /// Material values are nested two levels less deeply than the boundaries + /// of the geometry that carries them (CityJSON 2.0, section 6.1). + #[test] + fn material_values_land_in_the_variant_their_depth_implies() { + let parse = |v: serde_json::Value| -> MaterialValues { serde_json::from_value(v).unwrap() }; + + //-- MultiSurface/CompositeSurface: one index per surface + assert!(matches!( + parse(serde_json::json!([0, null, 2])), + MaterialValues::Surfaces(_) + )); + //-- Solid: one index per surface, per shell + assert!(matches!( + parse(serde_json::json!([[0, null, 2]])), + MaterialValues::Shells(_) + )); + //-- MultiSolid/CompositeSolid: ... per solid + assert!(matches!( + parse(serde_json::json!([[[0, null, 2]]])), + MaterialValues::Solids(_) + )); + + //-- the empty array is ambiguous; it must resolve to the shallowest + //-- variant, and stay `[]` on the way out + let empty = parse(serde_json::json!([])); + assert!(matches!(empty, MaterialValues::Surfaces(_))); + assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([])); + + //-- deeper than the ladder is not silently accepted + assert!(serde_json::from_value::(serde_json::json!([[[[0]]]])).is_err()); + //-- and neither is a bare scalar + assert!(serde_json::from_value::(serde_json::json!(0)).is_err()); + } + + /// Texture values are nested exactly as deeply as the boundaries of the + /// geometry that carries them (CityJSON 2.0, section 6.2), each ring + /// becoming `[texture_index, uv_index, ...]`. + #[test] + fn texture_values_land_in_the_variant_their_depth_implies() { + let parse = |v: serde_json::Value| -> TextureValues { serde_json::from_value(v).unwrap() }; + + //-- MultiSurface/CompositeSurface: per surface, per ring + assert!(matches!( + parse(serde_json::json!([[[0, 10, 11, 12]]])), + TextureValues::Surface(_) + )); + //-- Solid: ... per shell + assert!(matches!( + parse(serde_json::json!([[[[0, 10, 11, 12]]]])), + TextureValues::Shell(_) + )); + //-- MultiSolid/CompositeSolid: ... per solid + assert!(matches!( + parse(serde_json::json!([[[[[0, 10, 11, 12]]]]])), + TextureValues::Solid(_) + )); + //-- an untextured ring is `[null]`, at every depth + assert!(matches!( + parse(serde_json::json!([[[null]]])), + TextureValues::Surface(_) + )); + + let empty = parse(serde_json::json!([])); + assert!(matches!(empty, TextureValues::Surface(_))); + assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([])); + + //-- too deep, and too shallow, are both rejected: the untyped + //-- `Option` this replaces accepted either without a murmur + assert!(serde_json::from_value::(serde_json::json!([[[[[[0]]]]]])).is_err()); + assert!(serde_json::from_value::(serde_json::json!([0, 1])).is_err()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a850d53 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,58 @@ +use crate::geometry::GeometryType; +use std::fmt; + +/// The crate's error type. +/// +/// Implemented by hand: this crate does not hand-write serde impls, and it +/// does not take a derive dependency for its error type either. +#[derive(Debug)] +pub enum CjseqError { + /// A value was not the JSON the type expected, and nothing more specific + /// could be said about why. + Json(serde_json::Error), + /// A geometry's `boundaries` were nested to a depth its `type` does not + /// allow. serde reports this as "invalid type: integer, expected a + /// sequence", which names neither the geometry nor the depth it wanted; + /// this variant names both. + GeometryDepth { + geometry_type: GeometryType, + /// The nesting depth the geometry type requires. + expected: usize, + /// What was found instead, in words. + found: String, + }, +} + +impl fmt::Display for CjseqError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CjseqError::Json(e) => write!(f, "invalid JSON: {e}"), + CjseqError::GeometryDepth { + geometry_type, + expected, + found, + } => write!( + f, + "a {geometry_type:?} nests its boundaries {expected} levels deep, but {found}" + ), + } + } +} + +impl std::error::Error for CjseqError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CjseqError::Json(e) => Some(e), + CjseqError::GeometryDepth { .. } => None, + } + } +} + +impl From for CjseqError { + fn from(e: serde_json::Error) -> Self { + CjseqError::Json(e) + } +} + +/// The crate's result type. +pub type Result = std::result::Result; diff --git a/src/geometry.rs b/src/geometry.rs index f502af9..3e37660 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,4 +1,5 @@ -use crate::appearance::{Material, Texture}; +use crate::appearance::{MaterialReference, TextureReference}; +use crate::error::{CjseqError, Result}; use crate::semantics::Semantics; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -25,19 +26,39 @@ pub enum GeometryType { GeometryInstance, } +impl GeometryType { + /// The nesting depth `boundaries` must have for this geometry type: the + /// number of array levels between the outermost array and a vertex index. + pub fn boundary_depth(self) -> usize { + match self { + GeometryType::MultiPoint | GeometryType::GeometryInstance => 1, + GeometryType::MultiLineString => 2, + GeometryType::MultiSurface | GeometryType::CompositeSurface => 3, + GeometryType::Solid => 4, + GeometryType::MultiSolid | GeometryType::CompositeSolid => 5, + } + } +} + /// The members every geometry may carry that are neither its type, its lod, -/// nor its boundaries. -/// -/// The appearance values stay untyped for now; typing them is the subject of -/// later work. +/// nor its boundaries. The appearance values are depth-typed, keyed by theme. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct GeometryCommon { #[serde(skip_serializing_if = "Option::is_none")] pub semantics: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub material: Option>, + pub material: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub texture: Option>, + pub texture: Option>, +} + +/// The number of array levels between `v` and the deepest non-array value +/// inside it. A non-array is 0 deep; `[]` is 1 deep. +fn nesting_depth(v: &Value) -> usize { + match v { + Value::Array(a) => 1 + a.iter().map(nesting_depth).max().unwrap_or(0), + _ => 0, + } } /// A CityJSON geometry object. @@ -107,6 +128,38 @@ pub enum Geometry { } impl Geometry { + /// Parse a geometry, reporting a wrong `boundaries` depth in terms of the + /// geometry type rather than in terms of serde's innards. + /// + /// `serde_json::from_value::` works just as well and is what + /// `#[derive(Deserialize)]` gives every containing type; the only thing + /// this adds is a readable error. Deserializing a deeply nested `Vec` + /// otherwise fails with "invalid type: integer, expected a sequence", + /// which says neither which geometry was at fault nor what depth it + /// wanted. + pub fn from_json_value(v: Value) -> Result { + let err = match serde_json::from_value::(v.clone()) { + Ok(g) => return Ok(g), + Err(e) => e, + }; + //-- can the failure be pinned on the depth of `boundaries`? + let thetype = v + .get("type") + .and_then(|t| serde_json::from_value::(t.clone()).ok()); + if let (Some(thetype), Some(boundaries)) = (thetype, v.get("boundaries")) { + let expected = thetype.boundary_depth(); + let found = nesting_depth(boundaries); + if found != expected { + return Err(CjseqError::GeometryDepth { + geometry_type: thetype, + expected, + found: format!("these boundaries are nested {found} levels deep"), + }); + } + } + Err(CjseqError::Json(err)) + } + pub fn geometry_type(&self) -> GeometryType { match self { Geometry::MultiPoint { .. } => GeometryType::MultiPoint, @@ -205,8 +258,10 @@ impl Geometry { } } + /// Renumber every material index against `m_oldnew`, first-encounter + /// order. The depth of `values` comes from its variant, so the geometry + /// type is never consulted. pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { - let thetype = self.geometry_type(); let Some(common) = self.common_mut() else { return; }; @@ -214,59 +269,33 @@ impl Geometry { return; }; for mat in materials.values_mut() { - //-- material.value + //-- material.value colours the whole object if let Some(thevalue) = mat.value { let l = m_oldnew.len(); mat.value = Some(*m_oldnew.entry(thevalue).or_insert(l)); continue; } - //-- else it's material.values (which differs per geom type) - if mat.values.is_none() { + //-- else it's material.values, one index per surface + let Some(values) = mat.values.as_mut() else { continue; - } - match thetype { - GeometryType::MultiPoint | GeometryType::MultiLineString => (), - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let mut a: Vec> = - serde_json::from_value(mat.values.take().into()).unwrap(); - for x in a.iter_mut().flatten() { - let l = m_oldnew.len(); - *x = *m_oldnew.entry(*x).or_insert(l); - } - mat.values = Some(serde_json::to_value(&a).unwrap()); - } - GeometryType::Solid => { - let mut a: Vec>> = - serde_json::from_value(mat.values.take().into()).unwrap(); - for x in a.iter_mut().flatten().flatten() { - let l = m_oldnew.len(); - *x = *m_oldnew.entry(*x).or_insert(l); - } - mat.values = Some(serde_json::to_value(&a).unwrap()); - } - GeometryType::MultiSolid | GeometryType::CompositeSolid => { - let mut a: Vec>>> = - serde_json::from_value(mat.values.take().into()).unwrap(); - for x in a.iter_mut().flatten().flatten().flatten() { - let l = m_oldnew.len(); - *x = *m_oldnew.entry(*x).or_insert(l); - } - mat.values = Some(serde_json::to_value(&a).unwrap()); - } - GeometryType::GeometryInstance => unreachable!( - "GeometryInstance carries no material; common_mut() returned None above" - ), + }; + for x in values.indices_mut() { + let l = m_oldnew.len(); + *x = *m_oldnew.entry(*x).or_insert(l); } } } + /// Renumber every texture index against `t_oldnew` and every texture + /// vertex against `t_v_oldnew`. As with `update_material`, the depth comes + /// from the variant, so every geometry that can carry a texture is walked + /// the same way. pub(crate) fn update_texture( &mut self, t_oldnew: &mut HashMap, t_v_oldnew: &mut HashMap, offset: usize, ) { - let thetype = self.geometry_type(); let Some(common) = self.common_mut() else { return; }; @@ -291,32 +320,12 @@ impl Geometry { } }; for tex in textures.values_mut() { - match thetype { - GeometryType::MultiSurface | GeometryType::CompositeSurface => { - let mut a: Vec>>> = - serde_json::from_value(tex.values.take().into()).unwrap(); - for y in a.iter_mut().flatten() { - for (k, z) in y.iter_mut().enumerate() { - if let Some(thevalue) = *z { - *z = Some(remap(k == 0, thevalue)); - } - } + for ring in tex.values.rings_mut() { + for (k, z) in ring.iter_mut().enumerate() { + if let Some(thevalue) = *z { + *z = Some(remap(k == 0, thevalue)); } - tex.values = Some(serde_json::to_value(&a).unwrap()); } - GeometryType::Solid => { - let mut a: Vec>>>> = - serde_json::from_value(tex.values.take().into()).unwrap(); - for z in a.iter_mut().flatten().flatten() { - for (l, zz) in z.iter_mut().enumerate() { - if let Some(thevalue) = *zz { - *zz = Some(remap(l == 0, thevalue)); - } - } - } - tex.values = Some(serde_json::to_value(&a).unwrap()); - } - _ => todo!(), } } } @@ -333,7 +342,7 @@ pub struct GeometryTemplates { mod tests { use super::*; - fn parse(v: serde_json::Value) -> Result { + fn parse(v: serde_json::Value) -> std::result::Result { serde_json::from_value(v) } @@ -487,10 +496,11 @@ mod tests { let mut t_v_oldnew: HashMap = HashMap::new(); g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 100); - let values = g.common().unwrap().texture.as_ref().unwrap()["winter"] - .values - .clone() - .unwrap(); + //-- `values` is now typed, so compare its serialization: the expected + //-- values below are untouched from the oracle run at a97bc2d. + let values = + serde_json::to_value(&g.common().unwrap().texture.as_ref().unwrap()["winter"].values) + .unwrap(); assert_eq!( values, serde_json::json!([ @@ -529,10 +539,14 @@ mod tests { let mut m_oldnew: HashMap = HashMap::new(); g.update_material(&mut m_oldnew); - let values = g.common().unwrap().material.as_ref().unwrap()["irradiation"] - .values - .clone() - .unwrap(); + //-- as above: typed `values`, oracle expectations unchanged. + let values = serde_json::to_value( + g.common().unwrap().material.as_ref().unwrap()["irradiation"] + .values + .as_ref() + .unwrap(), + ) + .unwrap(); assert_eq!( values, serde_json::json!([[0, null, 1], [1, null, 0]]), @@ -544,6 +558,64 @@ mod tests { assert_eq!(m, vec![(0, 0), (2, 1)]); } + /// A raw serde error on a deeply nested `Vec` reads "invalid type: + /// integer, expected a sequence", which names neither the geometry nor the + /// depth it wanted. `from_json_value` must say both. + #[test] + fn wrong_depth_reports_which_geometry_and_which_depth() { + let err = Geometry::from_json_value(serde_json::json!({ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[[0, 1, 2]]]] + })) + .unwrap_err(); + + match &err { + CjseqError::GeometryDepth { + geometry_type, + expected, + found, + } => { + assert_eq!(*geometry_type, GeometryType::MultiSurface); + assert_eq!(*expected, 3, "a MultiSurface nests boundaries 3 deep"); + assert!( + found.contains('4'), + "the message must say what depth was actually found, got {found:?}" + ); + } + other => panic!("expected a GeometryDepth error, got {other:?}"), + } + + let msg = err.to_string(); + assert!( + msg.contains("MultiSurface") && msg.contains('3'), + "the message must name the geometry and the depth it expected, got {msg:?}" + ); + } + + /// A geometry whose boundaries are the right depth but which is malformed + /// some other way is not misreported as a depth problem. + #[test] + fn a_non_depth_failure_stays_a_json_error() { + let err = Geometry::from_json_value(serde_json::json!({ + "type": "MultiSurface", "lod": "2", + "boundaries": [[["not an index"]]] + })) + .unwrap_err(); + assert!( + matches!(err, CjseqError::Json(_)), + "expected a Json error, got {err:?}" + ); + } + + #[test] + fn from_json_value_accepts_a_good_geometry() { + let g = Geometry::from_json_value(serde_json::json!({ + "type": "Solid", "lod": "2", "boundaries": [[[[0, 1, 2]]]] + })) + .unwrap(); + assert_eq!(g.geometry_type(), GeometryType::Solid); + } + #[test] fn absent_members_are_not_serialized_as_null() { let g: Geometry = serde_json::from_value(serde_json::json!({ diff --git a/src/lib.rs b/src/lib.rs index 169dd04..ded15d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,9 +9,10 @@ //! Internally the crate is organised into one module per concern: //! - `geometry` — `Geometry`, `GeometryType`, `GeometryTemplates` //! - `semantics` — `Semantics`, `SemanticsSurface`, `SemanticsValues` -//! - `appearance` — `Appearance`, `Material`, `Texture` +//! - `appearance` — `Appearance`, `MaterialReference`, `MaterialValues`, `TextureReference`, `TextureValues` //! - `city_object` — `CityObject` //! - `metadata` — `Metadata`, `PointOfContact`, `Address`, `ReferenceSystem`, `Transform`, `GeographicalExtent` +//! - `error` — `CjseqError`, `Result` //! - `cityjson` — `CityJSON`, `CityJSONFeature`, `SortingStrategy`, and the sequencing/collect/filter logic //! //! Every type is re-exported at the crate root, so the module boundaries @@ -21,6 +22,7 @@ mod appearance; mod city_object; mod cityjson; +mod error; mod geometry; mod metadata; mod semantics; @@ -28,6 +30,7 @@ mod semantics; pub use appearance::*; pub use city_object::*; pub use cityjson::*; +pub use error::*; pub use geometry::*; pub use metadata::*; pub use semantics::*; From e4f88c699a3db6a256c6e144e518d58412f8d5d4 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 20:47:37 +0200 Subject: [PATCH 10/65] fix!: allow null sub-arrays where the CityJSON schema does geomprimitives.schema.json types material.values and semantics.values as ["array","null"] at *every* nesting level, not just at the leaf, so a whole null shell or null solid is valid CityJSON. Both ladders carried Option only at the leaf and hard-failed such documents with an untagged-enum error that names neither the path nor the position. Make the intermediate levels Option in MaterialValues and SemanticsValues, and make Semantics.values itself an Option (required member, nullable value) so it is written back as null rather than dropped. Every None serializes as null, never as [] -- finding #7 -- pinned by test at each level. texture.values is deliberately left strict: the schema types it and each of its intermediate items as a plain "array", with only the innermost items ["integer","null"], so a null surface or shell there is not valid CityJSON. TextureReference.values does become Option, because the per-theme texture object carries no "required" keyword at all. Also drop the deep copy on the happy path of Geometry::from_json_value by deserializing from &Value instead of cloning into from_value. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appearance.rs | 172 +++++++++++++++++++++++++++++++++++++++++++--- src/geometry.rs | 13 +++- src/semantics.rs | 105 +++++++++++++++++++++++++++- 3 files changed, 276 insertions(+), 14 deletions(-) diff --git a/src/appearance.rs b/src/appearance.rs index 315ba47..e8a2967 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,10 +1,23 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +/// One shell's worth of material indices, one per surface. `None` — a whole +/// shell with no material — is `null` in JSON, which +/// `geomprimitives.schema.json` permits at every level of `material.values` +/// (`"type": ["array", "null"]`). +pub type MaterialShell = Vec>; +/// One solid's worth of material indices, one per surface, per shell. +pub type MaterialSolid = Vec>; + /// The `values` array of a [`MaterialReference`]: one index into the /// document's `materials` palette per *surface*, so it is nested exactly two /// levels less deeply than that geometry's `boundaries` (CityJSON 2.0, -/// section 6.1). A surface with no material is `null`, never `[]`. +/// section 6.1). +/// +/// `null` is permitted at *every* level, not only at the leaf: a surface, a +/// whole shell, or a whole solid may have no material. Every one of those +/// `None`s must serialize back as `null` and never as `[]` — that is +/// finding #7, and it is pinned by test at each level. /// /// The variants are ordered shallowest-first; serde tries them in declaration /// order, so a value only reaches a deeper variant when the shallower ones @@ -15,20 +28,28 @@ pub enum MaterialValues { /// `MultiSurface`, `CompositeSurface`: one index per surface. Surfaces(Vec>), /// `Solid`: one index per surface, per shell. - Shells(Vec>>), + Shells(Vec>), /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per /// solid. - Solids(Vec>>>), + Solids(Vec>), } impl MaterialValues { /// Every material index, in document order, whatever the depth. The depth - /// is known from the variant, so no runtime inspection is needed. + /// is known from the variant, so no runtime inspection is needed; the + /// `flatten()`s skip `None` sub-arrays as well as `null` leaves. pub(crate) fn indices_mut(&mut self) -> Box + '_> { match self { MaterialValues::Surfaces(v) => Box::new(v.iter_mut().flatten()), - MaterialValues::Shells(v) => Box::new(v.iter_mut().flatten().flatten()), - MaterialValues::Solids(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), + MaterialValues::Shells(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), + MaterialValues::Solids(v) => Box::new( + v.iter_mut() + .flatten() + .flatten() + .flatten() + .flatten() + .flatten(), + ), } } } @@ -49,7 +70,14 @@ pub type TexturedShell = Vec; /// /// Only surface-bearing geometries can carry a texture, so the ladder starts /// at `MultiSurface` depth: a `MultiPoint` or `MultiLineString` has no rings -/// to texture. +/// to texture, and both declare `additionalProperties: false` without a +/// `texture` member. +/// +/// Unlike [`MaterialValues`], the intermediate levels are *not* nullable. +/// `geomprimitives.schema.json` types `texture.values` and every one of its +/// intermediate `items` as a plain `"array"` — only the innermost items are +/// `["integer", "null"]` — so `[null, [[0, 10, 11]]]` is not valid CityJSON +/// and is rejected here. /// /// The variants are ordered shallowest-first, as in [`MaterialValues`]. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -88,9 +116,15 @@ pub struct MaterialReference { } /// One theme's texture assignment for a geometry. +/// +/// `values` is optional: the per-theme texture object in +/// `geomprimitives.schema.json` carries no `required` keyword at all (in +/// contrast to `material`, which requires exactly one of `value`/`values`), so +/// a bare `{"theme": {}}` is valid CityJSON. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct TextureReference { - pub values: TextureValues, + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -259,6 +293,128 @@ mod tests { assert!(serde_json::from_value::(serde_json::json!([[[[0]]]])).is_err()); //-- and neither is a bare scalar assert!(serde_json::from_value::(serde_json::json!(0)).is_err()); + //-- the untyped Option this replaced accepted six levels + assert!( + serde_json::from_value::(serde_json::json!([[[[[[0]]]]]])).is_err() + ); + } + + /// `geomprimitives.schema.json` types `material.values` and every one of + /// its intermediate `items` as `["array", "null"]`, so a whole null shell + /// or a whole null solid is valid CityJSON. Each of those `None`s must + /// come back as `null` — never as `[]`, which is finding #7, the bug this + /// whole rewrite exists to prevent. + #[test] + fn a_null_material_sub_array_round_trips_as_null_at_every_level() { + for input in [ + //-- leaf: a surface with no material + serde_json::json!([0, null, 2]), + //-- intermediate: a whole shell with no material + serde_json::json!([[0, 1], null]), + serde_json::json!([null, [0, 1]]), + //-- intermediate: a whole solid, and a shell inside a solid + serde_json::json!([[[0, 1]], null]), + serde_json::json!([[[0, 1], null], null]), + //-- nothing but nulls, at each depth + serde_json::json!([null, null]), + serde_json::json!([[null], null]), + ] { + let v: MaterialValues = serde_json::from_value(input.clone()) + .unwrap_or_else(|e| panic!("{input} is schema-valid CityJSON: {e}")); + assert_eq!( + serde_json::to_value(&v).unwrap(), + input, + "{input} must round-trip with its nulls intact, never as []" + ); + } + } + + /// The variant ladder must still resolve by depth now that the + /// intermediate levels are `Option`. + #[test] + fn null_sub_arrays_do_not_disturb_the_material_ladder() { + let parse = |v: serde_json::Value| -> MaterialValues { serde_json::from_value(v).unwrap() }; + + assert!(matches!( + parse(serde_json::json!([null, 1])), + MaterialValues::Surfaces(_) + )); + assert!(matches!( + parse(serde_json::json!([[0, 1], null])), + MaterialValues::Shells(_) + )); + assert!(matches!( + parse(serde_json::json!([[[0, 1]], null])), + MaterialValues::Solids(_) + )); + + //-- the empty-shape edges: each is ambiguous and must settle on the + //-- shallowest variant that can hold it, and stay byte-identical + for (input, is_surfaces) in [ + (serde_json::json!([]), true), + (serde_json::json!([[]]), false), + (serde_json::json!([[[]]]), false), + ] { + let v = parse(input.clone()); + assert_eq!(serde_json::to_value(&v).unwrap(), input, "{input}"); + assert_eq!( + matches!(v, MaterialValues::Surfaces(_)), + is_surfaces, + "{input} landed in {v:?}" + ); + } + assert!(matches!( + parse(serde_json::json!([[]])), + MaterialValues::Shells(_) + )); + assert!(matches!( + parse(serde_json::json!([[[]]])), + MaterialValues::Solids(_) + )); + } + + /// The texture ladder is deliberately *not* null-tolerant at its + /// intermediate levels: `geomprimitives.schema.json` types + /// `texture.values` and each intermediate `items` as a plain `"array"`, + /// with only the innermost items `["integer", "null"]`. An untextured ring + /// is spelled `[null]`, not `null`. + #[test] + fn texture_nulls_are_leaf_only() { + //-- what the schema allows: a null texture index inside a ring + let t: TextureValues = serde_json::from_value(serde_json::json!([[[null]]])).unwrap(); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!([[[null]]]) + ); + + //-- what it does not: a null in place of a surface, ring, or shell + for bad in [ + serde_json::json!([null, [[0, 10, 11]]]), + serde_json::json!([[null]]), + serde_json::json!([[[[0, 1]]], null]), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "{bad} is not valid CityJSON and must be rejected" + ); + } + } + + /// The per-theme texture object has no `required` keyword in the schema, + /// so both of these are valid CityJSON and must parse. + #[test] + fn a_texture_reference_may_carry_no_values() { + let absent: TextureReference = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(absent.values, None); + //-- and an absent `values` must not reappear as `null` on the way out + assert_eq!( + serde_json::to_value(&absent).unwrap(), + serde_json::json!({}) + ); + + let null: TextureReference = + serde_json::from_value(serde_json::json!({"values": null})).unwrap(); + assert_eq!(null.values, None); } /// Texture values are nested exactly as deeply as the boundaries of the diff --git a/src/geometry.rs b/src/geometry.rs index 3e37660..f169d38 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -138,14 +138,17 @@ impl Geometry { /// which says neither which geometry was at fault nor what depth it /// wanted. pub fn from_json_value(v: Value) -> Result { - let err = match serde_json::from_value::(v.clone()) { + //-- `&Value` is itself a Deserializer, so the happy path borrows `v` + //-- instead of deep-copying every boundary just to keep `v` alive for + //-- the error branch below. + let err = match Geometry::deserialize(&v) { Ok(g) => return Ok(g), Err(e) => e, }; //-- can the failure be pinned on the depth of `boundaries`? let thetype = v .get("type") - .and_then(|t| serde_json::from_value::(t.clone()).ok()); + .and_then(|t| GeometryType::deserialize(t).ok()); if let (Some(thetype), Some(boundaries)) = (thetype, v.get("boundaries")) { let expected = thetype.boundary_depth(); let found = nesting_depth(boundaries); @@ -320,7 +323,11 @@ impl Geometry { } }; for tex in textures.values_mut() { - for ring in tex.values.rings_mut() { + //-- a theme may legally carry no `values` at all + let Some(values) = tex.values.as_mut() else { + continue; + }; + for ring in values.rings_mut() { for (k, z) in ring.iter_mut().enumerate() { if let Some(thevalue) = *z { *z = Some(remap(k == 0, thevalue)); diff --git a/src/semantics.rs b/src/semantics.rs index 193b171..37303db 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -22,10 +22,22 @@ pub struct SemanticsSurface { pub other: HashMap, } +/// One shell's worth of semantic-surface indices, one per surface. `None` — a +/// whole shell with no semantics — is `null` in JSON, which +/// `geomprimitives.schema.json` permits at every level of `semantics.values` +/// (`"type": ["array", "null"]`), exactly as it does for `material.values`. +pub type SemanticsShell = Vec>; +/// One solid's worth of semantic-surface indices, per surface, per shell. +pub type SemanticsSolid = Vec>; + /// The `values` array of a [`Semantics`] object: one index into `surfaces` per /// *surface* of the geometry, so it is nested exactly one level less deeply /// than that geometry's `boundaries`. /// +/// As with [`crate::MaterialValues`], `null` is permitted at every level, not +/// only at the leaf, and every `None` must serialize back as `null` rather +/// than `[]` (finding #7). +/// /// The variants are ordered shallowest-first; serde tries them in declaration /// order, so a value only reaches a deeper variant when the shallower ones /// cannot describe it. @@ -35,17 +47,23 @@ pub enum SemanticsValues { /// `MultiSurface`, `CompositeSurface`: one index per surface. Surfaces(Vec>), /// `Solid`: one index per surface, per shell. - Shells(Vec>>), + Shells(Vec>), /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per /// solid. - Solids(Vec>>>), + Solids(Vec>), } /// The `semantics` member of a geometry. +/// +/// `values` is a *required* member whose value may be `null` +/// (`"type": ["array", "null"]`, and `"required": ["surfaces", "values"]`), so +/// it is an `Option` that is serialized even when `None` — writing it out as +/// `null` rather than dropping the member, which would produce a document the +/// schema rejects. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Semantics { pub surfaces: Vec, - pub values: SemanticsValues, + pub values: Option, } #[cfg(test)] @@ -127,4 +145,85 @@ mod tests { //-- a shape deeper than the ladder is not silently accepted assert!(serde_json::from_value::(serde_json::json!([[[[0]]]])).is_err()); } + + /// `geomprimitives.schema.json` types `semantics.values` and every one of + /// its intermediate `items` as `["array", "null"]`, exactly as it does for + /// `material.values` — so a whole null shell is valid CityJSON here too, + /// and each `None` must come back as `null`, never as `[]` (finding #7). + #[test] + fn a_null_semantics_sub_array_round_trips_as_null_at_every_level() { + for input in [ + serde_json::json!([0, null, 2]), + serde_json::json!([[0, 1], null]), + serde_json::json!([null, [0, 1]]), + serde_json::json!([[[0, 1]], null]), + serde_json::json!([[[0, 1], null], null]), + serde_json::json!([null, null]), + serde_json::json!([[null], null]), + ] { + let v: SemanticsValues = serde_json::from_value(input.clone()) + .unwrap_or_else(|e| panic!("{input} is schema-valid CityJSON: {e}")); + assert_eq!( + serde_json::to_value(&v).unwrap(), + input, + "{input} must round-trip with its nulls intact, never as []" + ); + } + } + + /// `values` is required by the schema but may be `null`. It must parse, + /// and it must be written back out as `null` — dropping the member + /// entirely would violate `"required": ["surfaces", "values"]`. + #[test] + fn a_null_semantics_values_member_round_trips() { + let input = serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}], "values": null + }); + let s: Semantics = + serde_json::from_value(input.clone()).expect("`values: null` is schema-valid CityJSON"); + assert_eq!(s.values, None); + assert_eq!(serde_json::to_value(&s).unwrap(), input); + } + + /// The ladder must still resolve by depth now that the intermediate levels + /// are `Option`, including the ambiguous empty shapes. + #[test] + fn null_sub_arrays_do_not_disturb_the_semantics_ladder() { + let parse = + |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() }; + + assert!(matches!( + parse(serde_json::json!([null, 1])), + SemanticsValues::Surfaces(_) + )); + assert!(matches!( + parse(serde_json::json!([[0, 1], null])), + SemanticsValues::Shells(_) + )); + assert!(matches!( + parse(serde_json::json!([[[0, 1]], null])), + SemanticsValues::Solids(_) + )); + + for input in [ + serde_json::json!([]), + serde_json::json!([[]]), + serde_json::json!([[[]]]), + ] { + let v = parse(input.clone()); + assert_eq!(serde_json::to_value(&v).unwrap(), input, "{input}"); + } + assert!(matches!( + parse(serde_json::json!([])), + SemanticsValues::Surfaces(_) + )); + assert!(matches!( + parse(serde_json::json!([[]])), + SemanticsValues::Shells(_) + )); + assert!(matches!( + parse(serde_json::json!([[[]]])), + SemanticsValues::Solids(_) + )); + } } From fbe1b56e171d69dd13ff2321a4b5482e57ebf330 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 20:58:29 +0200 Subject: [PATCH 11/65] fix!: keep an explicit null material.values distinct from an absent one {"theme":{"values":null}} is schema-valid, but with a plain Option field it deserialized to the same value as {"theme":{}} and re-emitted as {}, which satisfies neither branch of material's oneOf(value|values) and is rejected by a validator. Same bug class as the Semantics.values fix in e4f88c6. values becomes Option>: the outer level is present-vs-absent, the inner null-vs-array. serde_json maps a JSON null onto the *outer* None of a plain double Option, so the field needs a three-line deserialize_with that defers to the derived Deserialize of the inner type and wraps it in Some. No visitor is written and serialization stays fully derived. Also retitle the TextureReference {"values": null} assertion: that shape is invalid CityJSON, and the test now documents accepting it as a deliberate leniency that normalizes to {} rather than claiming schema support. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appearance.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++-- src/geometry.rs | 6 ++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/appearance.rs b/src/appearance.rs index e8a2967..6675cc1 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -104,13 +104,41 @@ impl TextureValues { } } +/// Keep an explicit `null` distinct from an absent member. +/// +/// `serde_json` maps a JSON `null` onto the *outer* `None` of a plain +/// `Option>`, which collapses `{"values": null}` and `{}` onto the +/// same value and re-emits the first as the second. Deserializing the inner +/// `Option` and wrapping that in `Some` keeps the two apart. +/// +/// This delegates entirely to the derived `Deserialize` of `T` — it writes no +/// visitor — and it does not touch serialization, which stays fully derived. +fn present_even_if_null<'de, T, D>(de: D) -> std::result::Result, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + T::deserialize(de).map(Some) +} + /// One theme's material assignment for a geometry: either a `value` colouring /// the whole object, or a depth-typed `values` array with one index per /// surface. +/// +/// The schema requires exactly one of the two +/// (`oneOf: [{required: ["value"]}, {required: ["values"]}]`) but separately +/// permits `"values": null`. So `values` is a *double* option: the outer level +/// is present-vs-absent, the inner is `null`-vs-array. Without that +/// distinction a schema-valid `{"values": null}` re-emits as `{}`, which +/// satisfies neither branch of the `oneOf` and is rejected by a validator. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct MaterialReference { - #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, + #[serde( + default, + deserialize_with = "present_even_if_null", + skip_serializing_if = "Option::is_none" + )] + pub values: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, } @@ -412,9 +440,60 @@ mod tests { serde_json::json!({}) ); + //-- Documented leniency, NOT a schema claim: `texture.values` is typed + //-- `"type": "array"`, so `{"values": null}` is *invalid* CityJSON. A + //-- plain `Option` field accepts it anyway, and normalizes it to the + //-- valid `{}` on the way out. Pinned so the normalization is + //-- deliberate rather than incidental. let null: TextureReference = serde_json::from_value(serde_json::json!({"values": null})).unwrap(); assert_eq!(null.values, None); + assert_eq!(serde_json::to_value(&null).unwrap(), serde_json::json!({})); + } + + /// A `material` theme has three distinct states, and the schema + /// distinguishes all three: absent (invalid on its own, but the `value` + /// sibling may be carrying the assignment), explicitly `null`, and an + /// array. Collapsing `null` onto absent re-emits `{"values": null}` as + /// `{}`, which satisfies neither branch of the schema's + /// `oneOf: [{required: ["value"]}, {required: ["values"]}]`. + #[test] + fn material_values_distinguishes_absent_from_null_from_array() { + //-- absent: only `value` carries the assignment + let absent: MaterialReference = + serde_json::from_value(serde_json::json!({"value": 3})).unwrap(); + assert_eq!(absent.values, None); + assert_eq!( + serde_json::to_value(&absent).unwrap(), + serde_json::json!({"value": 3}), + "an absent `values` must stay absent" + ); + + //-- explicitly null: "no materials anywhere on this geometry" + let null: MaterialReference = + serde_json::from_value(serde_json::json!({"values": null})).unwrap(); + assert_eq!(null.values, Some(None)); + assert_eq!( + serde_json::to_value(&null).unwrap(), + serde_json::json!({"values": null}), + "an explicit `null` must be written back as `null`, not dropped" + ); + + //-- an array: the ordinary case + let array: MaterialReference = + serde_json::from_value(serde_json::json!({"values": [0, 1]})).unwrap(); + assert_eq!( + array.values, + Some(Some(MaterialValues::Surfaces(vec![Some(0), Some(1)]))) + ); + assert_eq!( + serde_json::to_value(&array).unwrap(), + serde_json::json!({"values": [0, 1]}) + ); + + //-- and a bare `{}` stays `{}` rather than gaining a null + let empty: MaterialReference = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!({})); } /// Texture values are nested exactly as deeply as the boundaries of the diff --git a/src/geometry.rs b/src/geometry.rs index f169d38..7e24d60 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -278,8 +278,10 @@ impl Geometry { mat.value = Some(*m_oldnew.entry(thevalue).or_insert(l)); continue; } - //-- else it's material.values, one index per surface - let Some(values) = mat.values.as_mut() else { + //-- else it's material.values, one index per surface. The outer + //-- Option is present-vs-absent, the inner null-vs-array; there is + //-- nothing to renumber unless both are Some. + let Some(values) = mat.values.as_mut().and_then(|v| v.as_mut()) else { continue; }; for x in values.indices_mut() { From 1cd7f2154c0a9be564655364d421b527c10bce09 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 21:26:01 +0200 Subject: [PATCH 12/65] feat!: typed CityObject/semantic-surface tags with extension support Co-Authored-By: Claude Opus 4.8 (1M context) --- src/city_object.rs | 238 ++++++++++++++++++++++++++++++++++++++++++++- src/cityjson.rs | 68 ++++++++++++- src/main.rs | 9 +- src/semantics.rs | 178 ++++++++++++++++++++++++++++++++- tests/cj_input.rs | 2 +- 5 files changed, 483 insertions(+), 12 deletions(-) diff --git a/src/city_object.rs b/src/city_object.rs index 9d20fdf..82bf8ba 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -2,10 +2,145 @@ use crate::geometry::Geometry; use serde::{Deserialize, Serialize}; use serde_json::Value; +/// The known 1st- and 2nd-level City Object types (§ 2 The different City +/// Objects). Every variant is a unit variant and every identifier is already +/// spelled exactly as the spec requires (eg `TINRelief`, `WaterBody`, +/// `CityFurniture`), so serde's default (non-untagged) derive collapses each +/// one to its bare name string with no `#[serde(rename)]` needed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub enum KnownCityObjectType { + Bridge, + BridgePart, + BridgeInstallation, + BridgeConstructiveElement, + BridgeRoom, + BridgeFurniture, + Building, + BuildingPart, + BuildingInstallation, + BuildingConstructiveElement, + BuildingFurniture, + BuildingStorey, + BuildingRoom, + BuildingUnit, + CityFurniture, + CityObjectGroup, + GenericCityObject, + LandUse, + OtherConstruction, + PlantCover, + SolitaryVegetationObject, + TINRelief, + Road, + Railway, + Waterway, + TransportSquare, + Tunnel, + TunnelPart, + TunnelInstallation, + TunnelConstructiveElement, + TunnelHollowSpace, + TunnelFurniture, + WaterBody, +} + +/// The `type` member of a City Object (§ 2 The different City Objects). +/// +/// `Known` covers the 1st- and 2nd-level City Object types defined by the +/// CityJSON 2.0.1 spec. Any other value is a CityJSON Extension type, which +/// the spec requires to start with `"+"` (§ 8 Extensions) -- `Extension` +/// carries that string verbatim so a file using an Extension (eg +/// `"+NoiseCityFurnitureSegment"`) round-trips byte for byte. +/// +/// This is `#[serde(untagged)]` over a *nested* known-variants enum, not a +/// single flat enum mixing unit variants with `Extension(String)` directly: +/// under `#[serde(untagged)]`, a bare unit variant (de)serializes as JSON +/// `null`, not its name (this is a documented serde quirk -- untagged +/// dispatch tries each variant's own `Deserialize` standalone, and a unit +/// variant's is `deserialize_unit`, which only accepts `null`). A flat +/// mixed enum was tried and empirically failed this exact way: every known +/// name fell through to `Extension` because none of the unit variants ever +/// matched a JSON string. Nesting the known names inside their own +/// unit-only enum sidesteps this, because *that* enum, derived without +/// `#[serde(untagged)]`, gets the ordinary external-tagging collapse (a +/// unit-only enum's tag+content is just the bare name string). +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum CityObjectType { + Known(KnownCityObjectType), + /// Not a core CityJSON type: a CityJSON Extension type, always spelled + /// with a leading `"+"`. + Extension(String), +} + +/// Flat, spec-spelling access to each known variant (eg +/// `CityObjectType::BuildingPart`), so call sites read exactly like the +/// CityJSON type name rather than `CityObjectType::Known(KnownCityObjectType::BuildingPart)`. +/// These are associated `const`s, not enum variants -- `CityObjectType` +/// itself has exactly the two variants above -- but a `const` path is usable +/// wherever a variant path would be: in expressions (`x == CityObjectType::Bridge`) +/// and, because the type derives `PartialEq`/`Eq`, in match patterns too. +#[allow(non_upper_case_globals)] +impl CityObjectType { + pub const Bridge: CityObjectType = CityObjectType::Known(KnownCityObjectType::Bridge); + pub const BridgePart: CityObjectType = CityObjectType::Known(KnownCityObjectType::BridgePart); + pub const BridgeInstallation: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BridgeInstallation); + pub const BridgeConstructiveElement: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BridgeConstructiveElement); + pub const BridgeRoom: CityObjectType = CityObjectType::Known(KnownCityObjectType::BridgeRoom); + pub const BridgeFurniture: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BridgeFurniture); + pub const Building: CityObjectType = CityObjectType::Known(KnownCityObjectType::Building); + pub const BuildingPart: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingPart); + pub const BuildingInstallation: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingInstallation); + pub const BuildingConstructiveElement: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingConstructiveElement); + pub const BuildingFurniture: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingFurniture); + pub const BuildingStorey: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingStorey); + pub const BuildingRoom: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingRoom); + pub const BuildingUnit: CityObjectType = + CityObjectType::Known(KnownCityObjectType::BuildingUnit); + pub const CityFurniture: CityObjectType = + CityObjectType::Known(KnownCityObjectType::CityFurniture); + pub const CityObjectGroup: CityObjectType = + CityObjectType::Known(KnownCityObjectType::CityObjectGroup); + pub const GenericCityObject: CityObjectType = + CityObjectType::Known(KnownCityObjectType::GenericCityObject); + pub const LandUse: CityObjectType = CityObjectType::Known(KnownCityObjectType::LandUse); + pub const OtherConstruction: CityObjectType = + CityObjectType::Known(KnownCityObjectType::OtherConstruction); + pub const PlantCover: CityObjectType = CityObjectType::Known(KnownCityObjectType::PlantCover); + pub const SolitaryVegetationObject: CityObjectType = + CityObjectType::Known(KnownCityObjectType::SolitaryVegetationObject); + pub const TINRelief: CityObjectType = CityObjectType::Known(KnownCityObjectType::TINRelief); + pub const Road: CityObjectType = CityObjectType::Known(KnownCityObjectType::Road); + pub const Railway: CityObjectType = CityObjectType::Known(KnownCityObjectType::Railway); + pub const Waterway: CityObjectType = CityObjectType::Known(KnownCityObjectType::Waterway); + pub const TransportSquare: CityObjectType = + CityObjectType::Known(KnownCityObjectType::TransportSquare); + pub const Tunnel: CityObjectType = CityObjectType::Known(KnownCityObjectType::Tunnel); + pub const TunnelPart: CityObjectType = CityObjectType::Known(KnownCityObjectType::TunnelPart); + pub const TunnelInstallation: CityObjectType = + CityObjectType::Known(KnownCityObjectType::TunnelInstallation); + pub const TunnelConstructiveElement: CityObjectType = + CityObjectType::Known(KnownCityObjectType::TunnelConstructiveElement); + pub const TunnelHollowSpace: CityObjectType = + CityObjectType::Known(KnownCityObjectType::TunnelHollowSpace); + pub const TunnelFurniture: CityObjectType = + CityObjectType::Known(KnownCityObjectType::TunnelFurniture); + pub const WaterBody: CityObjectType = CityObjectType::Known(KnownCityObjectType::WaterBody); +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CityObject { #[serde(rename = "type")] - pub thetype: String, + pub thetype: CityObjectType, #[serde(rename = "geographicalExtent")] #[serde(skip_serializing_if = "Option::is_none")] pub geographical_extent: Option>, @@ -22,7 +157,7 @@ pub struct CityObject { } impl CityObject { - pub fn get_type(&self) -> String { + pub fn get_type(&self) -> CityObjectType { self.thetype.clone() } pub(crate) fn is_toplevel(&self) -> bool { @@ -50,3 +185,102 @@ impl CityObject { re } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_city_object_type_roundtrips_with_its_plus() { + let t: CityObjectType = + serde_json::from_value(serde_json::json!("+NoiseCityFurnitureSegment")).unwrap(); + assert_eq!( + t, + CityObjectType::Extension("+NoiseCityFurnitureSegment".into()) + ); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!("+NoiseCityFurnitureSegment") + ); + } + + #[test] + fn known_city_object_type_is_a_unit_variant() { + let t: CityObjectType = serde_json::from_value(serde_json::json!("BuildingPart")).unwrap(); + assert_eq!(t, CityObjectType::BuildingPart); + } + + /// A known name prefixed with `+` is not the known variant: extensions are + /// spelled with a leading `+` precisely so they never collide with a core + /// type, and ordering (known arm declared first) must not accidentally + /// swallow it either. + #[test] + fn plus_prefixed_known_name_lands_in_extension_not_the_known_variant() { + let t: CityObjectType = serde_json::from_value(serde_json::json!("+Building")).unwrap(); + assert_eq!(t, CityObjectType::Extension("+Building".into())); + assert_ne!(t, CityObjectType::Building); + } + + /// Every known CityObjectType name round-trips through its unit variant, + /// not through Extension -- a typo'd rename would otherwise silently fall + /// through to Extension and no other test would catch it. + #[test] + fn every_known_city_object_type_round_trips_as_its_unit_variant() { + let known: &[(&str, CityObjectType)] = &[ + ("Bridge", CityObjectType::Bridge), + ("BridgePart", CityObjectType::BridgePart), + ("BridgeInstallation", CityObjectType::BridgeInstallation), + ( + "BridgeConstructiveElement", + CityObjectType::BridgeConstructiveElement, + ), + ("BridgeRoom", CityObjectType::BridgeRoom), + ("BridgeFurniture", CityObjectType::BridgeFurniture), + ("Building", CityObjectType::Building), + ("BuildingPart", CityObjectType::BuildingPart), + ("BuildingInstallation", CityObjectType::BuildingInstallation), + ( + "BuildingConstructiveElement", + CityObjectType::BuildingConstructiveElement, + ), + ("BuildingFurniture", CityObjectType::BuildingFurniture), + ("BuildingStorey", CityObjectType::BuildingStorey), + ("BuildingRoom", CityObjectType::BuildingRoom), + ("BuildingUnit", CityObjectType::BuildingUnit), + ("CityFurniture", CityObjectType::CityFurniture), + ("CityObjectGroup", CityObjectType::CityObjectGroup), + ("GenericCityObject", CityObjectType::GenericCityObject), + ("LandUse", CityObjectType::LandUse), + ("OtherConstruction", CityObjectType::OtherConstruction), + ("PlantCover", CityObjectType::PlantCover), + ( + "SolitaryVegetationObject", + CityObjectType::SolitaryVegetationObject, + ), + ("TINRelief", CityObjectType::TINRelief), + ("Road", CityObjectType::Road), + ("Railway", CityObjectType::Railway), + ("Waterway", CityObjectType::Waterway), + ("TransportSquare", CityObjectType::TransportSquare), + ("Tunnel", CityObjectType::Tunnel), + ("TunnelPart", CityObjectType::TunnelPart), + ("TunnelInstallation", CityObjectType::TunnelInstallation), + ( + "TunnelConstructiveElement", + CityObjectType::TunnelConstructiveElement, + ), + ("TunnelHollowSpace", CityObjectType::TunnelHollowSpace), + ("TunnelFurniture", CityObjectType::TunnelFurniture), + ("WaterBody", CityObjectType::WaterBody), + ]; + for (name, expected) in known { + let parsed: CityObjectType = serde_json::from_value(serde_json::json!(name)).unwrap(); + assert_eq!(&parsed, expected, "{name} did not parse to its own variant"); + assert_eq!( + serde_json::to_value(&parsed).unwrap(), + serde_json::json!(name), + "{name} did not round-trip back to its own string" + ); + } + } +} diff --git a/src/cityjson.rs b/src/cityjson.rs index 4616371..20252b6 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -17,7 +17,7 @@ pub enum SortingStrategy { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CityJSON { #[serde(rename = "type")] - pub thetype: String, + pub thetype: CityJSONType, pub version: String, pub transform: Transform, #[serde(rename = "CityObjects")] @@ -46,7 +46,7 @@ impl CityJSON { let v: Vec> = Vec::new(); let tr = Transform::new(); CityJSON { - thetype: "CityJSON".to_string(), + thetype: CityJSONType::CityJSON, version: "2.0".to_string(), transform: tr, city_objects: co, @@ -465,7 +465,7 @@ impl CityJSON { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CityJSONFeature { #[serde(rename = "type")] - pub thetype: String, + pub thetype: CityJSONFeatureType, pub id: String, #[serde(rename = "CityObjects")] pub city_objects: HashMap, @@ -478,7 +478,7 @@ impl CityJSONFeature { let co: HashMap = HashMap::new(); let v: Vec> = Vec::new(); CityJSONFeature { - thetype: "CityJSONFeature".to_string(), + thetype: CityJSONFeatureType::CityJSONFeature, id: "".to_string(), city_objects: co, vertices: v, @@ -519,3 +519,63 @@ pub fn cjseq_to_cj(mut base_cj: CityJSON, features: Vec) -> Cit base_cj } + +/// The `type` member of a [`CityJSON`] object. The spec fixes its value to +/// the single string `"CityJSON"` (§ 1 CityJSON Object); unlike City Object +/// and semantic-surface types, CityJSON does not define an Extension +/// document type, so there is no `Extension` variant here -- a document +/// declaring any other `type` value fails to deserialize. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub enum CityJSONType { + CityJSON, +} + +/// The `type` member of a [`CityJSONFeature`] object. The spec fixes its +/// value to the single string `"CityJSONFeature"` (§ 7.2 Text sequences and +/// streaming with CityJSONFeature); as with [`CityJSONType`], there is no +/// Extension variant. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub enum CityJSONFeatureType { + CityJSONFeature, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cityjson_type_round_trips() { + let t: CityJSONType = serde_json::from_value(serde_json::json!("CityJSON")).unwrap(); + assert_eq!(t, CityJSONType::CityJSON); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!("CityJSON") + ); + } + + #[test] + fn cityjson_type_rejects_any_other_value() { + assert!( + serde_json::from_value::(serde_json::json!("CityJSONFeature")).is_err() + ); + assert!(serde_json::from_value::(serde_json::json!("+CityJSON")).is_err()); + } + + #[test] + fn cityjson_feature_type_round_trips() { + let t: CityJSONFeatureType = + serde_json::from_value(serde_json::json!("CityJSONFeature")).unwrap(); + assert_eq!(t, CityJSONFeatureType::CityJSONFeature); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!("CityJSONFeature") + ); + } + + #[test] + fn cityjson_feature_type_rejects_any_other_value() { + assert!( + serde_json::from_value::(serde_json::json!("CityJSON")).is_err() + ); + } +} diff --git a/src/main.rs b/src/main.rs index 20e2171..7e4e7a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use cjseq::CityJSON; use cjseq::CityJSONFeature; +use cjseq::CityJSONType; +use cjseq::CityObjectType; use cjseq::Transform; extern crate clap; @@ -202,6 +204,11 @@ fn filter_random(exclude: bool, rand_factor: u32) -> Result<(), MyError> { } fn filter_cotype(exclude: bool, cotype: String) -> Result<(), MyError> { + // The CLI accepts any type name the user types (a core type such as + // "Building", or an Extension type such as "+NoiseBuilding"); parse it + // through the same enum used for the CityObjects being filtered so the + // comparison below is enum-to-enum, not string-to-string. + let cotype: CityObjectType = serde_json::from_value(serde_json::Value::String(cotype))?; let stdin = std::io::stdin(); for line in stdin.lock().lines() { let mut w: bool = false; @@ -367,7 +374,7 @@ fn cat_from_file(file: &PathBuf, order: cjseq::SortingStrategy) -> Result<(), My } fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<(), MyError> { - if cjj.thetype != "CityJSON" { + if cjj.thetype != CityJSONType::CityJSON { return Err(MyError::CityJsonError( "Input file not CityJSON.".to_string(), )); diff --git a/src/semantics.rs b/src/semantics.rs index 37303db..0e50835 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -2,15 +2,106 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +/// The known surface types the spec assigns to Building-, WaterBody-, and +/// Transportation-family City Objects (§ 3.3 Semantics of geometric +/// primitives). Every variant is a unit variant already spelled exactly as +/// the spec requires, so serde's default (non-untagged) derive collapses +/// each one to its bare name string with no `#[serde(rename)]` needed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub enum KnownSemanticSurfaceType { + RoofSurface, + GroundSurface, + WallSurface, + ClosureSurface, + OuterCeilingSurface, + OuterFloorSurface, + Window, + Door, + InteriorWallSurface, + CeilingSurface, + FloorSurface, + WaterSurface, + WaterGroundSurface, + WaterClosureSurface, + TrafficArea, + AuxiliaryTrafficArea, + TransportationMarking, + TransportationHole, +} + +/// The `type` member of a Semantic Object (§ 3.3 Semantics of geometric +/// primitives). +/// +/// `Known` covers the surface types above. Any other value is a CityJSON +/// Extension semantic surface, which the spec requires to start with `"+"` +/// (eg `"+ThermalSurface"`, § 8.5); `Extension` carries that string verbatim +/// so it round-trips byte for byte. +/// +/// This nests the known names inside their own unit-only enum rather than +/// mixing unit variants with `Extension(String)` directly in one flat +/// `#[serde(untagged)]` enum -- see [`crate::CityObjectType`]'s doc comment +/// for why a flat mix cannot round-trip (a unit variant under `untagged` +/// (de)serializes as `null`, not its name; this was verified empirically +/// while building `CityObjectType`, which has the identical shape). +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum SemanticSurfaceType { + Known(KnownSemanticSurfaceType), + /// Not a core CityJSON semantic surface type: a CityJSON Extension + /// surface type, always spelled with a leading `"+"`. + Extension(String), +} + +/// Flat, spec-spelling access to each known variant (eg +/// `SemanticSurfaceType::RoofSurface`); see [`crate::CityObjectType`]'s +/// equivalent block for why these are associated `const`s rather than enum +/// variants. +#[allow(non_upper_case_globals)] +impl SemanticSurfaceType { + pub const RoofSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::RoofSurface); + pub const GroundSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::GroundSurface); + pub const WallSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::WallSurface); + pub const ClosureSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::ClosureSurface); + pub const OuterCeilingSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::OuterCeilingSurface); + pub const OuterFloorSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::OuterFloorSurface); + pub const Window: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::Window); + pub const Door: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::Door); + pub const InteriorWallSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::InteriorWallSurface); + pub const CeilingSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::CeilingSurface); + pub const FloorSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::FloorSurface); + pub const WaterSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::WaterSurface); + pub const WaterGroundSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::WaterGroundSurface); + pub const WaterClosureSurface: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::WaterClosureSurface); + pub const TrafficArea: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::TrafficArea); + pub const AuxiliaryTrafficArea: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::AuxiliaryTrafficArea); + pub const TransportationMarking: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::TransportationMarking); + pub const TransportationHole: SemanticSurfaceType = + SemanticSurfaceType::Known(KnownSemanticSurfaceType::TransportationHole); +} + /// One semantic surface: its type, its place in the parent/children hierarchy, /// and any further attributes the file chooses to carry. -/// -/// `thetype` stays a `String` for now; giving it a closed set of variants is -/// the subject of later work. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct SemanticsSurface { #[serde(rename = "type")] - pub thetype: String, + pub thetype: SemanticSurfaceType, #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -226,4 +317,83 @@ mod tests { SemanticsValues::Solids(_) )); } + + /// Extension semantic surfaces (`"+ThermalSurface"`, `"+PatioDoor"`, ...) + /// must survive a round trip with their leading `+` intact -- mirrors the + /// `CityObjectType` extension-round-trip requirement, but for surfaces. + #[test] + fn extension_semantic_surface_type_roundtrips_with_its_plus() { + let t: SemanticSurfaceType = + serde_json::from_value(serde_json::json!("+ThermalSurface")).unwrap(); + assert_eq!(t, SemanticSurfaceType::Extension("+ThermalSurface".into())); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!("+ThermalSurface") + ); + } + + #[test] + fn plus_prefixed_known_surface_name_lands_in_extension() { + let t: SemanticSurfaceType = + serde_json::from_value(serde_json::json!("+RoofSurface")).unwrap(); + assert_eq!(t, SemanticSurfaceType::Extension("+RoofSurface".into())); + assert_ne!(t, SemanticSurfaceType::RoofSurface); + } + + /// Every known SemanticSurfaceType name round-trips through its own unit + /// variant, not through Extension. + #[test] + fn every_known_semantic_surface_type_round_trips_as_its_unit_variant() { + let known: &[(&str, SemanticSurfaceType)] = &[ + ("RoofSurface", SemanticSurfaceType::RoofSurface), + ("GroundSurface", SemanticSurfaceType::GroundSurface), + ("WallSurface", SemanticSurfaceType::WallSurface), + ("ClosureSurface", SemanticSurfaceType::ClosureSurface), + ( + "OuterCeilingSurface", + SemanticSurfaceType::OuterCeilingSurface, + ), + ("OuterFloorSurface", SemanticSurfaceType::OuterFloorSurface), + ("Window", SemanticSurfaceType::Window), + ("Door", SemanticSurfaceType::Door), + ( + "InteriorWallSurface", + SemanticSurfaceType::InteriorWallSurface, + ), + ("CeilingSurface", SemanticSurfaceType::CeilingSurface), + ("FloorSurface", SemanticSurfaceType::FloorSurface), + ("WaterSurface", SemanticSurfaceType::WaterSurface), + ( + "WaterGroundSurface", + SemanticSurfaceType::WaterGroundSurface, + ), + ( + "WaterClosureSurface", + SemanticSurfaceType::WaterClosureSurface, + ), + ("TrafficArea", SemanticSurfaceType::TrafficArea), + ( + "AuxiliaryTrafficArea", + SemanticSurfaceType::AuxiliaryTrafficArea, + ), + ( + "TransportationMarking", + SemanticSurfaceType::TransportationMarking, + ), + ( + "TransportationHole", + SemanticSurfaceType::TransportationHole, + ), + ]; + for (name, expected) in known { + let parsed: SemanticSurfaceType = + serde_json::from_value(serde_json::json!(name)).unwrap(); + assert_eq!(&parsed, expected, "{name} did not parse to its own variant"); + assert_eq!( + serde_json::to_value(&parsed).unwrap(), + serde_json::json!(name), + "{name} did not round-trip back to its own string" + ); + } + } } diff --git a/tests/cj_input.rs b/tests/cj_input.rs index 6ae7967..c414af4 100755 --- a/tests/cj_input.rs +++ b/tests/cj_input.rs @@ -64,7 +64,7 @@ fn basic_reading_of_cj() { cjj.sort_cjfeatures(cjseq::SortingStrategy::Lexicographical); let mut cjnext = cjj.get_cjfeature(0).unwrap(); - assert_eq!(cjnext.thetype, "CityJSONFeature"); + assert_eq!(cjnext.thetype, cjseq::CityJSONFeatureType::CityJSONFeature); assert_eq!(cjnext.id, "NL.IMBAG.Pand.0503100000028341"); cjnext = cjj.get_cjfeature(1).unwrap(); From c964c15df1f541c7461cb91ec9682c4463edf38b Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 21:36:56 +0200 Subject: [PATCH 13/65] test: pin the extension-type flatten round trip at the document level Add byte-for-byte round-trip tests for an Extension-typed CityObject inside a CityJSONFeature and an Extension-typed SemanticsSurface inside a real Geometry -- the shape the noise_extension fixture actually has, which the bare-CityObjectType/SemanticSurfaceType tests never exercised since they don't go through the #[serde(flatten)] path. Also document the match-on-value-not-reference caveat on both const-alias blocks, since matching a const against a &CityObjectType is E0308 and rustc's fix-it (rename the arm to a binding) compiles into a silent catch-all. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/city_object.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/semantics.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/city_object.rs b/src/city_object.rs index 82bf8ba..83329a1 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -80,6 +80,18 @@ pub enum CityObjectType { /// itself has exactly the two variants above -- but a `const` path is usable /// wherever a variant path would be: in expressions (`x == CityObjectType::Bridge`) /// and, because the type derives `PartialEq`/`Eq`, in match patterns too. +/// +/// Match on the value, not a reference: `match *t { CityObjectType::Building +/// => .., CityObjectType::Extension(ref s) => .., _ => .. }`, not `match &t` +/// or `match t` where `t: &CityObjectType`. Matching a `const` path against a +/// `&CityObjectType` is `E0308` ("mismatched types"), and rustc's fix-it +/// suggests renaming the arm to a fresh binding -- which compiles, but +/// silently turns that arm into a catch-all that matches everything, since +/// `CityObjectType::Building` (with binding-like syntax accepted) reads as a +/// new irrefutable binding named `Building`, not the constant. `_` is still +/// required after `Extension(ref s)` because these are two real variants +/// (`Known`, `Extension`), so the match isn't exhaustive over just the known +/// names. #[allow(non_upper_case_globals)] impl CityObjectType { pub const Bridge: CityObjectType = CityObjectType::Known(KnownCityObjectType::Bridge); @@ -221,6 +233,34 @@ mod tests { assert_ne!(t, CityObjectType::Building); } + /// The stated critical requirement names a specific shape: a `CityObject` + /// (inside a `CityJSONFeature`, since that is how `flatcitybuf`'s + /// `noise_extension` fixture is shaped) whose `type` is an Extension + /// string, where `CityObjectType` sits behind `CityObject`'s + /// `#[serde(flatten)] other` field. The bare-`CityObjectType` tests above + /// don't exercise that flatten path at all, so this pins it at the + /// document level, asserting on the serialized *string* -- not a + /// re-parsed `Value` -- so a change that reorders or reformats output + /// would fail this test even though it would pass a structural check. + #[test] + fn extension_typed_city_object_roundtrips_byte_for_byte_inside_a_feature() { + let input = concat!( + r#"{"type":"CityJSONFeature","id":"id-1","#, + r#""CityObjects":{"id-1":{"type":"+NoiseCityFurnitureSegment"}},"#, + r#""vertices":[]}"# + ); + let parsed: crate::cityjson::CityJSONFeature = serde_json::from_str(input).unwrap(); + assert_eq!( + parsed.city_objects["id-1"].thetype, + CityObjectType::Extension("+NoiseCityFurnitureSegment".into()) + ); + let reserialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!( + reserialized, input, + "an Extension-typed CityObject inside a CityJSONFeature must round-trip byte-for-byte" + ); + } + /// Every known CityObjectType name round-trips through its unit variant, /// not through Extension -- a typo'd rename would otherwise silently fall /// through to Extension and no other test would catch it. diff --git a/src/semantics.rs b/src/semantics.rs index 0e50835..90655d3 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -56,6 +56,14 @@ pub enum SemanticSurfaceType { /// `SemanticSurfaceType::RoofSurface`); see [`crate::CityObjectType`]'s /// equivalent block for why these are associated `const`s rather than enum /// variants. +/// +/// Match on the value, not a reference: `match *t { +/// SemanticSurfaceType::RoofSurface => .., SemanticSurfaceType::Extension(ref +/// s) => .., _ => .. }`, not `match &t`/`match t` where `t: +/// &SemanticSurfaceType`. Matching a `const` path against a reference is +/// `E0308`, and rustc's fix-it suggestion (rename the arm to a binding) +/// compiles into a silent catch-all instead of the intended check -- see +/// [`crate::CityObjectType`]'s equivalent block for the full explanation. #[allow(non_upper_case_globals)] impl SemanticSurfaceType { pub const RoofSurface: SemanticSurfaceType = @@ -340,6 +348,27 @@ mod tests { assert_ne!(t, SemanticSurfaceType::RoofSurface); } + /// Mirrors `city_object::tests::extension_typed_city_object_roundtrips_byte_for_byte_inside_a_feature`: + /// the bare-`SemanticSurfaceType` tests above never exercise the flatten + /// path an extension surface actually sits behind in practice -- a + /// `SemanticsSurface` nested inside a real `Geometry`'s `semantics` + /// member. Asserts on the serialized *string*, not a re-parsed `Value`, + /// so a change to `other`/flatten handling would fail this even with an + /// otherwise-green suite. + #[test] + fn extension_typed_semantics_surface_roundtrips_byte_for_byte_inside_a_geometry() { + let input = concat!( + r#"{"type":"MultiSurface","boundaries":[[[0,1,2]]],"#, + r#""semantics":{"surfaces":[{"type":"+ThermalSurface"}],"values":[0]}}"# + ); + let parsed: crate::geometry::Geometry = serde_json::from_str(input).unwrap(); + let reserialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!( + reserialized, input, + "an Extension-typed SemanticsSurface inside a Geometry must round-trip byte-for-byte" + ); + } + /// Every known SemanticSurfaceType name round-trips through its own unit /// variant, not through Extension. #[test] From ad328c517b94ef1c5186d673c769f8e845de6134 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 20 Jul 2026 21:44:23 +0200 Subject: [PATCH 14/65] test: exercise the flatten field itself in the extension round-trip tests The two document-level byte-for-byte tests only had "type" in their fixtures, so an empty other/flatten field would pass identically to a correctly-populated one. Add exactly one extra member to each: a genuinely unrecognized "+noiseLevel" (nested object) on the CityObject -- not "attributes", which is CityObject's own named field and would never reach other -- and the spec's own "azimuth" example on the SemanticsSurface. One key each, since other's underlying map has no stable iteration order past a single entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/city_object.rs | 16 ++++++++++++++-- src/semantics.rs | 11 +++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/city_object.rs b/src/city_object.rs index 83329a1..9a860ba 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -242,11 +242,22 @@ mod tests { /// document level, asserting on the serialized *string* -- not a /// re-parsed `Value` -- so a change that reorders or reformats output /// would fail this test even though it would pass a structural check. + /// + /// The extra `"+noiseLevel"` member is deliberate and load-bearing: it is + /// *not* `"attributes"` (that member has its own dedicated, + /// already-typed `CityObject` field and so would never touch `other` at + /// all) but a genuinely unrecognized top-level member, so it is the one + /// that actually exercises `#[serde(flatten)] other: serde_json::Value`. + /// It carries a nested object (not a bare scalar) to prove nested + /// content survives too, and there is exactly one such extra member, + /// because `other`'s `serde_json::Map` has no stable iteration order + /// once it holds two or more keys. #[test] fn extension_typed_city_object_roundtrips_byte_for_byte_inside_a_feature() { let input = concat!( r#"{"type":"CityJSONFeature","id":"id-1","#, - r#""CityObjects":{"id-1":{"type":"+NoiseCityFurnitureSegment"}},"#, + r#""CityObjects":{"id-1":{"type":"+NoiseCityFurnitureSegment","#, + r#""+noiseLevel":{"dB":55}}},"#, r#""vertices":[]}"# ); let parsed: crate::cityjson::CityJSONFeature = serde_json::from_str(input).unwrap(); @@ -257,7 +268,8 @@ mod tests { let reserialized = serde_json::to_string(&parsed).unwrap(); assert_eq!( reserialized, input, - "an Extension-typed CityObject inside a CityJSONFeature must round-trip byte-for-byte" + "an Extension-typed CityObject with an extra flattened member inside a \ + CityJSONFeature must round-trip byte-for-byte" ); } diff --git a/src/semantics.rs b/src/semantics.rs index 90655d3..503c1a5 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -355,17 +355,24 @@ mod tests { /// member. Asserts on the serialized *string*, not a re-parsed `Value`, /// so a change to `other`/flatten handling would fail this even with an /// otherwise-green suite. + /// + /// `"azimuth"` (lifted from the spec's own `"+ThermalSurface"` example, + /// § 8.5) is the one extra, genuinely unrecognized member landing in + /// `#[serde(flatten)] other: HashMap`; kept to exactly + /// one key since that `HashMap` has no stable iteration order once it + /// holds two or more. #[test] fn extension_typed_semantics_surface_roundtrips_byte_for_byte_inside_a_geometry() { let input = concat!( r#"{"type":"MultiSurface","boundaries":[[[0,1,2]]],"#, - r#""semantics":{"surfaces":[{"type":"+ThermalSurface"}],"values":[0]}}"# + r#""semantics":{"surfaces":[{"type":"+ThermalSurface","azimuth":30.5}],"values":[0]}}"# ); let parsed: crate::geometry::Geometry = serde_json::from_str(input).unwrap(); let reserialized = serde_json::to_string(&parsed).unwrap(); assert_eq!( reserialized, input, - "an Extension-typed SemanticsSurface inside a Geometry must round-trip byte-for-byte" + "an Extension-typed SemanticsSurface with an extra flattened member inside a \ + Geometry must round-trip byte-for-byte" ); } From 62128a81c5c90c0c05cbe2ca2c7a99e98308c044 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 10:59:55 +0200 Subject: [PATCH 15/65] feat: finish the custom CjseqError type and route call sites through it Task 4 introduced CjseqError with Json and GeometryDepth variants; this finishes porting develop's 5a8102c by adding the two remaining cases the crate actually needs -- Io (file/stdio operations fail) and Validation (structural rules serde alone can't express, eg an unsupported CityJSON version) -- and wiring their Display/source/From impls. CityJSON::from_str, CityJSONFeature::from_str, and ReferenceSystem::from_url now return the crate's Result instead of serde_json::Error or &'static str directly, and main.rs's hand-rolled MyError enum (a near duplicate of CjseqError) is gone in favour of the library's own type. No thiserror dependency added; the impls stay hand-written to match error.rs's existing style. --- src/cityjson.rs | 9 +++--- src/error.rs | 39 ++++++++++++++++++++++++++ src/main.rs | 73 +++++++++++++++++++++++-------------------------- src/metadata.rs | 11 ++++++-- 4 files changed, 86 insertions(+), 46 deletions(-) diff --git a/src/cityjson.rs b/src/cityjson.rs index 20252b6..56ae81b 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,9 +1,10 @@ use crate::appearance::Appearance; use crate::city_object::CityObject; +use crate::error::Result; use crate::geometry::GeometryTemplates; use crate::metadata::{Metadata, Transform}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Error, Value}; +use serde_json::{json, Value}; use std::collections::HashMap; #[derive(Clone)] @@ -61,7 +62,7 @@ impl CityJSON { } } /// Create a new CityJSON instance from a string. - pub fn from_str(s: &str) -> Result { + pub fn from_str(s: &str) -> Result { let mut cjj: CityJSON = serde_json::from_str(s)?; //-- check if CO exists, then add them to the sorted_ids for (key, co) in &cjj.city_objects { @@ -485,8 +486,8 @@ impl CityJSONFeature { appearance: None, } } - pub fn from_str(s: &str) -> Result { - let cjf: CityJSONFeature = serde_json::from_str(&s)?; + pub fn from_str(s: &str) -> Result { + let cjf: CityJSONFeature = serde_json::from_str(s)?; Ok(cjf) } pub fn add_co(&mut self, id: String, co: CityObject) { diff --git a/src/error.rs b/src/error.rs index a850d53..fbcea3c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,13 @@ pub enum CjseqError { /// What was found instead, in words. found: String, }, + /// An I/O operation failed, eg opening or reading a CityJSON(Seq) file. + Io(std::io::Error), + /// A document parsed as valid JSON, and every typed field on it parsed + /// fine, but it still fails a structural rule this crate enforces beyond + /// what serde alone can express -- eg its `type` is not `"CityJSON"`, or + /// its `version` is not one this crate supports. + Validation(String), } impl fmt::Display for CjseqError { @@ -35,6 +42,8 @@ impl fmt::Display for CjseqError { f, "a {geometry_type:?} nests its boundaries {expected} levels deep, but {found}" ), + CjseqError::Io(e) => write!(f, "I/O error: {e}"), + CjseqError::Validation(msg) => write!(f, "invalid CityJSON: {msg}"), } } } @@ -44,6 +53,8 @@ impl std::error::Error for CjseqError { match self { CjseqError::Json(e) => Some(e), CjseqError::GeometryDepth { .. } => None, + CjseqError::Io(e) => Some(e), + CjseqError::Validation(_) => None, } } } @@ -54,5 +65,33 @@ impl From for CjseqError { } } +impl From for CjseqError { + fn from(e: std::io::Error) -> Self { + CjseqError::Io(e) + } +} + /// The crate's result type. pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn io_error_converts_via_from_and_keeps_its_message() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "nope"); + let err: CjseqError = io_err.into(); + assert!(matches!(err, CjseqError::Io(_))); + assert_eq!(err.to_string(), "I/O error: nope"); + } + + #[test] + fn validation_error_carries_its_message() { + let err = CjseqError::Validation("Input file not CityJSON.".to_string()); + assert_eq!( + err.to_string(), + "invalid CityJSON: Input file not CityJSON." + ); + } +} diff --git a/src/main.rs b/src/main.rs index 7e4e7a1..08a204a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ use cjseq::CityJSON; use cjseq::CityJSONFeature; use cjseq::CityJSONType; use cjseq::CityObjectType; +use cjseq::CjseqError; +use cjseq::Result; use cjseq::Transform; extern crate clap; @@ -9,7 +11,6 @@ use clap::{Parser, Subcommand, ValueEnum}; use serde_json::Value; use rand::Rng; -use std::fmt; use std::fs::File; use std::io::BufRead; use std::io::BufReader; @@ -70,33 +71,6 @@ enum Commands { }, } -#[derive(Debug)] -enum MyError { - IoError(std::io::Error), - JsonError(serde_json::Error), - CityJsonError(String), -} -impl fmt::Display for MyError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MyError::JsonError(json_error) => write!(f, "Error (JSON): {}", json_error), - MyError::IoError(io_error) => write!(f, "Error (io): {}", io_error), - MyError::CityJsonError(cjson_error) => write!(f, "Error (CityJSON): {}", cjson_error), - } - } -} -impl std::error::Error for MyError {} -impl From for MyError { - fn from(err: serde_json::Error) -> Self { - MyError::JsonError(err) - } -} -impl From for MyError { - fn from(err: std::io::Error) -> Self { - MyError::IoError(err) - } -} - fn main() { let cli = Cli::parse(); @@ -180,7 +154,7 @@ fn main() { } } -fn filter_random(exclude: bool, rand_factor: u32) -> Result<(), MyError> { +fn filter_random(exclude: bool, rand_factor: u32) -> Result<()> { let stdin = std::io::stdin(); let mut rng = rand::thread_rng(); for line in stdin.lock().lines() { @@ -203,7 +177,7 @@ fn filter_random(exclude: bool, rand_factor: u32) -> Result<(), MyError> { Ok(()) } -fn filter_cotype(exclude: bool, cotype: String) -> Result<(), MyError> { +fn filter_cotype(exclude: bool, cotype: String) -> Result<()> { // The CLI accepts any type name the user types (a core type such as // "Building", or an Extension type such as "+NoiseBuilding"); parse it // through the same enum used for the CityObjects being filtered so the @@ -231,7 +205,7 @@ fn filter_cotype(exclude: bool, cotype: String) -> Result<(), MyError> { Ok(()) } -fn filter_bbox(exclude: bool, bbox: &Vec) -> Result<(), MyError> { +fn filter_bbox(exclude: bool, bbox: &Vec) -> Result<()> { let stdin = std::io::stdin(); let mut cj: CityJSON = CityJSON::new(); for line in stdin.lock().lines() { @@ -258,7 +232,7 @@ fn filter_bbox(exclude: bool, bbox: &Vec) -> Result<(), MyError> { Ok(()) } -fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<(), MyError> { +fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<()> { let stdin = std::io::stdin(); let mut cj: CityJSON = CityJSON::new(); for line in stdin.lock().lines() { @@ -286,7 +260,7 @@ fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<(), MyError> { Ok(()) } -fn collect_from_stdin() -> Result<(), MyError> { +fn collect_from_stdin() -> Result<()> { let stdin = std::io::stdin(); let mut cjj = CityJSON::new(); let mut cjj_init: bool = false; @@ -314,7 +288,7 @@ fn collect_from_stdin() -> Result<(), MyError> { Ok(()) } -fn collect_from_files(files: &Vec) -> Result<(), MyError> { +fn collect_from_files(files: &Vec) -> Result<()> { let mut cjj = CityJSON::new(); let mut cjj_init: bool = false; for file in files { @@ -349,7 +323,7 @@ fn collect_from_files(files: &Vec) -> Result<(), MyError> { Ok(()) } -fn cat_from_stdin(order: cjseq::SortingStrategy) -> Result<(), MyError> { +fn cat_from_stdin(order: cjseq::SortingStrategy) -> Result<()> { let mut input = String::new(); match std::io::stdin().read_to_string(&mut input) { Ok(_) => { @@ -363,7 +337,7 @@ fn cat_from_stdin(order: cjseq::SortingStrategy) -> Result<(), MyError> { Ok(()) } -fn cat_from_file(file: &PathBuf, order: cjseq::SortingStrategy) -> Result<(), MyError> { +fn cat_from_file(file: &PathBuf, order: cjseq::SortingStrategy) -> Result<()> { let f = File::open(file.canonicalize()?)?; let mut br = BufReader::new(f); let mut json_content = String::new(); @@ -373,14 +347,14 @@ fn cat_from_file(file: &PathBuf, order: cjseq::SortingStrategy) -> Result<(), My Ok(()) } -fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<(), MyError> { +fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { if cjj.thetype != CityJSONType::CityJSON { - return Err(MyError::CityJsonError( + return Err(CjseqError::Validation( "Input file not CityJSON.".to_string(), )); } if cjj.version != "1.1" && cjj.version != "2.0" { - return Err(MyError::CityJsonError( + return Err(CjseqError::Validation( "Input file not CityJSON v1.1 nor v2.0.".to_string(), )); } @@ -397,3 +371,24 @@ fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<(), MyError> } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `cat()` used to report a bad version with a hand-rolled `MyError` + /// local to the binary. It now reuses the library's `CjseqError`, so a + /// bad version must come back as `CjseqError::Validation`, not a panic + /// or a generic string error. + #[test] + fn cat_rejects_a_version_the_crate_does_not_support() { + let mut cjj = CityJSON::new(); + cjj.version = "9.9".to_string(); + let err = cat(&mut cjj, cjseq::SortingStrategy::Random).unwrap_err(); + assert!(matches!(err, CjseqError::Validation(_))); + assert_eq!( + err.to_string(), + "invalid CityJSON: Input file not CityJSON v1.1 nor v2.0." + ); + } +} diff --git a/src/metadata.rs b/src/metadata.rs index 36107e1..16f5561 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,3 +1,4 @@ +use crate::error::CjseqError; use serde::{Deserialize, Serialize}; const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; @@ -88,9 +89,11 @@ impl ReferenceSystem { // OGC Name Type Specification: // http://www.opengis.net/def/crs/{authority}/{version}/{code} // where {authority} designates the authority responsible for the definition of this CRS (usually "EPSG" or "OGC"), and where {version} designates the specific version of the CRS ("0" (zero) is used if there is no version). - pub fn from_url(url: &str) -> Result { + pub fn from_url(url: &str) -> crate::error::Result { if !url.contains("//www.opengis.net/def/crs") { - return Err("Invalid reference system URL"); + return Err(CjseqError::Validation( + "invalid reference system URL".to_string(), + )); } let i = url.find("crs").unwrap(); @@ -98,7 +101,9 @@ impl ReferenceSystem { let parts: Vec<&str> = s.split("/").collect(); if parts.len() != 3 { - return Err("Invalid reference system URL"); + return Err(CjseqError::Validation( + "invalid reference system URL".to_string(), + )); } Ok(ReferenceSystem { From c5eda21e4fe3bb309638e15ecf0d4922f44fc202 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:01:17 +0200 Subject: [PATCH 16/65] feat: add CityObjectGroup's children_roles field to CityObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports develop's 828c7c7. cityobjects.schema.json types CityObjectGroup's children_roles as one string-or-null entry per id in children (§ 2.5), so this is Option>>, not Option> as the original fork had it -- the schema explicitly permits an unspecified role to be null rather than an omitted array slot. --- src/city_object.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/city_object.rs b/src/city_object.rs index 9a860ba..fed4342 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -162,6 +162,12 @@ pub struct CityObject { pub geometry: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub children: Option>, + /// § 2.5 CityObjectGroup only: the role of each id in `children`, in the + /// same order. `cityobjects.schema.json` types each item as + /// `["string", "null"]`, so an unspecified role is `null`, not simply + /// absent from the array. + #[serde(skip_serializing_if = "Option::is_none")] + pub children_roles: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub parents: Option>, #[serde(flatten)] @@ -273,6 +279,26 @@ mod tests { ); } + /// § 2.5 CityObjectGroup: a group `CityObject` may carry a `children_roles` + /// member, one entry per id in `children`, describing that child's role + /// in the group. `cityobjects.schema.json`'s `CityObjectGroup` types each + /// item as `["string", "null"]`, so an unspecified role is `null`, not an + /// absent array entry -- this must round-trip too. + #[test] + fn city_object_group_children_roles_round_trips_including_null_entries() { + let input = serde_json::json!({ + "type": "CityObjectGroup", + "children": ["building1", "building3"], + "children_roles": ["residential building", null] + }); + let co: CityObject = serde_json::from_value(input.clone()).unwrap(); + assert_eq!( + co.children_roles, + Some(vec![Some("residential building".to_string()), None]) + ); + assert_eq!(serde_json::to_value(&co).unwrap(), input); + } + /// Every known CityObjectType name round-trips through its unit variant, /// not through Extension -- a typo'd rename would otherwise silently fall /// through to Extension and no other test would catch it. From 13e46bae44cf0c483334482b742dbffa956214eb Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:02:50 +0200 Subject: [PATCH 17/65] refactor: type CityObject.geographicalExtent as a fixed [f64; 6] Ports develop's 8f7fbaf onto the current base, reusing metadata's GeographicalExtent alias instead of a bespoke array type. cityobjects .schema.json's _AbstractCityObject fixes geographicalExtent's minItems and maxItems to 6 -- a 5- or 7-element extent is not schema-valid CityJSON -- and the field stays optional (it is not in the schema's required list). --- src/city_object.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/city_object.rs b/src/city_object.rs index fed4342..7146114 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -1,4 +1,5 @@ use crate::geometry::Geometry; +use crate::metadata::GeographicalExtent; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -155,7 +156,7 @@ pub struct CityObject { pub thetype: CityObjectType, #[serde(rename = "geographicalExtent")] #[serde(skip_serializing_if = "Option::is_none")] - pub geographical_extent: Option>, + pub geographical_extent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub attributes: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -279,6 +280,33 @@ mod tests { ); } + /// `cityobjects.schema.json`'s `_AbstractCityObject.geographicalExtent` + /// has `minItems`/`maxItems` 6 -- a fixed-length array, not an arbitrary + /// `Vec` -- so a 5- or 7-element extent must fail to deserialize + /// rather than being silently accepted. + #[test] + fn city_object_geographical_extent_rejects_the_wrong_length() { + let too_short = serde_json::json!({ + "type": "Building", + "geographicalExtent": [0.0, 0.0, 0.0, 1.0, 1.0] + }); + assert!(serde_json::from_value::(too_short).is_err()); + } + + #[test] + fn city_object_geographical_extent_round_trips_as_a_six_element_array() { + let input = serde_json::json!({ + "type": "Building", + "geographicalExtent": [84710.1, 446846.0, -5.3, 84757.1, 446944.0, 40.9] + }); + let co: CityObject = serde_json::from_value(input.clone()).unwrap(); + assert_eq!( + co.geographical_extent, + Some([84710.1, 446846.0, -5.3, 84757.1, 446944.0, 40.9]) + ); + assert_eq!(serde_json::to_value(&co).unwrap(), input); + } + /// § 2.5 CityObjectGroup: a group `CityObject` may carry a `children_roles` /// member, one entry per id in `children`, describing that child's role /// in the group. `cityobjects.schema.json`'s `CityObjectGroup` types each From 3f5cb12ac09157d95cd206a6d3d58893713b4740 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:04:50 +0200 Subject: [PATCH 18/65] feat: derive PartialEq on the remaining public structs Ports develop's 6a844e3. CityJSON, CityJSONFeature, CityObject, Transform, Address, PointOfContact, ReferenceSystem, and Metadata were the public types still missing PartialEq (every public type in this crate is meant to derive Debug, Clone, PartialEq). None of them contain anything that would block PartialEq, so this is a plain derive add, not a restructure. 6917631's Eq on SemanticsSurface/Semantics has no equivalent here: Task 3 already gave SemanticsSurface a HashMap `other` (Eq-capable in principle) but Semantics.values is untagged over Vec> types with no floats, so nothing here regresses -- but the fork's Eq was for those exact types and they already carry PartialEq only by design elsewhere in this branch; not touched. --- src/city_object.rs | 15 ++++++++- src/cityjson.rs | 32 +++++++++++++++++-- src/metadata.rs | 80 +++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/city_object.rs b/src/city_object.rs index 7146114..83a0ae8 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -150,7 +150,7 @@ impl CityObjectType { pub const WaterBody: CityObjectType = CityObjectType::Known(KnownCityObjectType::WaterBody); } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CityObject { #[serde(rename = "type")] pub thetype: CityObjectType, @@ -280,6 +280,19 @@ mod tests { ); } + /// Every public type in this crate derives `PartialEq`; `CityObject` was + /// missing it. + #[test] + fn city_object_with_equal_fields_is_equal() { + let input = serde_json::json!({"type": "Building"}); + let a: CityObject = serde_json::from_value(input.clone()).unwrap(); + let b: CityObject = serde_json::from_value(input).unwrap(); + assert_eq!(a, b); + let mut c = b.clone(); + c.children = Some(vec!["id-2".to_string()]); + assert_ne!(a, c); + } + /// `cityobjects.schema.json`'s `_AbstractCityObject.geographicalExtent` /// has `minItems`/`maxItems` 6 -- a fixed-length array, not an arbitrary /// `Vec` -- so a 5- or 7-element extent must fail to deserialize diff --git a/src/cityjson.rs b/src/cityjson.rs index 56ae81b..76bc3f4 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -15,7 +15,7 @@ pub enum SortingStrategy { Hilbert, //-- TODO implement Hilbert sorting } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CityJSON { #[serde(rename = "type")] pub thetype: CityJSONType, @@ -463,7 +463,7 @@ impl CityJSON { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CityJSONFeature { #[serde(rename = "type")] pub thetype: CityJSONFeatureType, @@ -544,6 +544,34 @@ pub enum CityJSONFeatureType { mod tests { use super::*; + /// Every public type in this crate derives `PartialEq`; `CityJSON` and + /// `CityJSONFeature` were missing it. + #[test] + fn cityjson_feature_with_equal_fields_is_equal() { + let input = serde_json::json!({ + "type": "CityJSONFeature", + "id": "id-1", + "CityObjects": {}, + "vertices": [] + }); + let a: CityJSONFeature = serde_json::from_value(input.clone()).unwrap(); + let b: CityJSONFeature = serde_json::from_value(input).unwrap(); + assert_eq!(a, b); + let mut c = b.clone(); + c.id = "id-2".to_string(); + assert_ne!(a, c); + } + + #[test] + fn cityjson_with_equal_fields_is_equal() { + let a = CityJSON::new(); + let b = CityJSON::new(); + assert_eq!(a, b); + let mut c = CityJSON::new(); + c.version = "1.1".to_string(); + assert_ne!(a, c); + } + #[test] fn cityjson_type_round_trips() { let t: CityJSONType = serde_json::from_value(serde_json::json!("CityJSON")).unwrap(); diff --git a/src/metadata.rs b/src/metadata.rs index 16f5561..e44d7d8 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Transform { pub scale: Vec, pub translate: Vec, @@ -19,7 +19,7 @@ impl Transform { pub type GeographicalExtent = [f64; 6]; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Address { #[serde(rename = "thoroughfareNumber")] pub thoroughfare_number: i64, @@ -31,7 +31,7 @@ pub struct Address { pub country: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PointOfContact { #[serde(rename = "contactName")] pub contact_name: String, @@ -60,7 +60,7 @@ pub struct PointOfContact { /// - `{version}` designates the specific version of the CRS /// (use "0" if there is no version) /// - `{code}` is the identifier for the specific coordinate reference system -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ReferenceSystem { pub base_url: String, pub authority: String, @@ -134,7 +134,7 @@ impl<'de> Deserialize<'de> for ReferenceSystem { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Metadata { #[serde(rename = "geographicalExtent")] #[serde(skip_serializing_if = "Option::is_none")] @@ -153,3 +153,73 @@ pub struct Metadata { #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every public type in this crate derives `PartialEq` so values can be + /// compared directly, not just formatted and eyeballed via `Debug`. + /// `Transform`, `Address`, `PointOfContact`, `ReferenceSystem`, and + /// `Metadata` were missing it; this pins that they now support `==`. + #[test] + fn transform_with_equal_fields_is_equal() { + let a = Transform::new(); + let b = Transform::new(); + assert_eq!(a, b); + let mut c = Transform::new(); + c.translate = vec![1.0, 0.0, 0.0]; + assert_ne!(a, c); + } + + #[test] + fn address_with_equal_fields_is_equal() { + let a = Address { + thoroughfare_number: 1, + thoroughfare_name: "rue de la Patate".to_string(), + locality: "Chibougamau".to_string(), + postal_code: "H0H 0H0".to_string(), + country: "Canada".to_string(), + }; + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn point_of_contact_with_equal_fields_is_equal() { + let a = PointOfContact { + contact_name: "Jane Doe".to_string(), + contact_type: None, + role: None, + phone: None, + email_address: "jane@example.com".to_string(), + website: None, + address: None, + }; + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn reference_system_with_equal_fields_is_equal() { + let a = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap(); + let b = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap(); + assert_eq!(a, b); + let c = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/4326").unwrap(); + assert_ne!(a, c); + } + + #[test] + fn metadata_with_equal_fields_is_equal() { + let a = Metadata { + geographical_extent: Some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]), + identifier: None, + point_of_contact: None, + reference_date: None, + reference_system: None, + title: Some("dataset".to_string()), + }; + let b = a.clone(); + assert_eq!(a, b); + } +} From 919754b7ab196da6b3f08c688c723ff48d998646 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:06:04 +0200 Subject: [PATCH 19/65] test: confirm SemanticsSurface already omits an empty other on write develop's 5eaf8f5 and aff25a1 made SemanticsSurface's fields public and its `other` catch-all Option so a surface with no extra members has nothing to flatten. Task 3 already made the fields public and gave `other` a HashMap instead of Value -- flattening an empty map contributes zero keys, so the same "no spurious members" outcome already holds without an Option wrapper. This pins that behaviour; no production code changed. --- src/semantics.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/semantics.rs b/src/semantics.rs index 503c1a5..b07d412 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -217,6 +217,24 @@ mod tests { assert_eq!(serde_json::to_value(&s).unwrap(), input); } + /// develop's aff25a1 made `SemanticsSurface.other` an + /// `Option` specifically so a surface with no extra + /// members serializes without an empty placeholder. Task 3 reaches the + /// same outcome a different way: `other` is a plain (non-`Option`) + /// `HashMap`, and `#[serde(flatten)]` on an empty map + /// contributes zero keys, so a surface with nothing extra round-trips + /// with no spurious members either way -- no `Option` wrapper needed. + #[test] + fn surface_with_no_extras_round_trips_without_spurious_members() { + let input = serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}], + "values": [0] + }); + let s: Semantics = serde_json::from_value(input.clone()).unwrap(); + assert!(s.surfaces[0].other.is_empty()); + assert_eq!(serde_json::to_value(&s).unwrap(), input); + } + /// The untagged variant order is load-bearing: each shape must land in the /// variant its depth implies, not merely re-serialize to the same JSON. #[test] From 10379efed8e907707610a3499460d50ba5e7ed72 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:22:49 +0200 Subject: [PATCH 20/65] fix: exclude CityJSON's #[serde(skip)] bookkeeping from PartialEq sorted_ids and transform_correction are #[serde(skip)] internal state, not part of the document, but the derived PartialEq added in 3f5cb12 compared them anyway. sorted_ids is filled by from_str iterating city_objects (a HashMap, whose iteration order is not guaranteed stable across independently-constructed maps) and can be further reordered by sort_cjfeatures(Random), so two CityJSONs parsed from the identical string could compare unequal. Confirmed empirically before fixing: a 5-top-level-CityObject fixture parsed twice via CityJSON::from_str and compared with the then-derived PartialEq failed on the first run (sorted_ids differed: ["id-a","id-c","id-d","id-b","id-e"] vs ["id-d","id-e","id-b","id-a","id-c"] for the identical input). Replaced the derive with a hand-written impl that compares only the document fields; reran the same fixture 20x under the fix with no failures. A hand-written PartialEq does not touch the "never hand-write" constraint, which is about Serialize/Deserialize. Checked every other type for the same #[serde(skip)]-reaches-PartialEq shape (only skip_serializing_if elsewhere, which does not affect equality); CityJSON was the only offender. Also fills in the missing too-long case in city_object_geographical_extent_rejects_the_wrong_length, whose doc comment already claimed to cover it. --- src/city_object.rs | 6 +++++ src/cityjson.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/city_object.rs b/src/city_object.rs index 83a0ae8..66cafa1 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -304,6 +304,12 @@ mod tests { "geographicalExtent": [0.0, 0.0, 0.0, 1.0, 1.0] }); assert!(serde_json::from_value::(too_short).is_err()); + + let too_long = serde_json::json!({ + "type": "Building", + "geographicalExtent": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0] + }); + assert!(serde_json::from_value::(too_long).is_err()); } #[test] diff --git a/src/cityjson.rs b/src/cityjson.rs index 76bc3f4..65b67e9 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -15,7 +15,7 @@ pub enum SortingStrategy { Hilbert, //-- TODO implement Hilbert sorting } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct CityJSON { #[serde(rename = "type")] pub thetype: CityJSONType, @@ -40,6 +40,34 @@ pub struct CityJSON { #[serde(skip)] transform_correction: Option, } + +/// Hand-written rather than derived: `sorted_ids` and `transform_correction` +/// are `#[serde(skip)]` internal bookkeeping, not part of the CityJSON +/// document. `sorted_ids` in particular is populated by iterating +/// `city_objects` (a `HashMap`, whose iteration order is not guaranteed +/// stable across independently-constructed maps) and can be further +/// reordered by `sort_cjfeatures(SortingStrategy::Random)`, so a derived +/// `PartialEq` that included it would make `==` spuriously `false` for two +/// values representing the identical document. Excluding it here is what +/// makes equality mean "same document", which is the only sense of equality +/// this crate (or its callers) should ever want. This does not violate the +/// "never hand-write" constraint, which is about `Serialize`/`Deserialize` +/// -- a hand-written `PartialEq` cannot corrupt output. +impl PartialEq for CityJSON { + fn eq(&self, other: &Self) -> bool { + self.thetype == other.thetype + && self.version == other.version + && self.transform == other.transform + && self.city_objects == other.city_objects + && self.vertices == other.vertices + && self.metadata == other.metadata + && self.appearance == other.appearance + && self.geometry_templates == other.geometry_templates + && self.extensions == other.extensions + && self.other == other.other + } +} + impl CityJSON { /// Create a new CityJSON instance with default values. pub fn new() -> Self { @@ -572,6 +600,41 @@ mod tests { assert_ne!(a, c); } + /// `sorted_ids` and `transform_correction` are `#[serde(skip)]` internal + /// bookkeeping, not part of the document: `sorted_ids` is populated by + /// `from_str` iterating `city_objects` (a `HashMap`, whose iteration + /// order is not guaranteed stable across independently-constructed + /// maps), and `sort_cjfeatures(Random)` shuffles it further. Two + /// `CityJSON`s parsed from the identical string represent the identical + /// document and must compare equal regardless of what order that + /// bookkeeping happened to land in. A fixture with a single top-level + /// CityObject cannot exercise this -- `sorted_ids` would trivially have + /// one, unambiguous order either way -- so this uses five. + #[test] + fn cityjson_parsed_twice_from_the_same_string_is_equal_regardless_of_hashmap_order() { + let input = serde_json::json!({ + "type": "CityJSON", + "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": { + "id-a": {"type": "Building"}, + "id-b": {"type": "Building"}, + "id-c": {"type": "Building"}, + "id-d": {"type": "Building"}, + "id-e": {"type": "Building"} + }, + "vertices": [] + }) + .to_string(); + let a = CityJSON::from_str(&input).unwrap(); + let b = CityJSON::from_str(&input).unwrap(); + assert_eq!( + a, b, + "the same document parsed twice must be equal, independent of internal \ + bookkeeping order" + ); + } + #[test] fn cityjson_type_round_trips() { let t: CityJSONType = serde_json::from_value(serde_json::json!("CityJSON")).unwrap(); From 9a69065920f72e1643dcc8c202518a3af53c1ccf Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:22:58 +0200 Subject: [PATCH 21/65] feat: derive Eq on SemanticsSurface, SemanticsValues, and Semantics Completes develop's 6917631, previously declined in 3f5cb12 on the belief that serde_json::Value blocks Eq through SemanticsSurface.other: HashMap. Checked rather than assumed: this serde_json version (1.0.151) implements Eq for both Number (src/number.rs:50, sound because a JSON number can never be NaN) and Value (src/value/mod.rs:115), so nothing here actually blocks it. Verified by removing the derives, confirming E0277 on all three types, then restoring them. --- src/semantics.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/semantics.rs b/src/semantics.rs index b07d412..f8c7f69 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -106,7 +106,7 @@ impl SemanticSurfaceType { /// One semantic surface: its type, its place in the parent/children hierarchy, /// and any further attributes the file chooses to carry. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct SemanticsSurface { #[serde(rename = "type")] pub thetype: SemanticSurfaceType, @@ -140,7 +140,7 @@ pub type SemanticsSolid = Vec>; /// The variants are ordered shallowest-first; serde tries them in declaration /// order, so a value only reaches a deeper variant when the shallower ones /// cannot describe it. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] pub enum SemanticsValues { /// `MultiSurface`, `CompositeSurface`: one index per surface. @@ -159,7 +159,7 @@ pub enum SemanticsValues { /// it is an `Option` that is serialized even when `None` — writing it out as /// `null` rather than dropping the member, which would produce a document the /// schema rejects. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct Semantics { pub surfaces: Vec, pub values: Option, @@ -195,6 +195,21 @@ mod tests { ); } + /// develop's 6917631 added `Eq` (alongside `PartialEq`) to + /// `SemanticsSurface`/`Semantics`. `SemanticsSurface.other` is + /// `HashMap`, and this serde_json version + /// (1.0.151) implements `Eq` for both `Number` (`impl Eq for N {}`, + /// `src/number.rs:50` -- sound because a JSON number can never be NaN) + /// and `Value` (`#[derive(Clone, Eq, PartialEq, Hash)]`, + /// `src/value/mod.rs:115`), so nothing here actually blocks `Eq`. + #[test] + fn semantics_surface_and_semantics_implement_eq() { + fn assert_impls_eq() {} + assert_impls_eq::(); + assert_impls_eq::(); + assert_impls_eq::(); + } + /// A semantic surface may carry a hierarchy and arbitrary extra members; /// both must survive a round-trip byte for byte. #[test] From 7fccf2b10db7c3e35eb48ec6b6d7f329cd454d8b Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:36:59 +0200 Subject: [PATCH 22/65] test: vendor nine real CityJSONSeq fixtures for round-trip testing Every existing test in this crate is synthetic -- hand-built JSON literals written from the spec. These are real files, copied in (rather than referenced by absolute path) so the suite works in a fresh clone, which this branch needs since it is destined for an upstream pull request. Chosen for diversity rather than bulk (104 kB total): noise_extension Extension City Object and semantic surface types, with their leading `+` geom_temp geometry templates, GeometryInstance, header appearance empty_appearance an appearance object whose arrays are empty small 3DBAG output, with non-core `metadata` members degenerate_extent, inferable_types, long_strings, single_feature conformance edge cases from the C++ suite duplicate_keys kept for reference only; see tests/roundtrip.rs for why it is not round-tripped flatcitybuf's delft.city.jsonl (6.6 MB) was deliberately skipped: it adds nothing over small.city.jsonl, which comes from the same 3DBAG pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/data/degenerate_extent.city.jsonl | 4 ++++ tests/data/duplicate_keys.city.jsonl | 6 ++++++ tests/data/empty_appearance.city.jsonl | 3 +++ tests/data/geom_temp.city.jsonl | 5 +++++ tests/data/inferable_types.city.jsonl | 2 ++ tests/data/long_strings.city.jsonl | 3 +++ tests/data/noise_extension.city.jsonl | 4 ++++ tests/data/single_feature.city.jsonl | 2 ++ tests/data/small.city.jsonl | 4 ++++ 9 files changed, 33 insertions(+) create mode 100644 tests/data/degenerate_extent.city.jsonl create mode 100644 tests/data/duplicate_keys.city.jsonl create mode 100644 tests/data/empty_appearance.city.jsonl create mode 100644 tests/data/geom_temp.city.jsonl create mode 100644 tests/data/inferable_types.city.jsonl create mode 100644 tests/data/long_strings.city.jsonl create mode 100644 tests/data/noise_extension.city.jsonl create mode 100644 tests/data/single_feature.city.jsonl create mode 100644 tests/data/small.city.jsonl diff --git a/tests/data/degenerate_extent.city.jsonl b/tests/data/degenerate_extent.city.jsonl new file mode 100644 index 0000000..1f35398 --- /dev/null +++ b/tests/data/degenerate_extent.city.jsonl @@ -0,0 +1,4 @@ +{"type": "CityJSON", "version": "2.0", "transform": {"scale": [0.001, 0.001, 0.001], "translate": [0.0, 0.0, 0.0]}, "CityObjects": {}, "vertices": [], "metadata": {"geographicalExtent": [0, 0, 0, 0, 0, 0]}} +{"type": "CityJSONFeature", "id": "p0", "CityObjects": {"p0": {"type": "Building", "attributes": {"n": 0}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]} +{"type": "CityJSONFeature", "id": "p1", "CityObjects": {"p1": {"type": "Building", "attributes": {"n": 1}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]} +{"type": "CityJSONFeature", "id": "p2", "CityObjects": {"p2": {"type": "Building", "attributes": {"n": 2}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]} diff --git a/tests/data/duplicate_keys.city.jsonl b/tests/data/duplicate_keys.city.jsonl new file mode 100644 index 0000000..776f842 --- /dev/null +++ b/tests/data/duplicate_keys.city.jsonl @@ -0,0 +1,6 @@ +{"type": "CityJSON", "version": "2.0", "transform": {"scale": [0.001, 0.001, 0.001], "translate": [0.0, 0.0, 0.0]}, "CityObjects": {}, "vertices": [], "metadata": {"geographicalExtent": [0, 0, 0, 100, 100, 10]}} +{"type": "CityJSONFeature", "id": "d0", "CityObjects": {"d0": {"type": "Building", "attributes": {"grp": "same", "idx": 0}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} +{"type": "CityJSONFeature", "id": "d1", "CityObjects": {"d1": {"type": "Building", "attributes": {"grp": "same", "idx": 1}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} +{"type": "CityJSONFeature", "id": "d2", "CityObjects": {"d2": {"type": "Building", "attributes": {"grp": "same", "idx": 2}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} +{"type": "CityJSONFeature", "id": "d3", "CityObjects": {"d3": {"type": "Building", "attributes": {"grp": "same", "idx": 3}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} +{"type": "CityJSONFeature", "id": "d4", "CityObjects": {"d4": {"type": "Building", "attributes": {"grp": "same", "idx": 4}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} diff --git a/tests/data/empty_appearance.city.jsonl b/tests/data/empty_appearance.city.jsonl new file mode 100644 index 0000000..e759e11 --- /dev/null +++ b/tests/data/empty_appearance.city.jsonl @@ -0,0 +1,3 @@ +{"type": "CityJSON", "version": "2.0", "transform": {"scale": [0.001, 0.001, 0.001], "translate": [0.0, 0.0, 0.0]}, "CityObjects": {}, "vertices": [], "metadata": {"geographicalExtent": [0, 0, 0, 100, 100, 10]}} +{"type": "CityJSONFeature", "id": "empty_maps", "CityObjects": {"empty_maps": {"type": "Building", "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]], "material": {}, "texture": {}}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} +{"type": "CityJSONFeature", "id": "null_indices", "CityObjects": {"null_indices": {"type": "Building", "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]], [[0, 1, 2, 3]]], "material": {"visual": {"values": [null, 0]}}}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} diff --git a/tests/data/geom_temp.city.jsonl b/tests/data/geom_temp.city.jsonl new file mode 100644 index 0000000..0db8552 --- /dev/null +++ b/tests/data/geom_temp.city.jsonl @@ -0,0 +1,5 @@ +{"type":"CityJSON","version":"2.0","transform":{"scale":[0.001,0.001,0.001],"translate":[0.56,0.64,7.579]},"CityObjects":{},"vertices":[],"metadata":{"geographicalExtent":[0.56,0.64,7.579,12.64,7.68,9.103]},"appearance":{"materials":[{"diffuseColor":[0.49609375,0.4296875,0.296875],"emissiveColor":[0.0,0.0,0.0],"name":"UUID_e58d9d68-2e8c-4b81-bec1-1892e9d71c81","specularColor":[1.0,1.0,1.0],"transparency":0.0},{"diffuseColor":[0.49609375,0.4296875,0.296875],"emissiveColor":[0.0,0.0,0.0],"name":"UUID_f55b5612-d5f9-4ef8-842d-ca4849d90b59","specularColor":[1.0,1.0,1.0],"transparency":0.0}],"textures":[{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Vegetation_Juniper2.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"},{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/MaerZ-0.png","textureType":"unknown","type":"PNG","wrapMode":"wrap"}],"vertices-texture":[[0.038837,0.0129458],[0.0428056,0.0188853],[0.0441993,0.0258914],[0.0428056,0.0328975],[0.038837,0.038837],[0.0328975,0.0428057],[0.0258914,0.0441993],[0.0188852,0.0428057],[0.0129458,0.038837],[0.0089771,0.0328976],[0.0075835,0.0258914],[0.0089771,0.0188853],[0.0129457,0.0129458],[0.0188852,0.0089772],[0.0258913,0.0075836],[0.0328975,0.0089771],[-0.0285743,0.1513162],[0.0428595,0.1513165],[0.0107114,0.7826142],[0.0035681,0.7826143],[-0.0428609,0.1513161],[0.028573,0.1513166],[-0.0035751,0.7826142],[-0.0107185,0.7826141],[-0.0560601,0.1527158],[0.0153736,0.152716],[-0.0167737,0.7840137],[-0.0239171,0.7840136],[-0.066162,0.1553019],[0.0052719,0.1553021],[-0.0268747,0.7865998],[-0.0340181,0.7865997],[-0.0716283,0.1623381],[-0.0001945,0.1623383],[-0.0323392,0.7936359],[-0.0394826,0.7936359],[-0.0661614,0.1657173],[0.0052724,0.165717],[-0.0268707,0.7970147],[-0.0340141,0.7970149],[-0.0560588,0.1683033],[0.015375,0.1683031],[-0.0167675,0.7996008],[-0.0239109,0.7996008],[-0.0428596,0.1697029],[0.0285742,0.1697026],[-0.003568,0.8010004],[-0.0107113,0.8010004],[-0.028573,0.1697029],[0.042861,0.1697025],[0.0107186,0.8010004],[0.0035752,0.8010004],[-0.0153737,0.1683032],[0.0560601,0.168303],[0.0239171,0.7996008],[0.0167737,0.7996008],[-0.0052716,0.1657171],[0.0661622,0.1657169],[0.0340182,0.7970146],[0.0268748,0.7970148],[0.0001952,0.1623382],[0.071629,0.1623384],[0.0394841,0.7936358],[0.0323407,0.7936359],[0.0001953,0.1586811],[0.0716291,0.158681],[0.0394827,0.7899788],[0.0323393,0.7899787],[-0.0052725,0.1553019],[0.0661613,0.1553022],[0.0340141,0.7865999],[0.0268707,0.7865999],[-0.0153749,0.1527159],[0.0560588,0.1527161],[0.023911,0.7840139],[0.0167676,0.7840138],[-0.0716295,0.158681],[-0.0001958,0.1586808],[-0.0323407,0.7899786],[-0.0394841,0.7899788],[0.0,0.0],[0.0,0.125],[0.125,0.125],[0.125,0.0],[0.25,0.125],[0.25,0.0],[0.375,0.125],[0.375,0.0],[0.5,0.125],[0.5,0.0],[0.625,0.125],[0.625,0.0],[0.75,0.125],[0.75,0.0],[0.875,0.125],[0.875,0.0],[1.0,0.125],[2e-7,0.2499998],[0.1250002,0.2499998],[0.0637249,0.1249996],[2e-7,0.1249998],[0.1250012,0.2499989],[0.2500012,0.2499989],[0.1887258,0.1249988],[0.1250012,0.1249989],[0.2499995,0.2500005],[0.3749995,0.2500005],[0.3137239,0.1250005],[0.2499995,0.1250005],[0.3750003,0.2499996],[0.5000003,0.2499996],[0.4387248,0.1249996],[0.3750003,0.1249996],[0.5,0.2499999],[0.625,0.2499999],[0.5637245,0.1249999],[0.5,0.1249999],[0.6249999,0.2500003],[0.7499999,0.2500003],[0.6887244,0.1250003],[0.6249999,0.1250003],[0.7499972,0.2500061],[0.8749972,0.2500061],[0.8137217,0.1250059],[0.7499972,0.1250061],[0.8750046,0.2499957],[1.0000046,0.2499957],[0.9387289,0.124996],[0.8750046,0.1249957],[-9e-7,0.2499991],[0.1249991,0.2499991],[0.0637238,0.1249989],[-1e-6,0.1249991],[0.125,0.2499999],[0.25,0.2499999],[0.1887246,0.1249999],[0.125,0.1249999],[0.2499998,0.2500002],[0.3749998,0.2500002],[0.3137242,0.1250002],[0.2499998,0.1250002],[0.375,0.25],[0.5,0.25],[0.4387245,0.125],[0.4999999,0.2500008],[0.6249999,0.2500008],[0.5637241,0.1250005],[0.4999999,0.1250008],[0.6250003,0.2500008],[0.7500003,0.2500008],[0.6887249,0.125001],[0.6250003,0.1250008],[0.7499986,0.2499975],[0.8749986,0.2499975],[0.813723,0.1249973],[0.7499986,0.1249975],[0.8750002,0.25],[1.0000002,0.25],[0.9387245,0.1250004],[0.8750002,0.125],[-4e-7,0.375001],[0.1249996,0.375001],[0.086101,0.2500011],[-4e-7,0.250001],[0.1249992,0.3750015],[0.2499992,0.3750015],[0.2111003,0.2500016],[0.1249992,0.2500015],[0.250005,0.3749917],[0.375005,0.3749917],[0.3361069,0.2499917],[0.250005,0.2499917],[0.3749983,0.3750042],[0.4999983,0.3750042],[0.4610997,0.2500042],[0.3749983,0.2500042],[0.5000014,0.3749924],[0.6250014,0.3749924],[0.5861031,0.2499925],[0.5000014,0.2499924],[0.6249969,0.3750248],[0.7499969,0.3750248],[0.7110982,0.2500244],[0.6249969,0.2500248],[0.7499995,0.375002],[0.8749995,0.375002],[0.836101,0.250002],[0.7499995,0.250002],[0.8750009,0.3749988],[1.0000009,0.3749988],[0.9611024,0.2499988],[0.8750009,0.2499988],[0.0,0.3749988],[0.125,0.3749988],[0.0861014,0.2499989],[0.0,0.2499988],[0.1249971,0.3750053],[0.2499971,0.3750053],[0.2110986,0.2500054],[0.1249971,0.2500053],[0.2499998,0.3750002],[0.3749998,0.3750002],[0.3361014,0.2500002],[0.3749999,0.3750002],[0.4999999,0.3750002],[0.4611016,0.2500003],[0.3749999,0.2500002],[0.5000007,0.3749918],[0.6250007,0.3749917],[0.586102,0.2499915],[0.5000007,0.2499918],[0.6250001,0.375001],[0.7500001,0.375001],[0.7111017,0.2500011],[0.6250001,0.250001],[0.75,0.375],[0.875,0.375],[0.8361015,0.25],[0.75,0.25],[0.8749999,0.3749999],[0.9999999,0.3749999],[0.9611015,0.2499995],[0.8749999,0.2499999],[-1e-7,0.5000004],[0.1249999,0.5000004],[0.0982118,0.3750004],[-1e-7,0.3750004],[0.1250005,0.4999988],[0.2500005,0.4999988],[0.2232126,0.3749987],[0.1250005,0.3749988],[0.2499978,0.500002],[0.3749978,0.500002],[0.3482094,0.375002],[0.2499978,0.375002],[0.374998,0.5000033],[0.499998,0.5000033],[0.4732098,0.3750033],[0.374998,0.3750033],[0.5000016,0.4999863],[0.6250016,0.4999864],[0.5982135,0.3749864],[0.5000016,0.3749863],[0.6250012,0.4999839],[0.7500012,0.499984],[0.7232131,0.3749841],[0.6250012,0.3749839],[0.749997,0.5000188],[0.874997,0.5000187],[0.8482088,0.3750186],[0.7499969,0.3750188],[0.8749958,0.5000063],[0.9999958,0.5000062],[0.9732077,0.3750061],[0.8749958,0.3750063],[-6e-7,0.5000052],[0.1249994,0.5000052],[0.0982113,0.3750051],[-6e-7,0.3750052],[0.1249985,0.5000035],[0.2499985,0.5000035],[0.2232102,0.3750036],[0.1249985,0.3750035],[0.2500035,0.4999929],[0.3750035,0.4999929],[0.3482156,0.374993],[0.2500035,0.3749929],[0.3750012,0.4999962],[0.5000012,0.4999962],[0.4732126,0.3749959],[0.3750012,0.3749962],[0.4999997,0.5000046],[0.6249997,0.5000047],[0.5982117,0.3750048],[0.4999997,0.3750046],[0.6250004,0.5000088],[0.7500004,0.5000088],[0.7232124,0.375009],[0.6250004,0.3750088],[0.7499999,0.4999961],[0.8749999,0.4999961],[0.8482117,0.3749959],[0.7499999,0.3749961],[0.8749997,0.500001],[0.9999997,0.500001],[0.9732115,0.3750014],[0.8749997,0.375001],[-1.8e-6,0.6250085],[0.1249982,0.6250085],[0.1063021,0.5000083],[-1.8e-6,0.5000085],[0.1249994,0.6250016],[0.2499994,0.6250016],[0.2313032,0.5000016],[0.1249994,0.5000016],[0.2499986,0.6250045],[0.3749986,0.6250045],[0.3563023,0.5000045],[0.2499986,0.5000045],[0.3750039,0.62498],[0.5000039,0.62498],[0.4813078,0.49998],[0.3750039,0.49998],[0.4999949,0.6250572],[0.6249949,0.6250571],[0.6062984,0.500057],[0.4999949,0.5000572],[0.6250005,0.6249912],[0.7500005,0.6249912],[0.7313042,0.4999912],[0.6250005,0.4999912],[0.7500062,0.6249449],[0.8750062,0.6249452],[0.8563101,0.4999454],[0.7500063,0.4999449],[0.8749974,0.6250048],[0.9999974,0.6250047],[0.9813011,0.5000047],[0.8749973,0.5000048],[2.9e-6,0.6249813],[0.1250029,0.6249812],[0.1063067,0.4999812],[2.9e-6,0.4999813],[0.1250012,0.624997],[0.2500012,0.624997],[0.2313049,0.4999969],[0.1250012,0.499997],[0.2499976,0.6249981],[0.3749976,0.6249981],[0.3563013,0.499998],[0.2499976,0.499998],[0.3749992,0.6250036],[0.4999992,0.6250036],[0.4813031,0.5000037],[0.3749992,0.5000036],[0.5000019,0.624968],[0.6250019,0.624968],[0.6063054,0.4999676],[0.5000019,0.499968],[0.6250003,0.6250114],[0.7500003,0.6250114],[0.7313041,0.5000114],[0.6250003,0.5000114],[0.7500002,0.6250246],[0.8750001,0.6250248],[0.856304,0.5000252],[0.7500002,0.5000246],[0.8750006,0.6249978],[1.0000006,0.6249978],[0.9813043,0.4999977],[0.8750006,0.4999978],[-2.9e-6,0.750013],[0.1249971,0.750013],[0.1124942,0.6250129],[-2.9e-6,0.625013],[0.1249996,0.7500013],[0.2499996,0.7500014],[0.2374967,0.6250013],[0.1249996,0.6250013],[0.2499993,0.7500024],[0.3749993,0.7500024],[0.3624962,0.6250024],[0.2499993,0.6250024],[0.3749991,0.7500177],[0.4999991,0.7500177],[0.4874961,0.6250177],[0.3749991,0.6250177],[0.5000014,0.7499832],[0.6250014,0.7499833],[0.6124985,0.6249833],[0.5000014,0.6249832],[0.6249993,0.7500147],[0.7499993,0.7500146],[0.7374963,0.6250146],[0.6249993,0.6250147],[0.750001,0.7499869],[0.875001,0.749987],[0.8624981,0.624987],[0.750001,0.6249869],[0.8749972,0.7500094],[0.9999972,0.7500093],[0.9874941,0.6250092],[0.8749971,0.6250094],[-8.6e-6,0.7500417],[0.1249914,0.7500417],[0.1124883,0.6250418],[-8.5e-6,0.6250417],[0.1250028,0.7499924],[0.2500028,0.7499924],[0.2374999,0.6249924],[0.1250028,0.6249924],[0.25,0.7500001],[0.375,0.7500001],[0.3624968,0.6250001],[0.25,0.6250001],[0.3749979,0.7500113],[0.4999979,0.7500112],[0.4874951,0.6250114],[0.3749979,0.6250113],[0.499999,0.7500155],[0.624999,0.7500155],[0.6124961,0.6250156],[0.499999,0.6250155],[0.6249997,0.7499841],[0.7499997,0.7499841],[0.7374967,0.6249841],[0.6249997,0.6249841],[0.749999,0.749947],[0.8749991,0.7499467],[0.8624959,0.6249464],[0.749999,0.624947],[0.874998,0.7500257],[0.999998,0.750026],[0.987495,0.6250263],[0.874998,0.6250257],[-2.4e-6,0.8750021],[0.1249976,0.875002],[0.1177448,0.7500021],[-2.5e-6,0.7500021],[0.1249997,0.875],[0.2499997,0.8749999],[0.2427469,0.7500001],[0.1249997,0.75],[0.25,0.8750002],[0.375,0.8750002],[0.3677472,0.7500003],[0.2499997,0.7500004],[0.3750008,0.8749994],[0.5000008,0.8749995],[0.4927485,0.7499995],[0.3750009,0.7499994],[0.5000005,0.8749997],[0.6250005,0.8749999],[0.617748,0.75],[0.5000006,0.7499997],[0.6249992,0.8749999],[0.7499992,0.8749995],[0.7427465,0.7499995],[0.6249991,0.75],[0.7500006,0.8750018],[0.8750006,0.8750013],[0.8677478,0.7500013],[0.7500004,0.7500019],[0.8750028,0.8749959],[1.0000028,0.8749957],[0.9927501,0.7499957],[0.8750027,0.7499959],[-1.6e-6,0.875001],[0.1249984,0.8750011],[0.117746,0.7500011],[-1.6e-6,0.750001],[0.1249998,0.8750002],[0.2499998,0.8750003],[0.2427473,0.7500003],[0.1249998,0.7500002],[0.25,0.8749995],[0.375,0.8749995],[0.3677472,0.7499994],[0.25,0.7499995],[0.3749999,0.8750001],[0.4999999,0.8750001],[0.4927473,0.7500001],[0.3749999,0.7500001],[0.6250005,0.8749998],[0.6177479,0.7499999],[0.5000005,0.7499997],[0.6250009,0.8750001],[0.7500008,0.8750005],[0.7427484,0.7500006],[0.6250009,0.7500002],[0.7499995,0.8749976],[0.8749995,0.8749975],[0.8677469,0.7499975],[0.7499995,0.7499976],[0.8750041,0.8749874],[1.0000041,0.8749875],[0.9927516,0.7499873],[0.8750041,0.7499875],[1.7e-6,0.9999991],[0.1250017,0.9999992],[0.1226,0.8749991],[1.7e-6,0.874999],[0.1250002,1.0],[0.2500002,1.0000001],[0.2475984,0.875],[0.1250002,0.875],[0.2500001,0.9999998],[0.3750001,0.9999999],[0.3725984,0.8749997],[0.2500001,0.8749998],[0.374999,1.0000007],[0.499999,1.0000006],[0.4975969,0.8750007],[0.374999,0.8750007],[0.5000005,0.9999997],[0.6250005,0.9999998],[0.6225988,0.8749997],[0.5000005,0.8749996],[0.6250001,1.0],[0.7500001,0.9999999],[0.7475983,0.8749999],[0.6250001,0.8749999],[0.7499896,1.000003],[0.8749897,0.9999988],[0.8725869,0.8749989],[0.7499888,0.8750029],[0.8750064,0.9999965],[1.0000063,0.9999936],[0.9976037,0.8749936],[0.8750057,0.8749964],[-1.5e-6,1.0000005],[0.1249985,1.0000008],[0.1225968,0.8750007],[-1.5e-6,0.8750005],[0.1250004,0.9999994],[0.2500004,0.9999993],[0.2475983,0.8749992],[0.1250004,0.8749994],[0.25,0.9999997],[0.375,0.9999997],[0.3725983,0.8749997],[0.25,0.8749996],[0.3750001,1.0],[0.5000001,1.0],[0.4975982,0.8749999],[0.3750001,0.875],[0.4999999,0.9999994],[0.6249999,0.9999995],[0.6225981,0.8749996],[0.4999999,0.8749994],[0.6249992,1.000002],[0.7499992,1.000001],[0.7475971,0.8750008],[0.624999,0.8750019],[0.7500049,1.0000115],[0.8750049,1.0000135],[0.8726035,0.8750136],[0.7500053,0.8750115],[0.8749966,1.0000057],[0.9999966,1.0000055],[0.9975947,0.8750055],[0.8749965,0.8750057],[0.0024033,0.1249999],[0.1250016,0.1250001],[0.1250016,1e-7],[1.6e-6,-1e-7],[0.1274015,0.1249998],[0.2499995,0.1249998],[0.2499995,-2e-7],[0.1249995,-1e-7],[0.2524017,0.125],[0.3774018,0.1250001],[0.4999999,0.1250001],[0.4999999,1e-7],[0.3749999,2e-7],[0.5024026,0.1249992],[0.6250009,0.1249993],[0.6250009,-7e-7],[0.5000009,-8e-7],[0.6273963,0.1250067],[0.7499943,0.1250062],[0.7499942,6.2e-6],[0.6249942,6.8e-6],[0.7523956,0.1250256],[0.8749938,0.1250256],[0.8749937,0.0000256],[0.7499937,0.0000258],[0.8773955,0.124983],[0.9999933,0.1249791],[0.9999925,-0.0000209],[0.8749925,-0.0000174],[0.0024013,0.1250002],[0.1249996,0.1250005],[0.1249996,5e-7],[-4e-7,3e-7],[0.1274016,0.1250003],[0.2499998,0.1250004],[0.2499998,4e-7],[0.1249998,4e-7],[0.252402,0.1249999],[0.375,0.1249998],[0.375,-1e-7],[0.25,-1e-7],[0.3774019,0.1250004],[0.5000001,0.1250003],[0.5000001,4e-7],[0.3750001,4e-7],[0.5024014,0.1249992],[0.6249996,0.1249994],[0.6249996,-6e-7],[0.4999996,-7e-7],[0.6274017,0.1249999],[0.7499999,0.1249999],[0.7499999,-1e-7],[0.6249999,0.0],[0.7524019,0.1250003],[0.875,0.1250001],[0.875,1e-7],[0.75,3e-7],[0.8773992,0.1250006],[0.9999973,0.125],[0.9999972,0.0],[0.8749972,7e-7],[0.0072538,0.2500005],[0.1250013,0.2500008],[0.1250013,0.1250008],[1.3e-6,0.1250006],[0.1322524,0.2500001],[0.2499999,0.25],[0.2499999,0.125],[0.1249998,0.125],[0.2572525,0.25],[0.375,0.2499999],[0.25,0.1249999],[0.3822519,0.2500008],[0.4999991,0.2500009],[0.4999991,0.1250009],[0.3749991,0.125001],[0.5072533,0.249999],[0.6250008,0.249999],[0.6250008,0.124999],[0.5000008,0.1249989],[0.6322554,0.2499938],[0.7500029,0.2499938],[0.750003,0.1249939],[0.625003,0.1249936],[0.7572542,0.2499809],[0.8750016,0.2499806],[0.8750016,0.1249806],[0.7500016,0.1249807],[0.8822576,0.2500161],[1.0000052,0.2500178],[1.0000056,0.1250179],[0.8750055,0.1250167],[0.0072527,0.2499994],[0.1249999,0.2499992],[0.1249999,0.1249992],[-1e-7,0.1249993],[0.1322526,0.2499998],[0.2500001,0.2499998],[0.2500001,0.1249998],[0.1250001,0.1249998],[0.2572528,0.2500007],[0.375,0.2500006],[0.375,0.1250006],[0.25,0.1250007],[0.3822517,0.2499988],[0.4999994,0.249999],[0.4999994,0.124999],[0.3749994,0.1249988],[0.5072518,0.2499995],[0.6249993,0.2499997],[0.6249993,0.1249997],[0.4999993,0.1249995],[0.6322595,0.2499987],[0.7500067,0.2499976],[0.7500064,0.1249975],[0.6250065,0.1249988],[0.7572475,0.2500154],[0.874995,0.2500158],[0.8749951,0.1250159],[0.7499951,0.1250153],[0.8822528,0.2500005],[1.0000002,0.2500001],[1.0000001,0.1250001],[0.8750001,0.1250005],[0.012493,0.3749341],[0.1249898,0.3749338],[0.1249897,0.2499337],[-0.0000103,0.249934],[0.1375058,0.3750084],[0.250003,0.3750084],[0.250003,0.2500083],[0.125003,0.2500083],[0.2625026,0.3750077],[0.3749994,0.3750079],[0.3749995,0.2500078],[0.2499994,0.2500078],[0.3875045,0.3749858],[0.5000017,0.3749856],[0.5000017,0.2499855],[0.3750017,0.2499856],[0.5125007,0.3750377],[0.6249976,0.3750379],[0.6249976,0.2500378],[0.4999976,0.2500379],[0.6375024,0.3749677],[0.7499995,0.3749675],[0.7499996,0.2499674],[0.6249995,0.2499673],[0.762514,0.3750255],[0.875011,0.3750256],[0.8750109,0.2500256],[0.7500109,0.2500258],[0.8874968,0.3750464],[0.9999937,0.375046],[0.9999937,0.250046],[0.8749937,0.2500461],[0.0125064,0.3750578],[0.1250035,0.3750577],[0.1250032,0.2500573],[3.5e-6,0.2500579],[0.1375101,0.375048],[0.2500069,0.3750477],[0.2500069,0.2500476],[0.1250065,0.2500476],[0.2625036,0.3750317],[0.3750011,0.3750317],[0.375001,0.2500316],[0.250001,0.2500317],[0.3875105,0.3749474],[0.5000072,0.3749476],[0.5000072,0.2499475],[0.3750072,0.2499475],[0.5125012,0.3750159],[0.6249983,0.3750159],[0.6249983,0.2500158],[0.4999983,0.2500158],[0.6375052,0.3750465],[0.7500024,0.3750466],[0.7500024,0.2500466],[0.6250024,0.2500464],[0.7624886,0.3749701],[0.8749856,0.3749701],[0.8749856,0.24997],[0.7499856,0.2499703],[0.8874999,0.3750149],[0.9999969,0.375015],[0.9999969,0.2500149],[0.8749969,0.2500149],[0.0187023,0.500032],[0.1250062,0.5000321],[0.1250062,0.3750321],[6.2e-6,0.375032],[0.1436982,0.5000041],[0.250002,0.5000042],[0.250002,0.3750041],[0.125002,0.3750041],[0.2686957,0.4999999],[0.3749994,0.4999999],[0.3749994,0.3749999],[0.2499994,0.3749998],[0.3936969,0.5000002],[0.5000008,0.5],[0.5000008,0.375],[0.3750008,0.375],[0.5186965,0.5000094],[0.6250003,0.5000095],[0.6250002,0.3750095],[0.5000003,0.3750095],[0.6436951,0.4999727],[0.7499989,0.4999727],[0.749999,0.3749723],[0.6249989,0.3749726],[0.7686907,0.4999504],[0.8749945,0.4999507],[0.8749946,0.3749507],[0.7499945,0.37495],[0.893698,0.4999836],[1.0000018,0.4999837],[1.0000018,0.3749837],[0.8750018,0.3749837],[0.0186973,0.5000229],[0.1250009,0.5000226],[0.1250009,0.3750226],[9e-7,0.3750227],[0.1436941,0.4999769],[0.2499982,0.4999772],[0.2499982,0.3749771],[0.1249982,0.3749771],[0.2686953,0.4999987],[0.3749987,0.4999987],[0.3749987,0.3749986],[0.2499987,0.3749987],[0.3936936,0.5000388],[0.4999977,0.5000387],[0.4999977,0.3750387],[0.3749977,0.3750386],[0.5187,0.4999464],[0.6250035,0.4999465],[0.6250035,0.3749465],[0.5000035,0.3749466],[0.6436957,0.500001],[0.7499994,0.500001],[0.7499994,0.375001],[0.6249994,0.375001],[0.7687036,0.5000047],[0.8750074,0.5000045],[0.8750074,0.3750045],[0.7500074,0.3750044],[0.893695,0.5000156],[0.9999988,0.5000157],[0.9999988,0.3750156],[0.8749988,0.3750156],[0.0267858,0.6249912],[0.1249977,0.6249912],[0.1249976,0.4999913],[-2.4e-6,0.4999913],[0.1517836,0.6249841],[0.2499953,0.624984],[0.2499953,0.4999841],[0.1249953,0.4999841],[0.2767868,0.6249918],[0.3749985,0.6249918],[0.3749985,0.4999919],[0.2499985,0.4999919],[0.401786,0.6250005],[0.4999978,0.6250005],[0.4999979,0.5000006],[0.3749979,0.5000006],[0.5267888,0.6249911],[0.6250008,0.624991],[0.6250008,0.4999911],[0.5000008,0.4999911],[0.6517864,0.6249871],[0.7499983,0.624987],[0.7499983,0.499987],[0.6249984,0.499987],[0.7767878,0.6249942],[0.8749997,0.6249944],[0.8749997,0.4999944],[0.7499997,0.4999944],[0.901788,0.6249918],[0.9999999,0.6249921],[0.9999999,0.4999921],[0.8749999,0.4999921],[0.0267933,0.6250388],[0.1250048,0.6250383],[0.1250047,0.5000383],[4.7e-6,0.5000384],[0.1517918,0.6250024],[0.2500039,0.6250025],[0.2500039,0.5000026],[0.1250039,0.5000026],[0.2767865,0.6250018],[0.3749986,0.6250018],[0.3749986,0.5000019],[0.2499986,0.5000019],[0.4017946,0.6249676],[0.5000061,0.6249678],[0.5000061,0.4999679],[0.3750061,0.4999679],[0.5267857,0.6250177],[0.6249976,0.6250177],[0.6249976,0.5000177],[0.4999976,0.5000178],[0.6517909,0.6250222],[0.7500028,0.6250221],[0.7500028,0.5000222],[0.6250028,0.5000222],[0.7767897,0.6250078],[0.8750016,0.625008],[0.8750016,0.500008],[0.7500016,0.500008],[0.9017902,0.625013],[1.0000021,0.6250132],[1.0000021,0.5000132],[0.8750021,0.5000132],[0.0388976,0.7499972],[0.1249991,0.7499971],[0.1249991,0.6249971],[-9e-7,0.6249971],[0.1638973,0.7499955],[0.2499988,0.7499955],[0.2499988,0.6249954],[0.1249988,0.6249955],[0.2888961,0.7499997],[0.3749981,0.7499997],[0.3749981,0.6249996],[0.2499981,0.6249996],[0.4138967,0.7499997],[0.4999981,0.7499998],[0.4999981,0.6249998],[0.3749981,0.6249998],[0.5388978,0.7500035],[0.6249991,0.7500038],[0.6249991,0.6250037],[0.4999991,0.6250038],[0.6638982,0.7499985],[0.7499998,0.7499984],[0.7499998,0.6249984],[0.6249997,0.6249984],[0.7888982,0.7499986],[0.8749997,0.7499986],[0.8749997,0.6249986],[0.7499997,0.6249986],[0.9138986,0.7499972],[1.0,0.7499972],[1.0,0.6249972],[0.8750001,0.6249972],[0.038898,0.7499978],[0.1249997,0.749998],[0.1249998,0.6249979],[-3e-7,0.624998],[0.1638999,0.7500008],[0.250001,0.7500007],[0.250001,0.6250007],[0.125001,0.6250007],[0.2888994,0.7500074],[0.375001,0.7500073],[0.375001,0.6250073],[0.250001,0.6250073],[0.4138975,0.7500065],[0.4999992,0.7500064],[0.4999992,0.6250064],[0.3749992,0.6250064],[0.5388958,0.7500092],[0.6249974,0.7500091],[0.6249973,0.625009],[0.4999974,0.6250091],[0.6638987,0.7499882],[0.7500001,0.7499886],[0.7500001,0.6249886],[0.6250001,0.6249886],[0.7888984,0.7500006],[0.8749999,0.7500006],[0.8749999,0.6250006],[0.7499999,0.6250006],[0.913898,0.7499983],[0.9999995,0.7499983],[0.9999995,0.6249983],[0.8749995,0.6249983],[0.0612812,0.8750098],[0.1250061,0.87501],[0.1250061,0.75001],[6.1e-6,0.75001],[0.1862784,0.8750042],[0.250003,0.8750042],[0.250003,0.7500041],[0.125003,0.7500041],[0.3112748,0.8750062],[0.3749996,0.8750062],[0.3750003,0.750006],[0.2499996,0.7500061],[0.4362775,0.8750014],[0.5000011,0.8750018],[0.5000011,0.7500018],[0.3750017,0.7500015],[0.5612761,0.8750022],[0.6250008,0.8750019],[0.6250007,0.7500019],[0.5000007,0.7500019],[0.6862761,0.8750022],[0.7500006,0.8750019],[0.7500006,0.7500019],[0.6250005,0.7500019],[0.8112766,0.8749994],[0.8750011,0.8749996],[0.8750011,0.7499995],[0.7500011,0.7499995],[0.9362756,0.8749961],[1.0000002,0.8749962],[1.0000002,0.7499962],[0.8750002,0.7499962],[0.0612734,0.8749955],[0.1249976,0.8749954],[0.1249977,0.7499954],[-2.3e-6,0.7499954],[0.1862744,0.8749944],[0.2499989,0.8749945],[0.249999,0.7499944],[0.124999,0.7499944],[0.3112709,0.8749969],[0.3749953,0.8749968],[0.3749953,0.7499968],[0.2499953,0.7499968],[0.4362761,0.8749953],[0.5000006,0.8749953],[0.5000006,0.7499953],[0.3750006,0.7499952],[0.5612782,0.8749847],[0.6250023,0.8749851],[0.6250022,0.749985],[0.5000022,0.7499851],[0.6862727,0.8750028],[0.7499973,0.8750023],[0.7499973,0.7500023],[0.6249972,0.7500023],[0.8112737,0.8749987],[0.8749981,0.8749989],[0.8749981,0.7499989],[0.7499981,0.7499989],[0.9362704,0.8749923],[0.9999945,0.8749919],[0.9999945,0.7499919],[0.8749945,0.7499919],[0.125,1.0],[0.1249998,0.8750001],[-1e-7,0.875],[0.25,1.0],[0.2499999,0.8750001],[0.1249999,0.8750001],[0.375,1.0],[0.375,0.8750001],[0.25,0.8750001],[0.5,1.0],[0.499999,0.8750004],[0.3750001,0.8750001],[0.625,1.0],[0.6250001,0.875],[0.4999995,0.8750005],[0.75,1.0],[0.75,0.875],[0.875,1.0],[0.8749999,0.875],[1.0,1.0],[1.0000008,0.8750005],[0.1249999,0.875],[1.2e-6,0.8750004],[0.5000001,0.8750001],[0.6249994,0.8750005],[0.5000002,0.8750001],[0.7500001,0.875],[0.6249999,0.8750006],[0.9999999,0.875]]},"geometry-templates":{"templates":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[16,17,4,3]],[[17,18,5,4]],[[18,19,6,5]],[[19,20,7,6]],[[21,22,9,8]],[[22,23,10,9]],[[23,24,11,10]],[[24,25,12,11]],[[25,26,13,12]],[[26,27,14,13]],[[27,28,15,14]],[[28,29,0,15]],[[29,30,1,0]],[[30,31,2,1]],[[31,16,3,2]],[[19,18,17,16,31,30,29,28,27,26,25,24,23,22,21,20]],[[20,21,8,7]],[[32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]],[[48,49,36,35]],[[49,50,37,36]],[[50,51,38,37]],[[51,52,39,38]],[[52,53,40,39]],[[53,54,41,40]],[[54,55,42,41]],[[55,56,43,42]],[[56,57,44,43]],[[57,58,45,44]],[[58,59,46,45]],[[59,60,47,46]],[[60,61,32,47]],[[61,62,33,32]],[[62,63,34,33]],[[63,48,35,34]],[[51,50,49,48,63,62,61,60,59,58,57,56,55,54,53,52]]],"material":{"visual":{"values":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]}},"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[0,24,25,26,27]],[[0,28,29,30,31]],[[0,32,33,34,35]],[[0,36,37,38,39]],[[0,40,41,42,43]],[[0,44,45,46,47]],[[0,48,49,50,51]],[[0,52,53,54,55]],[[0,56,57,58,59]],[[0,60,61,62,63]],[[0,64,65,66,67]],[[0,68,69,70,71]],[[0,72,73,74,75]],[[null]],[[0,76,77,78,79]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]]]}}},{"type":"MultiSurface","lod":"3","boundaries":[[[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79]],[[80,81,68,67]],[[81,82,69,68]],[[82,83,70,69]],[[83,84,71,70]],[[84,85,72,71]],[[85,86,73,72]],[[86,87,74,73]],[[87,88,75,74]],[[88,89,76,75]],[[89,90,77,76]],[[90,91,78,77]],[[91,92,79,78]],[[92,93,64,79]],[[93,94,65,64]],[[94,95,66,65]],[[95,80,67,66]],[[83,82,81,80,95,94,93,92,91,90,89,88,87,86,85,84]],[[96,97,98]],[[96,98,99]],[[96,99,100]],[[96,100,101]],[[96,101,102]],[[96,102,103]],[[96,103,104]],[[96,104,105]],[[96,105,106]],[[96,106,107]],[[96,107,108]],[[96,108,109]],[[96,109,110]],[[96,110,111]],[[96,111,112]],[[96,112,97]],[[113,114,98,97]],[[114,115,99,98]],[[115,116,100,99]],[[116,117,101,100]],[[117,118,102,101]],[[118,119,103,102]],[[119,120,104,103]],[[120,121,105,104]],[[121,122,106,105]],[[122,123,107,106]],[[123,124,108,107]],[[124,125,109,108]],[[125,126,110,109]],[[126,127,111,110]],[[127,128,112,111]],[[128,113,97,112]],[[129,130,114,113]],[[130,131,115,114]],[[131,132,116,115]],[[132,133,117,116]],[[133,134,118,117]],[[134,135,119,118]],[[135,136,120,119]],[[136,137,121,120]],[[137,138,122,121]],[[138,139,123,122]],[[139,140,124,123]],[[140,141,125,124]],[[141,142,126,125]],[[142,143,127,126]],[[143,144,128,127]],[[144,129,113,128]],[[145,146,130,129]],[[146,147,131,130]],[[147,148,132,131]],[[148,149,133,132]],[[149,150,134,133]],[[150,151,135,134]],[[151,152,136,135]],[[152,153,137,136]],[[153,154,138,137]],[[154,155,139,138]],[[155,156,140,139]],[[156,157,141,140]],[[157,158,142,141]],[[158,159,143,142]],[[159,160,144,143]],[[160,145,129,144]],[[161,162,146,145]],[[162,163,147,146]],[[163,164,148,147]],[[164,165,149,148]],[[165,166,150,149]],[[166,167,151,150]],[[167,168,152,151]],[[168,169,153,152]],[[169,170,154,153]],[[170,171,155,154]],[[171,172,156,155]],[[172,173,157,156]],[[173,174,158,157]],[[174,175,159,158]],[[175,176,160,159]],[[176,161,145,160]],[[177,178,162,161]],[[178,179,163,162]],[[179,180,164,163]],[[180,181,165,164]],[[181,182,166,165]],[[182,183,167,166]],[[183,184,168,167]],[[184,185,169,168]],[[185,186,170,169]],[[186,187,171,170]],[[187,188,172,171]],[[188,189,173,172]],[[189,190,174,173]],[[190,191,175,174]],[[191,192,176,175]],[[192,177,161,176]],[[193,194,178,177]],[[194,195,179,178]],[[195,196,180,179]],[[196,197,181,180]],[[197,198,182,181]],[[198,199,183,182]],[[199,200,184,183]],[[200,201,185,184]],[[201,202,186,185]],[[202,203,187,186]],[[203,204,188,187]],[[204,205,189,188]],[[205,206,190,189]],[[206,207,191,190]],[[207,208,192,191]],[[208,193,177,192]],[[209,210,194,193]],[[210,211,195,194]],[[211,212,196,195]],[[212,213,197,196]],[[213,214,198,197]],[[214,215,199,198]],[[215,216,200,199]],[[216,217,201,200]],[[217,218,202,201]],[[218,219,203,202]],[[219,220,204,203]],[[220,221,205,204]],[[221,222,206,205]],[[222,223,207,206]],[[223,224,208,207]],[[224,209,193,208]],[[225,226,210,209]],[[226,227,211,210]],[[227,228,212,211]],[[228,229,213,212]],[[229,230,214,213]],[[230,231,215,214]],[[231,232,216,215]],[[232,233,217,216]],[[233,234,218,217]],[[234,235,219,218]],[[235,236,220,219]],[[236,237,221,220]],[[237,238,222,221]],[[238,239,223,222]],[[239,240,224,223]],[[240,225,209,224]],[[241,242,226,225]],[[242,243,227,226]],[[243,244,228,227]],[[244,245,229,228]],[[245,246,230,229]],[[246,247,231,230]],[[247,248,232,231]],[[248,249,233,232]],[[249,250,234,233]],[[250,251,235,234]],[[251,252,236,235]],[[252,253,237,236]],[[253,254,238,237]],[[254,255,239,238]],[[255,256,240,239]],[[256,241,225,240]],[[257,258,242,241]],[[258,259,243,242]],[[259,260,244,243]],[[260,261,245,244]],[[261,262,246,245]],[[262,263,247,246]],[[263,264,248,247]],[[264,265,249,248]],[[265,266,250,249]],[[266,267,251,250]],[[267,268,252,251]],[[268,269,253,252]],[[269,270,254,253]],[[270,271,255,254]],[[271,272,256,255]],[[272,257,241,256]],[[273,274,258,257]],[[274,275,259,258]],[[275,276,260,259]],[[276,277,261,260]],[[277,278,262,261]],[[278,279,263,262]],[[279,280,264,263]],[[280,281,265,264]],[[281,282,266,265]],[[282,283,267,266]],[[283,284,268,267]],[[284,285,269,268]],[[285,286,270,269]],[[286,287,271,270]],[[287,288,272,271]],[[288,273,257,272]],[[289,290,274,273]],[[290,291,275,274]],[[291,292,276,275]],[[292,293,277,276]],[[293,294,278,277]],[[294,295,279,278]],[[295,296,280,279]],[[296,297,281,280]],[[297,298,282,281]],[[298,299,283,282]],[[299,300,284,283]],[[300,301,285,284]],[[301,302,286,285]],[[302,303,287,286]],[[303,304,288,287]],[[304,289,273,288]],[[305,306,290,289]],[[306,307,291,290]],[[307,308,292,291]],[[308,309,293,292]],[[309,310,294,293]],[[310,311,295,294]],[[311,312,296,295]],[[312,313,297,296]],[[313,314,298,297]],[[314,315,299,298]],[[315,316,300,299]],[[316,317,301,300]],[[317,318,302,301]],[[318,319,303,302]],[[319,320,304,303]],[[320,305,289,304]],[[321,322,306,305]],[[322,323,307,306]],[[323,324,308,307]],[[324,325,309,308]],[[325,326,310,309]],[[326,327,311,310]],[[327,328,312,311]],[[328,329,313,312]],[[329,330,314,313]],[[330,331,315,314]],[[331,332,316,315]],[[332,333,317,316]],[[333,334,318,317]],[[334,335,319,318]],[[335,336,320,319]],[[336,321,305,320]],[[337,322,321]],[[337,323,322]],[[337,324,323]],[[337,325,324]],[[337,326,325]],[[337,327,326]],[[337,328,327]],[[337,329,328]],[[337,330,329]],[[337,331,330]],[[337,332,331]],[[337,333,332]],[[337,334,333]],[[337,335,334]],[[337,336,335]],[[337,321,336]]],"material":{"visual":{"value":2}},"texture":{"visual":{"values":[[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[1,80,81,82]],[[1,83,82,84]],[[1,85,84,86]],[[1,87,86,88]],[[1,89,88,90]],[[1,91,90,92]],[[1,93,92,94]],[[1,95,94,96]],[[1,80,81,82]],[[1,83,82,84]],[[1,85,84,86]],[[1,87,86,88]],[[1,89,88,90]],[[1,91,90,92]],[[1,93,92,94]],[[1,95,94,96]],[[1,97,98,99,100]],[[1,101,102,103,104]],[[1,105,106,107,108]],[[1,109,110,111,112]],[[1,113,114,115,116]],[[1,117,118,119,120]],[[1,121,122,123,124]],[[1,125,126,127,128]],[[1,129,130,131,132]],[[1,133,134,135,136]],[[1,137,138,139,140]],[[1,141,142,143,86]],[[1,144,145,146,147]],[[1,148,149,150,151]],[[1,152,153,154,155]],[[1,156,157,158,159]],[[1,160,161,162,163]],[[1,164,165,166,167]],[[1,168,169,170,171]],[[1,172,173,174,175]],[[1,176,177,178,179]],[[1,180,181,182,183]],[[1,184,185,186,187]],[[1,188,189,190,191]],[[1,192,193,194,195]],[[1,196,197,198,199]],[[1,200,201,202,137]],[[1,203,204,205,206]],[[1,207,208,209,210]],[[1,211,212,213,214]],[[1,215,216,217,218]],[[1,219,220,221,222]],[[1,223,224,225,226]],[[1,227,228,229,230]],[[1,231,232,233,234]],[[1,235,236,237,238]],[[1,239,240,241,242]],[[1,243,244,245,246]],[[1,247,248,249,250]],[[1,251,252,253,254]],[[1,255,256,257,258]],[[1,259,260,261,262]],[[1,263,264,265,266]],[[1,267,268,269,270]],[[1,271,272,273,274]],[[1,275,276,277,278]],[[1,279,280,281,282]],[[1,283,284,285,286]],[[1,287,288,289,290]],[[1,291,292,293,294]],[[1,295,296,297,298]],[[1,299,300,301,302]],[[1,303,304,305,306]],[[1,307,308,309,310]],[[1,311,312,313,314]],[[1,315,316,317,318]],[[1,319,320,321,322]],[[1,323,324,325,326]],[[1,327,328,329,330]],[[1,331,332,333,334]],[[1,335,336,337,338]],[[1,339,340,341,342]],[[1,343,344,345,346]],[[1,347,348,349,350]],[[1,351,352,353,354]],[[1,355,356,357,358]],[[1,359,360,361,362]],[[1,363,364,365,366]],[[1,367,368,369,370]],[[1,371,372,373,374]],[[1,375,376,377,378]],[[1,379,380,381,382]],[[1,383,384,385,386]],[[1,387,388,389,390]],[[1,391,392,393,394]],[[1,395,396,397,398]],[[1,399,400,401,402]],[[1,403,404,405,406]],[[1,407,408,409,410]],[[1,411,412,413,414]],[[1,415,416,417,418]],[[1,419,420,421,422]],[[1,423,424,425,426]],[[1,427,428,429,430]],[[1,431,432,433,434]],[[1,435,436,437,438]],[[1,439,440,441,442]],[[1,443,444,445,446]],[[1,447,448,449,450]],[[1,451,452,453,454]],[[1,455,456,457,458]],[[1,459,460,461,462]],[[1,431,463,464,465]],[[1,466,467,468,469]],[[1,470,471,472,473]],[[1,474,475,476,477]],[[1,478,479,480,481]],[[1,482,483,484,485]],[[1,486,487,488,489]],[[1,490,491,492,493]],[[1,494,495,496,497]],[[1,498,499,500,501]],[[1,502,503,504,505]],[[1,506,507,508,509]],[[1,510,511,512,513]],[[1,514,515,516,517]],[[1,518,519,520,521]],[[1,522,523,524,525]],[[1,526,527,528,529]],[[1,530,531,532,533]],[[1,534,535,536,537]],[[1,538,539,540,541]],[[1,542,543,544,545]],[[1,546,547,548,549]],[[1,550,86,87,85]],[[1,551,552,553,554]],[[1,555,556,557,558]],[[1,559,560,561,562]],[[1,563,564,565,566]],[[1,567,568,569,570]],[[1,571,572,573,574]],[[1,575,576,577,578]],[[1,579,580,581,582]],[[1,583,584,585,586]],[[1,587,588,589,590]],[[1,591,592,593,594]],[[1,595,596,597,598]],[[1,599,600,601,602]],[[1,603,604,605,606]],[[1,607,608,609,610]],[[1,611,612,86,613]],[[1,614,615,616,617]],[[1,618,619,620,621]],[[1,622,623,624,625]],[[1,626,627,628,629]],[[1,630,631,632,633]],[[1,634,635,636,637]],[[1,638,639,640,641]],[[1,642,643,644,645]],[[1,646,647,648,649]],[[1,650,651,652,653]],[[1,654,655,656,657]],[[1,658,659,660,661]],[[1,662,663,664,665]],[[1,666,667,668,669]],[[1,670,671,672,673]],[[1,674,675,676,677]],[[1,678,679,680,681]],[[1,682,683,684,685]],[[1,686,687,688,689]],[[1,690,691,692,693]],[[1,694,695,696,697]],[[1,698,699,700,701]],[[1,702,703,704,705]],[[1,706,707,708,709]],[[1,710,711,712,713]],[[1,714,715,716,717]],[[1,718,719,720,721]],[[1,722,723,724,725]],[[1,726,727,728,729]],[[1,730,731,732,733]],[[1,734,735,736,737]],[[1,738,739,740,741]],[[1,742,743,744,745]],[[1,746,747,748,749]],[[1,750,751,752,753]],[[1,754,755,756,757]],[[1,758,759,760,761]],[[1,762,763,764,765]],[[1,766,767,768,769]],[[1,770,771,772,773]],[[1,774,775,776,777]],[[1,778,779,780,781]],[[1,782,783,784,785]],[[1,786,787,788,789]],[[1,790,791,792,793]],[[1,794,795,796,797]],[[1,798,799,800,801]],[[1,802,803,804,805]],[[1,806,807,808,809]],[[1,810,811,812,813]],[[1,814,815,816,817]],[[1,818,819,820,821]],[[1,822,823,824,825]],[[1,826,827,828,829]],[[1,830,831,832,833]],[[1,834,835,836,837]],[[1,838,839,840,841]],[[1,842,843,844,845]],[[1,846,847,848,849]],[[1,850,851,852,853]],[[1,854,855,856,857]],[[1,858,859,860,861]],[[1,862,863,864,865]],[[1,866,867,868,869]],[[1,870,871,872,873]],[[1,874,875,876,877]],[[1,878,879,880,881]],[[1,882,883,884,885]],[[1,886,887,888,889]],[[1,890,891,892,893]],[[1,894,895,896,897]],[[1,898,899,900,901]],[[1,902,903,904,905]],[[1,906,907,908,909]],[[1,910,911,912,913]],[[1,914,915,916,917]],[[1,918,919,920,921]],[[1,922,923,924,925]],[[1,926,927,928,929]],[[1,930,931,932,933]],[[1,934,935,936,937]],[[1,938,939,940,941]],[[1,942,943,944,945]],[[1,946,947,948,949]],[[1,950,951,952,953]],[[1,954,955,956,957]],[[1,958,959,960,961]],[[1,962,963,964,965]],[[1,966,967,968,969]],[[1,970,971,972,973]],[[1,974,975,976,977]],[[1,978,979,980,981]],[[1,982,983,984,985]],[[1,986,987,988]],[[1,989,990,991]],[[1,992,993,994]],[[1,995,996,997]],[[1,998,999,1000]],[[1,1001,1002,999]],[[1,1003,1004,1002]],[[1,1005,1006,1004]],[[1,986,1007,1008]],[[1,989,990,991]],[[1,992,993,994]],[[1,995,1009,997]],[[1,998,1010,1011]],[[1,1001,1012,1013]],[[1,1003,1004,1012]],[[1,1005,1014,1004]]]}}},{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[16,17,4,3]],[[17,18,5,4]],[[18,19,6,5]],[[19,20,7,6]],[[21,22,9,8]],[[22,23,10,9]],[[23,24,11,10]],[[24,25,12,11]],[[25,26,13,12]],[[26,27,14,13]],[[27,28,15,14]],[[28,29,0,15]],[[29,30,1,0]],[[30,31,2,1]],[[31,16,3,2]],[[19,18,17,16,31,30,29,28,27,26,25,24,23,22,21,20]],[[20,21,8,7]],[[32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]],[[48,49,36,35]],[[49,50,37,36]],[[50,51,38,37]],[[51,52,39,38]],[[52,53,40,39]],[[53,54,41,40]],[[54,55,42,41]],[[55,56,43,42]],[[56,57,44,43]],[[57,58,45,44]],[[58,59,46,45]],[[59,60,47,46]],[[60,61,32,47]],[[61,62,33,32]],[[62,63,34,33]],[[63,48,35,34]],[[51,50,49,48,63,62,61,60,59,58,57,56,55,54,53,52]]],"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[0,24,25,26,27]],[[0,28,29,30,31]],[[0,32,33,34,35]],[[0,36,37,38,39]],[[0,40,41,42,43]],[[0,44,45,46,47]],[[0,48,49,50,51]],[[0,52,53,54,55]],[[0,56,57,58,59]],[[0,60,61,62,63]],[[0,64,65,66,67]],[[0,68,69,70,71]],[[0,72,73,74,75]],[[null]],[[0,76,77,78,79]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]]]}}}],"vertices-templates":[[0.112,0.121,0.502],[0.107,0.121,0.502],[0.103,0.119,0.502],[0.1,0.116,0.502],[0.099,0.112,0.502],[0.099,0.107,0.502],[0.101,0.103,0.502],[0.104,0.1,0.502],[0.108,0.099,0.502],[0.112,0.099,0.502],[0.116,0.101,0.502],[0.119,0.104,0.502],[0.121,0.108,0.502],[0.121,0.112,0.502],[0.119,0.116,0.502],[0.116,0.119,0.502],[0.016,0.17,0.13],[0.0,0.13,0.13],[0.001,0.086,0.13],[0.018,0.046,0.13],[0.049,0.016,0.13],[0.089,0.0,0.13],[0.133,0.001,0.13],[0.173,0.018,0.13],[0.203,0.049,0.13],[0.219,0.089,0.13],[0.219,0.133,0.13],[0.202,0.173,0.13],[0.17,0.203,0.13],[0.13,0.219,0.13],[0.086,0.219,0.13],[0.046,0.202,0.13],[0.112,0.124,0.186],[0.107,0.124,0.186],[0.101,0.122,0.186],[0.097,0.118,0.186],[0.095,0.112,0.186],[0.095,0.107,0.186],[0.097,0.101,0.186],[0.102,0.097,0.186],[0.107,0.095,0.186],[0.113,0.095,0.186],[0.118,0.097,0.186],[0.122,0.102,0.186],[0.124,0.107,0.186],[0.124,0.113,0.186],[0.122,0.118,0.186],[0.118,0.122,0.186],[0.091,0.122,0.0],[0.088,0.114,0.0],[0.088,0.105,0.0],[0.091,0.097,0.0],[0.098,0.091,0.0],[0.106,0.088,0.0],[0.114,0.088,0.0],[0.122,0.091,0.0],[0.128,0.098,0.0],[0.132,0.106,0.0],[0.132,0.114,0.0],[0.128,0.122,0.0],[0.122,0.128,0.0],[0.114,0.132,0.0],[0.105,0.132,0.0],[0.097,0.128,0.0],[0.115,0.127,0.188],[0.109,0.127,0.188],[0.104,0.125,0.188],[0.1,0.121,0.188],[0.098,0.115,0.188],[0.098,0.109,0.188],[0.1,0.104,0.188],[0.104,0.1,0.188],[0.11,0.098,0.188],[0.116,0.098,0.188],[0.121,0.1,0.188],[0.125,0.104,0.188],[0.127,0.11,0.188],[0.127,0.116,0.188],[0.125,0.121,0.188],[0.121,0.125,0.188],[0.094,0.125,0.0],[0.09,0.117,0.0],[0.09,0.108,0.0],[0.094,0.1,0.0],[0.1,0.094,0.0],[0.108,0.09,0.0],[0.117,0.09,0.0],[0.125,0.094,0.0],[0.131,0.1,0.0],[0.135,0.108,0.0],[0.135,0.117,0.0],[0.131,0.125,0.0],[0.125,0.131,0.0],[0.117,0.135,0.0],[0.108,0.135,0.0],[0.1,0.131,0.0],[0.004,0.144,0.301],[0.006,0.143,0.33],[0.008,0.151,0.327],[0.01,0.158,0.321],[0.012,0.162,0.312],[0.012,0.164,0.301],[0.012,0.162,0.29],[0.01,0.158,0.281],[0.008,0.151,0.274],[0.006,0.143,0.272],[0.004,0.135,0.274],[0.002,0.128,0.281],[0.0,0.123,0.29],[0.0,0.122,0.301],[0.0,0.123,0.312],[0.002,0.128,0.321],[0.004,0.135,0.327],[0.012,0.141,0.357],[0.017,0.157,0.353],[0.021,0.171,0.341],[0.023,0.18,0.322],[0.024,0.183,0.301],[0.023,0.18,0.279],[0.021,0.171,0.261],[0.017,0.157,0.249],[0.012,0.141,0.245],[0.008,0.125,0.249],[0.004,0.112,0.261],[0.001,0.103,0.279],[0.0,0.1,0.301],[0.001,0.103,0.322],[0.004,0.112,0.341],[0.008,0.125,0.353],[0.022,0.138,0.382],[0.029,0.161,0.376],[0.034,0.181,0.359],[0.038,0.194,0.332],[0.04,0.199,0.301],[0.038,0.194,0.27],[0.034,0.181,0.243],[0.029,0.161,0.226],[0.022,0.138,0.219],[0.016,0.115,0.226],[0.01,0.096,0.243],[0.006,0.083,0.27],[0.005,0.078,0.301],[0.006,0.083,0.332],[0.01,0.096,0.359],[0.016,0.115,0.376],[0.036,0.134,0.405],[0.044,0.164,0.397],[0.051,0.189,0.374],[0.056,0.205,0.341],[0.058,0.211,0.301],[0.056,0.205,0.261],[0.051,0.189,0.228],[0.044,0.164,0.205],[0.036,0.134,0.197],[0.027,0.105,0.205],[0.02,0.08,0.228],[0.015,0.064,0.261],[0.014,0.058,0.301],[0.015,0.064,0.341],[0.02,0.08,0.374],[0.027,0.105,0.397],[0.052,0.13,0.423],[0.062,0.164,0.414],[0.07,0.194,0.387],[0.076,0.213,0.348],[0.078,0.22,0.301],[0.076,0.213,0.254],[0.07,0.194,0.215],[0.062,0.164,0.188],[0.052,0.13,0.179],[0.042,0.095,0.188],[0.034,0.066,0.215],[0.028,0.046,0.254],[0.026,0.04,0.301],[0.028,0.046,0.348],[0.034,0.066,0.387],[0.042,0.095,0.414],[0.071,0.124,0.436],[0.082,0.163,0.426],[0.091,0.195,0.397],[0.097,0.217,0.353],[0.1,0.225,0.301],[0.097,0.217,0.249],[0.091,0.195,0.205],[0.082,0.163,0.176],[0.071,0.124,0.165],[0.06,0.086,0.176],[0.051,0.053,0.205],[0.044,0.032,0.249],[0.042,0.024,0.301],[0.044,0.032,0.353],[0.051,0.053,0.397],[0.06,0.086,0.426],[0.091,0.119,0.445],[0.103,0.159,0.434],[0.113,0.194,0.403],[0.119,0.217,0.356],[0.122,0.225,0.301],[0.119,0.217,0.246],[0.113,0.194,0.199],[0.103,0.159,0.168],[0.091,0.119,0.157],[0.08,0.078,0.168],[0.07,0.043,0.199],[0.063,0.02,0.246],[0.061,0.012,0.301],[0.063,0.02,0.356],[0.07,0.043,0.403],[0.08,0.078,0.434],[0.112,0.112,0.448],[0.124,0.154,0.436],[0.134,0.189,0.405],[0.141,0.213,0.357],[0.144,0.221,0.301],[0.141,0.213,0.245],[0.134,0.189,0.197],[0.124,0.154,0.165],[0.112,0.112,0.154],[0.101,0.071,0.165],[0.091,0.036,0.197],[0.084,0.012,0.245],[0.081,0.004,0.301],[0.084,0.012,0.357],[0.091,0.036,0.405],[0.101,0.071,0.436],[0.134,0.106,0.445],[0.145,0.147,0.434],[0.155,0.182,0.403],[0.162,0.205,0.356],[0.164,0.213,0.301],[0.162,0.205,0.246],[0.155,0.182,0.199],[0.145,0.147,0.168],[0.134,0.106,0.157],[0.122,0.066,0.168],[0.112,0.031,0.199],[0.106,0.008,0.246],[0.103,0.0,0.301],[0.106,0.008,0.356],[0.112,0.031,0.403],[0.122,0.066,0.434],[0.154,0.101,0.436],[0.165,0.139,0.426],[0.174,0.171,0.397],[0.18,0.193,0.353],[0.183,0.201,0.301],[0.18,0.193,0.249],[0.174,0.171,0.205],[0.165,0.139,0.176],[0.154,0.101,0.165],[0.143,0.062,0.176],[0.134,0.03,0.205],[0.127,0.008,0.249],[0.125,0.0,0.301],[0.127,0.008,0.353],[0.134,0.03,0.397],[0.143,0.062,0.426],[0.173,0.095,0.423],[0.183,0.13,0.414],[0.191,0.159,0.387],[0.197,0.179,0.348],[0.199,0.185,0.301],[0.197,0.179,0.254],[0.191,0.159,0.215],[0.183,0.13,0.188],[0.173,0.095,0.179],[0.163,0.061,0.188],[0.154,0.031,0.215],[0.149,0.012,0.254],[0.147,0.005,0.301],[0.149,0.012,0.348],[0.154,0.031,0.387],[0.163,0.061,0.414],[0.189,0.091,0.405],[0.198,0.12,0.397],[0.205,0.145,0.374],[0.209,0.161,0.341],[0.211,0.167,0.301],[0.209,0.161,0.261],[0.205,0.145,0.228],[0.198,0.12,0.205],[0.189,0.091,0.197],[0.181,0.061,0.205],[0.174,0.036,0.228],[0.169,0.02,0.261],[0.167,0.014,0.301],[0.169,0.02,0.341],[0.174,0.036,0.374],[0.181,0.061,0.397],[0.203,0.087,0.382],[0.209,0.11,0.376],[0.215,0.129,0.359],[0.219,0.142,0.332],[0.22,0.147,0.301],[0.219,0.142,0.27],[0.215,0.129,0.243],[0.209,0.11,0.226],[0.203,0.087,0.219],[0.196,0.064,0.226],[0.19,0.044,0.243],[0.187,0.031,0.27],[0.185,0.026,0.301],[0.187,0.031,0.332],[0.19,0.044,0.359],[0.196,0.064,0.376],[0.213,0.084,0.357],[0.217,0.1,0.353],[0.221,0.113,0.341],[0.224,0.122,0.322],[0.225,0.125,0.301],[0.224,0.122,0.279],[0.221,0.113,0.261],[0.217,0.1,0.249],[0.213,0.084,0.245],[0.208,0.068,0.249],[0.204,0.054,0.261],[0.202,0.045,0.279],[0.201,0.042,0.301],[0.202,0.045,0.322],[0.204,0.054,0.341],[0.208,0.068,0.353],[0.219,0.082,0.33],[0.221,0.09,0.327],[0.223,0.097,0.321],[0.224,0.102,0.312],[0.225,0.103,0.301],[0.224,0.102,0.29],[0.223,0.097,0.281],[0.221,0.09,0.274],[0.219,0.082,0.272],[0.217,0.074,0.274],[0.215,0.067,0.281],[0.213,0.062,0.29],[0.213,0.061,0.301],[0.213,0.062,0.312],[0.215,0.067,0.321],[0.217,0.074,0.327],[0.221,0.081,0.301]]},"extensions":{}} +{"type":"CityJSONFeature","id":"GMLID_0598627_75956_700","CityObjects":{"GMLID_0598627_75956_700":{"type":"GenericCityObject","geometry":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26]],[[27,28,29,30]],[[1,27,30]],[[31,32,28,27]],[[33,34,32,31]],[[31,0,26,33]],[[0,31,27,1]],[[35,36,34,33]],[[25,35,33,26]],[[24,37,35,25]],[[37,38,36,35]],[[39,40,38,37]],[[41,42,40,39]],[[43,44,42,41]],[[45,46,44,43]],[[47,48,46,45]],[[49,50,48,47]],[[51,52,50,49]],[[53,54,52,51]],[[55,56,54,53]],[[57,58,56,55]],[[59,60,58,57]],[[57,14,13,59]],[[55,15,14,57]],[[53,16,15,55]],[[51,17,16,53]],[[49,18,17,51]],[[47,19,18,49]],[[45,20,19,47]],[[43,21,20,45]],[[41,22,21,43]],[[23,39,37,24]],[[39,23,22,41]],[[61,1,30]],[[62,61,30,29]],[[63,64,60,59]],[[12,63,59,13]],[[65,66,64,63]],[[67,68,66,65]],[[69,70,68,67]],[[71,72,70,69]],[[73,74,72,71]],[[75,76,74,73]],[[77,78,76,75]],[[79,80,78,77]],[[81,82,80,79]],[[83,84,82,81]],[[84,83,61,62]],[[11,65,63,12]],[[10,67,65,11]],[[9,69,67,10]],[[8,71,69,9]],[[7,73,71,8]],[[6,75,73,7]],[[5,77,75,6]],[[4,79,77,5]],[[3,81,79,4]],[[2,83,81,3]],[[83,2,1,61]]],"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26]],[[0,27,28,29,30]],[[0,31,32,33]],[[0,34,35,36,37]],[[0,38,39,40,41]],[[0,42,43,44,45]],[[0,46,47,48,49]],[[0,50,51,52,53]],[[0,54,55,56,57]],[[0,58,59,60,61]],[[0,62,63,64,65]],[[0,66,67,68,69]],[[0,70,71,72,73]],[[0,74,75,76,77]],[[0,78,79,80,81]],[[0,82,83,84,85]],[[0,86,87,88,89]],[[0,90,91,92,93]],[[0,94,95,96,97]],[[0,98,99,100,101]],[[0,102,103,104,105]],[[0,106,107,108,109]],[[0,110,111,112,113]],[[0,114,115,116,117]],[[0,118,119,120,121]],[[0,122,123,124,125]],[[0,126,127,128,129]],[[0,130,131,132,133]],[[0,134,135,136,137]],[[0,138,139,140,141]],[[0,142,143,144,145]],[[0,146,147,148,149]],[[0,150,151,152,153]],[[0,154,155,156]],[[0,157,158,159,160]],[[0,161,162,163,164]],[[0,165,166,167,168]],[[0,169,170,171,172]],[[0,173,174,175,176]],[[0,177,178,179,180]],[[0,181,182,183,184]],[[0,185,186,187,188]],[[0,189,190,191,192]],[[0,193,194,195,196]],[[0,197,198,199,200]],[[0,201,202,203,204]],[[0,205,206,207,208]],[[0,209,210,211,212]],[[0,213,214,215,216]],[[0,217,218,219,220]],[[0,221,222,223,224]],[[0,225,226,227,228]],[[0,229,230,231,232]],[[0,233,234,235,236]],[[0,237,238,239,240]],[[0,241,242,243,244]],[[0,245,246,247,248]],[[0,249,250,251,252]],[[0,253,254,255,256]]]}}}]}},"vertices":[[4122,3975,419],[4121,3977,419],[4122,3978,419],[4125,3981,419],[4129,3983,419],[4132,3986,419],[4135,3988,419],[4140,3989,419],[4144,3991,419],[4148,3993,419],[4151,3994,419],[4157,3994,419],[4161,3994,419],[4201,3994,419],[4202,3988,419],[4202,3965,419],[4201,3959,419],[4161,3959,419],[4157,3959,419],[4151,3959,419],[4148,3961,419],[4144,3962,419],[4140,3964,419],[4135,3965,419],[4132,3967,419],[4129,3970,419],[4125,3972,419],[4116,3975,424],[4114,3973,434],[4111,3977,434],[4114,3977,424],[4119,3972,424],[4116,3970,434],[4122,3969,424],[4121,3965,434],[4125,3965,424],[4125,3964,434],[4130,3962,424],[4129,3961,434],[4134,3961,424],[4132,3959,434],[4138,3959,424],[4137,3957,434],[4143,3957,424],[4141,3954,434],[4147,3956,424],[4146,3954,434],[4151,3954,424],[4151,3953,434],[4156,3954,424],[4156,3951,434],[4161,3954,424],[4161,3951,434],[4204,3954,424],[4205,3951,434],[4208,3965,424],[4211,3965,434],[4208,3988,424],[4211,3989,434],[4204,3999,424],[4205,4002,434],[4116,3978,424],[4114,3981,434],[4161,3999,424],[4161,4002,434],[4156,3999,424],[4156,4002,434],[4151,3999,424],[4151,4001,434],[4147,3998,424],[4146,3999,434],[4143,3996,424],[4141,3999,434],[4138,3994,424],[4137,3996,434],[4134,3993,424],[4132,3994,434],[4130,3991,424],[4129,3993,434],[4125,3988,424],[4125,3989,434],[4122,3985,424],[4121,3988,434],[4119,3981,424],[4116,3983,434]],"appearance":{"materials":[],"textures":[{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Wood_Cherry_Original.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"}],"vertices-texture":[[-0.0064096,0.0126916],[-0.0054856,0.0136702],[-0.0064096,0.0146487],[-0.0081251,0.0162031],[-0.0099563,0.0176194],[-0.0118921,0.0188889],[-0.0139208,0.020004],[-0.0160301,0.0209579],[-0.0182072,0.0217449],[-0.0204389,0.02236],[-0.0227117,0.0227998],[-0.0250118,0.0230613],[-0.0274145,0.0231463],[-0.0491072,0.0231463],[-0.0504867,0.0196976],[-0.0504867,0.0076428],[-0.0491072,0.004194],[-0.0274145,0.004194],[-0.0250118,0.004279],[-0.0227117,0.0045406],[-0.0204389,0.0049803],[-0.0182072,0.0055955],[-0.0160301,0.0063824],[-0.0139208,0.0073363],[-0.0118921,0.0084514],[-0.0099563,0.0097209],[-0.0081251,0.0111372],[-0.008056,-0.0105787],[-0.0079976,-0.0049084],[-0.0106393,-0.0049084],[-0.0094285,-0.0105787],[-0.0071948,-0.0168297],[-0.008056,-0.0129386],[-0.0094285,-0.0129386],[-0.0047224,-0.0107364],[-0.0046639,-0.0050662],[-0.0073056,-0.0050662],[-0.0072472,-0.0107364],[-0.0013524,-0.0108251],[-0.001294,-0.0051548],[-0.0039357,-0.0051548],[-0.0038772,-0.0108251],[-0.0038772,-0.0135736],[-0.0037723,-0.0174113],[-0.0014573,-0.0174113],[-0.0013524,-0.0135736],[-0.0048273,-0.0171763],[-0.0047224,-0.0133385],[-0.0072472,-0.0133385],[-0.0061731,-0.0171763],[0.0020334,-0.0108441],[0.0020918,-0.0051738],[-0.0005499,-0.0051738],[-0.0004914,-0.0108441],[0.0019285,-0.0174618],[0.0020334,-0.013624],[-0.0004914,-0.013624],[-0.0003865,-0.0174618],[0.0053096,-0.0173272],[0.0054145,-0.0134894],[0.0028897,-0.0134894],[0.0029946,-0.0173272],[0.0054145,-0.0107934],[0.005473,-0.0051231],[0.0028312,-0.0051231],[0.0028897,-0.0107934],[0.0087704,-0.0106731],[0.0088289,-0.0050029],[0.0061872,-0.0050029],[0.0062457,-0.0106731],[0.0120809,-0.0104842],[0.0121394,-0.0048139],[0.0094976,-0.0048139],[0.0095561,-0.0104842],[0.0153258,-0.0102277],[0.0153842,-0.0045574],[0.0127425,-0.0045574],[0.012801,-0.0102277],[0.0184854,-0.0099051],[0.0185439,-0.0042348],[0.0159022,-0.0042348],[0.0159606,-0.0099051],[0.0215407,-0.0095184],[0.0215992,-0.0038482],[0.0189575,-0.0038482],[0.0190159,-0.0095184],[0.0244731,-0.0090701],[0.0245315,-0.0033998],[0.0218898,-0.0033998],[0.0219483,-0.0090701],[0.0272967,-0.0085627],[0.0273232,-0.0028924],[0.0246815,-0.0028924],[0.02474,-0.0085627],[0.0509306,-0.0083068],[0.0519466,-0.0026365],[0.0273403,-0.0026365],[0.0273669,-0.0083068],[0.0263651,0.0044587],[0.0266541,0.0101289],[0.0192925,0.0101289],[0.0203086,0.0044587],[0.0202162,0.0061656],[0.0205052,0.0118359],[0.0068351,0.0118359],[0.0071241,0.0061656],[0.0050763,0.0071461],[0.0060924,0.0128164],[-0.0012692,0.0128164],[-0.0009802,0.0071461],[-0.0009802,0.0340767],[-0.0004616,0.0302389],[0.0032529,0.0302389],[0.0050763,0.0340767],[0.0071241,0.0314769],[0.0076428,0.0276392],[0.0196976,0.0276392],[0.0202162,0.0314769],[0.0203086,0.0269509],[0.022132,0.0231131],[0.0258464,0.0231131],[0.0263651,0.0269509],[0.0273669,-0.0068964],[0.0274145,-0.0107342],[0.0491072,-0.0107342],[0.0509306,-0.0068964],[0.02474,-0.0075748],[0.0248449,-0.0114126],[0.0272491,-0.0114126],[0.0272967,-0.0075748],[0.0219483,-0.0089201],[0.0220532,-0.0127579],[0.0243682,-0.0127579],[0.0244731,-0.0089201],[0.0190159,-0.010109],[0.0191208,-0.0139468],[0.0214358,-0.0139468],[0.0215407,-0.010109],[0.0159606,-0.0111342],[0.0160656,-0.0149719],[0.0183805,-0.0149719],[0.0184854,-0.0111342],[0.012801,-0.0119895],[0.0129059,-0.0158272],[0.0152209,-0.0158272],[0.0153258,-0.0119895],[0.0086655,-0.0170084],[0.0087704,-0.0131706],[0.0062457,-0.0131706],[0.0063506,-0.0170084],[0.0095561,-0.0126697],[0.009661,-0.0165074],[0.011976,-0.0165074],[0.0120809,-0.0126697],[-0.0132225,-0.0007235],[-0.0140838,-0.0046145],[-0.0118501,-0.0007235],[-0.013281,-0.0003647],[-0.0132225,-0.006035],[-0.0118501,-0.006035],[-0.0106393,-0.0003647],[-0.0273669,-0.0010706],[-0.0273403,0.0045997],[-0.0519466,0.0045997],[-0.0509306,-0.0010706],[-0.0274145,0.0084525],[-0.0273669,0.0122903],[-0.0509306,0.0122903],[-0.0491072,0.0084525],[-0.0257067,-0.001331],[-0.0256482,0.0043393],[-0.0282899,0.0043393],[-0.0282634,-0.001331],[-0.0250376,-0.0018802],[-0.0249791,0.0037901],[-0.0276208,0.0037901],[-0.0275623,-0.0018802],[-0.024209,-0.002414],[-0.0241506,0.0032563],[-0.0267923,0.0032563],[-0.0267338,-0.002414],[-0.0232261,-0.002929],[-0.0231677,0.0027412],[-0.0258094,0.0027412],[-0.0257509,-0.002929],[-0.0220948,-0.0034223],[-0.0220364,0.0022479],[-0.0246781,0.0022479],[-0.0246196,-0.0034223],[-0.0208219,-0.0038909],[-0.0207635,0.0017794],[-0.0234052,0.0017794],[-0.0233467,-0.0038909],[-0.0194152,-0.0043317],[-0.0193568,0.0013385],[-0.0219985,0.0013385],[-0.02194,-0.0043317],[-0.0178832,-0.0047423],[-0.0178247,0.000928],[-0.0204664,0.000928],[-0.020408,-0.0047423],[-0.0162351,-0.0051201],[-0.0161767,0.0005502],[-0.0188184,0.0005502],[-0.0187599,-0.0051201],[-0.014481,-0.0054628],[-0.0144225,0.0002074],[-0.0170643,0.0002074],[-0.0170058,-0.0054628],[-0.0152147,-0.0000981],[-0.0151563,-0.0057684],[-0.0126315,-0.0057684],[-0.012573,-0.0000981],[-0.0258116,0.0077621],[-0.0257067,0.0115999],[-0.0282634,0.0115999],[-0.0282158,0.0077621],[-0.0251425,0.0063059],[-0.0250376,0.0101437],[-0.0275623,0.0101437],[-0.0274574,0.0063059],[-0.0243139,0.0048906],[-0.024209,0.0087284],[-0.0267338,0.0087284],[-0.0266289,0.0048906],[-0.023331,0.0035248],[-0.0232261,0.0073626],[-0.0257509,0.0073626],[-0.025646,0.0035248],[-0.0221997,0.0022169],[-0.0220948,0.0060547],[-0.0246196,0.0060547],[-0.0245147,0.0022169],[-0.0209269,0.0009746],[-0.0208219,0.0048124],[-0.0233467,0.0048124],[-0.0232418,0.0009746],[-0.0195201,-0.0001943],[-0.0194152,0.0036434],[-0.02194,0.0036434],[-0.0218351,-0.0001943],[-0.0179881,-0.001283],[-0.0178832,0.0025548],[-0.020408,0.0025548],[-0.0203031,-0.001283],[-0.01634,-0.0022847],[-0.0162351,0.0015531],[-0.0187599,0.0015531],[-0.018655,-0.0022847],[-0.0145859,-0.0031934],[-0.014481,0.0006443],[-0.0170058,0.0006443],[-0.0169009,-0.0031934],[-0.0151563,-0.0001659],[-0.0150514,-0.0040036],[-0.0137055,-0.0040036],[-0.0126315,-0.0001659]]}} +{"type":"CityJSONFeature","id":"GMLID_BUI100628_817_8083","CityObjects":{"GMLID_BUI100628_817_8083":{"type":"Bridge","geometry":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3]],[[4,5,1,0]],[[6,7,5,4]],[[3,2,7,6]],[[1,5,7,2]],[[4,0,3,6]],[[8,9,10,11]],[[12,13,14,15]],[[11,10,12,15]],[[10,9,13,12]],[[9,8,14,13]],[[8,11,15,14]],[[6,4,16,17]],[[18,19,20,21]],[[17,16,18,21]],[[16,4,19,18]],[[4,6,20,19]],[[6,17,21,20]],[[22,23,24,25]],[[26,27,28,29]],[[25,24,26,29]],[[24,23,27,26]],[[23,22,28,27]],[[22,25,29,28]],[[13,30,31,14]],[[32,33,30,13]],[[34,35,33,32]],[[14,31,35,34]],[[30,33,35,31]],[[32,13,14,34]]],"texture":{"visual":{"values":[[[0,0,1,2,3]],[[0,4,5,6,7]],[[0,8,9,10,11]],[[0,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[1,24,25,26,27]],[[1,28,29,30,31]],[[1,32,33,34,35]],[[1,36,37,38,39]],[[1,40,41,42,43]],[[1,44,45,46,47]],[[1,48,49,50,51]],[[1,52,53,54,55]],[[1,56,57,58,59]],[[1,60,61,62,63]],[[1,64,65,54,66]],[[1,67,68,69,54]],[[1,70,71,72,73]],[[1,74,75,54,76]],[[1,77,78,79,80]],[[1,81,82,83,84]],[[1,85,86,54,87]],[[1,88,89,90,54]],[[0,91,92,93,94]],[[0,95,96,97,98]],[[0,99,100,101,102]],[[0,103,104,105,106]],[[0,107,108,109,110]],[[0,111,112,113,114]]]}}}]}},"vertices":[[6016,5499,1032],[6016,5499,1052],[5064,6048,1069],[5063,6049,1049],[6045,5551,1032],[6047,5551,1052],[5093,6101,1049],[5093,6100,1069],[4959,5867,1051],[5911,5317,1033],[5912,5317,1103],[4959,5866,1120],[5917,5325,1103],[5916,5326,1033],[4964,5875,1051],[4965,5875,1120],[6047,5551,1101],[5093,6100,1120],[6052,5559,1101],[6051,5560,1032],[5098,6109,1049],[5099,6109,1120],[4983,5910,1030],[5936,5361,1013],[5936,5361,1033],[4984,5910,1051],[6026,5517,1032],[6026,5517,1011],[5073,6066,1030],[5073,6066,1049],[5917,5326,1053],[4964,5875,1071],[5946,5378,1032],[5946,5378,1052],[4994,5927,1051],[4994,5927,1071]],"appearance":{"materials":[],"textures":[{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Bruecke-Schotter.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"},{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Bruecke-Beton.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"}],"vertices-texture":[[2.3283218,0.8466667],[2.3283218,0.7620004],[-2.3283218,0.7620004],[-2.3283218,0.8466667],[0.5503364,0.8466821],[0.5503368,0.762016],[0.2963381,0.7620159],[0.2963381,0.8466822],[-2.32832,0.8465672],[-2.32832,0.761901],[2.3283238,0.7619011],[2.3283236,0.8465672],[0.2963319,0.8466667],[0.2963319,0.7620004],[0.5503306,0.7620004],[0.5503306,0.8466667],[2.3283218,0.5503348],[2.3283218,0.2963361],[-2.3283218,0.2963361],[-2.3283218,0.5503348],[2.3283276,0.2963172],[2.3283275,0.5503155],[-2.3283161,0.5503103],[-2.3283158,0.2963116],[-4e-7,8e-7],[9.166648,8e-7],[9.1666487,0.2916659],[1.6e-6,0.2916667],[-9.1666473,0.2916635],[-9.1666493,-2.9e-6],[-1.3e-6,-3e-6],[-6e-7,0.2916641],[2.3333208,1e-6],[2.3333193,4.5833245],[2.2499856,4.5833237],[2.2499863,3e-7],[-2.3333603,0.2916602],[-2.33336,-4.9e-6],[-2.2500279,-5.5e-6],[-2.2500267,0.2916608],[-2.3333277,4.5833242],[-2.3333291,0.0],[-2.2499961,7e-7],[-2.2499956,4.5833247],[2.3333291,0.0],[2.3333296,0.2916658],[2.249995,0.2916664],[2.2499961,-7e-7],[1.6e-6,-7e-7],[9.1666497,-6e-7],[9.1666477,0.2916657],[6e-7,0.2916665],[-9.1666487,0.2916652],[-9.1666484,0.0],[0.0,0.0],[-2e-6,0.2916658],[0.0833419,3e-7],[0.0833404,4.5833238],[8.3e-6,4.5833243],[9e-6,1e-6],[-0.0833447,0.2916594],[-0.083346,-6.9e-6],[-0.0000114,-6.3e-6],[-0.0000126,0.2916589],[-0.0833332,4.5833248],[-0.0833337,8e-7],[1.4e-6,4.5833242],[0.0833337,-7e-7],[0.0833335,0.2916665],[5e-7,0.2916658],[9e-7,-8e-7],[9.1666493,-9e-7],[9.1666488,0.0833325],[4e-7,0.0833326],[-9.1666505,0.0833321],[-9.166648,0.0],[-2.2e-6,0.0833322],[1.4999986,-3e-7],[1.5000043,4.5833239],[7.4e-6,4.5833253],[1.7e-6,1.1e-6],[-1.4999969,0.0833605],[-1.4999971,0.0000271],[5e-7,0.0000272],[0.0,0.0833593],[-1.5000042,4.5833242],[-1.4999985,0.0],[-6.6e-6,4.583324],[1.4999985,0.0],[1.4999983,0.0833334],[1.4e-6,0.0833322],[2.3283199,0.8465701],[2.3283201,0.761904],[-2.3283236,0.761904],[-2.3283236,0.8465702],[-0.2963256,0.8466821],[-0.2963256,0.7620159],[-0.5503239,0.762016],[-0.5503243,0.8466821],[-2.3283217,0.8466546],[-2.3283215,0.7619885],[2.3283221,0.7619885],[2.3283221,0.8466547],[-0.5503223,0.8466469],[-0.5503227,0.7619806],[-0.296324,0.7619806],[-0.296324,0.8466468],[2.3283264,1.3969773],[2.3283265,1.142979],[-2.3283171,1.1429739],[-2.3283174,1.3969726],[2.3283269,1.1429791],[2.3283266,1.3969779],[-2.3283169,1.3969723],[-2.3283168,1.142974]]}} +{"type":"CityJSONFeature","id":"UUID_f488e8ce-b953-4b35-a3fe-a394fb203868","CityObjects":{"GMLID_SO0200811_525_4986":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[0],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0158953_776_12462":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[2],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0260143_1214_1315":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[10],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0443764_1492_9667":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[13],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0124800_3522_13577":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[12],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0400934_2926_3381":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[11],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO015374_872_14131":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[1],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"UUID_f488e8ce-b953-4b35-a3fe-a394fb203868":{"type":"CityObjectGroup","children":["GMLID_SO0200811_525_4986","GMLID_SO015374_872_14131","GMLID_SO0158953_776_12462","GMLID_SO0321978_1466_7441","GMLID_SO0286258_965_2893","GMLID_SO0410128_214_10515","GMLID_SO0283142_1335_8999","GMLID_SO086970_1053_8814","GMLID_SO0107241_3793_12555","GMLID_SO081792_2341_11961","GMLID_SO0260143_1214_1315","GMLID_SO0400934_2926_3381","GMLID_SO0124800_3522_13577","GMLID_SO0443764_1492_9667"]},"GMLID_SO081792_2341_11961":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[9],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0286258_965_2893":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[4],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0283142_1335_8999":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[6],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0321978_1466_7441":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[3],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0107241_3793_12555":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[8],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0410128_214_10515":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[5],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO086970_1053_8814":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[7],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]}},"vertices":[[560,6240,1271],[134,6188,1370],[754,6706,1370],[240,6400,1373],[1065,6523,1221],[1325,6632,1221],[141,5520,1221],[90,5164,1221],[486,5879,1219],[478,6489,1370],[1654,6712,1221],[837,6480,1261],[159,6800,1521],[744,6163,1219]],"appearance":{"materials":[],"textures":[],"vertices-texture":[]}} +{"type":"CityJSONFeature","id":"GMLID_SO092422_3593_9527","CityObjects":{"GMLID_SO092422_3593_9527":{"type":"SolitaryVegetationObject","attributes":{"function":"LOD3","species":"9999"},"geometry":[{"type":"GeometryInstance","boundaries":[0],"template":2,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}]}},"vertices":[[480,1279,921]],"appearance":{"materials":[],"textures":[],"vertices-texture":[]}} \ No newline at end of file diff --git a/tests/data/inferable_types.city.jsonl b/tests/data/inferable_types.city.jsonl new file mode 100644 index 0000000..a6cbaa1 --- /dev/null +++ b/tests/data/inferable_types.city.jsonl @@ -0,0 +1,2 @@ +{"type": "CityJSON", "version": "2.0", "transform": {"scale": [0.001, 0.001, 0.001], "translate": [0.0, 0.0, 0.0]}, "CityObjects": {}, "vertices": [], "metadata": {"geographicalExtent": [0, 0, 0, 100, 100, 10]}} +{"type": "CityJSONFeature", "id": "t", "CityObjects": {"t": {"type": "Building", "attributes": {"a_bool": true, "a_double": 1.5, "a_long": -42, "a_ulong": 42, "a_string": "hello", "a_json": {"nested": [1, 2, 3]}}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} diff --git a/tests/data/long_strings.city.jsonl b/tests/data/long_strings.city.jsonl new file mode 100644 index 0000000..178072b --- /dev/null +++ b/tests/data/long_strings.city.jsonl @@ -0,0 +1,3 @@ +{"type": "CityJSON", "version": "2.0", "transform": {"scale": [0.001, 0.001, 0.001], "translate": [0.0, 0.0, 0.0]}, "CityObjects": {}, "vertices": [], "metadata": {"geographicalExtent": [0, 0, 0, 100, 100, 10]}} +{"type": "CityJSONFeature", "id": "a", "CityObjects": {"a": {"type": "Building", "attributes": {"label": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyAAA"}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} +{"type": "CityJSONFeature", "id": "b", "CityObjects": {"b": {"type": "Building", "attributes": {"label": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyBBB"}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} diff --git a/tests/data/noise_extension.city.jsonl b/tests/data/noise_extension.city.jsonl new file mode 100644 index 0000000..035e3d0 --- /dev/null +++ b/tests/data/noise_extension.city.jsonl @@ -0,0 +1,4 @@ +{"type":"CityJSON","version":"2.0","transform":{"scale":[0.0,0.0,0.0],"translate":[1.0,1.0,1.0]},"CityObjects":{},"vertices":[],"extensions":{"Noise":{"url":"https://www.cityjson.org/tutorials/files/noise.ext.json","version":"1.1"}}} +{"type":"CityJSONFeature","id":"1234","CityObjects":{"1234":{"type":"Building","attributes":{"+noise-buildingLNightMax":{"uom":"dB","value":43.123},"+noise-buildingReflectionCorrection":{"uom":"dB","value":4.123},"class":"22","roofType":"pointy"},"geometry":[{"type":"Solid","lod":"2","boundaries":[[[[0,1,2,3]],[[4,5,6,7]],[[0,3,5,4]],[[3,2,6,5]],[[2,1,7,6]],[[1,0,4,7]]]]}]}},"vertices":[[0,0,0],[0,10,0],[10,10,10],[10,0,1],[0,0,10],[10,0,10],[10,11,10],[0,10,10]]} +{"type":"CityJSONFeature","id":"onebuilding","CityObjects":{"onebuilding":{"type":"Building","geometry":[{"type":"Solid","lod":"1","boundaries":[[[[0,1,2,3]],[[4,5,6,7]],[[0,3,5,4]],[[3,2,6,5]],[[2,1,7,6]],[[1,0,4,7]]]]},{"type":"Solid","lod":"2","boundaries":[[[[8,9,10,11]],[[12,13,14,15]],[[8,11,13,12]],[[11,10,14,13]],[[10,9,15,14]],[[9,8,12,15]]]]}]}},"vertices":[[0,0,0],[0,10,0],[10,10,10],[10,0,1],[0,0,10],[10,0,10],[10,11,10],[0,10,10],[10,0,0],[10,110,0],[20,10,0],[20,0,0],[10,0,18],[20,0,14],[20,10,11],[10,10,11]]} +{"type":"CityJSONFeature","id":"a_noisy_bench","CityObjects":{"a_noisy_bench":{"type":"CityFurniture","geometry":[{"type":"Solid","lod":"2","boundaries":[[[[0,1,2,3]],[[4,5,6,7]],[[0,3,5,4]],[[3,2,6,5]],[[2,1,7,6]],[[1,0,4,7]]]]}],"children":["thesegment_1","thesegment_2"]},"thesegment_1":{"type":"+NoiseCityFurnitureSegment","attributes":{"reflectionCorrection":{"uom":"dB","value":4.123}},"geometry":[{"type":"MultiLineString","lod":"0","boundaries":[[2,1,5],[7,5,8]]}],"parents":["a_noisy_bench"]},"thesegment_2":{"type":"+NoiseCityFurnitureSegment","attributes":{"reflectionCorrection":{"uom":"dB","value":2.666}},"geometry":[{"type":"MultiLineString","lod":"0","boundaries":[[2,1,5],[7,2,8]]}],"parents":["a_noisy_bench"]}},"vertices":[[0,0,0],[0,10,0],[10,10,10],[10,0,1],[0,0,10],[10,0,10],[10,11,10],[0,10,10],[10,0,18]]} diff --git a/tests/data/single_feature.city.jsonl b/tests/data/single_feature.city.jsonl new file mode 100644 index 0000000..b8edd85 --- /dev/null +++ b/tests/data/single_feature.city.jsonl @@ -0,0 +1,2 @@ +{"type": "CityJSON", "version": "2.0", "transform": {"scale": [0.001, 0.001, 0.001], "translate": [0.0, 0.0, 0.0]}, "CityObjects": {}, "vertices": [], "metadata": {"geographicalExtent": [0, 0, 0, 100, 100, 10]}} +{"type": "CityJSONFeature", "id": "only", "CityObjects": {"only": {"type": "Building", "attributes": {"name": "solo", "n": 1}, "geometry": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2, 3]]]}]}}, "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0], [0, 1000, 0]]} diff --git a/tests/data/small.city.jsonl b/tests/data/small.city.jsonl new file mode 100644 index 0000000..f3056d3 --- /dev/null +++ b/tests/data/small.city.jsonl @@ -0,0 +1,4 @@ +{"type":"CityJSON","version":"2.0","transform":{"scale":[0.001,0.001,0.001],"translate":[85088.390625,446394.25,45.64800262451172]},"CityObjects":{},"vertices":[],"metadata":{"fullMetadataUrl":"https://data.3dbag.nl/metadata/v20240420/metadata.json","geographicalExtent":[84501.5546875,445805.03125,-3.746997833251953,85675.234375,446983.46875,95.04200744628906],"pointOfContact":{"contactName":"3DBAG Team","emailAddress":"info@3dbag.nl","role":"owner","website":"https://3dbag.nl"},"referenceSystem":"https://www.opengis.net/def/crs/EPSG/0/7415","title":"3DBAG","version":"v2024.04.20"}} +{"type":"CityJSONFeature","id":"NL.IMBAG.Pand.0503100000012869","CityObjects":{"NL.IMBAG.Pand.0503100000012869-0":{"type":"BuildingPart","attributes":{},"geometry":[{"type":"Solid","lod":"1.2","boundaries":[[[[1,0,3,2]],[[4,1,2,5]],[[5,2,3,6]],[[6,3,0,7]],[[7,0,1,4]],[[7,4,5,6]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,1]]}},{"type":"Solid","lod":"1.3","boundaries":[[[[1,0,3,2]],[[4,1,2,5]],[[5,2,3,6]],[[6,3,0,7]],[[7,0,1,4]],[[7,4,5,6]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,1]]}},{"type":"Solid","lod":"2.2","boundaries":[[[[1,0,3,2]],[[8,1,2,9]],[[9,2,3,10]],[[10,3,0,11]],[[11,0,1,8]],[[11,8,9,10]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,1]]}}],"parents":["NL.IMBAG.Pand.0503100000012869"]},"NL.IMBAG.Pand.0503100000012869":{"type":"Building","geographicalExtent":[84593.2421875,446459.625,0.47600001096725464,84597.5,446462.78125,3.2257587909698486],"attributes":{"b3_bag_bag_overlap":0.0,"b3_bouwlagen":null,"b3_dak_type":"horizontal","b3_h_dak_50p":3.180000066757202,"b3_h_dak_70p":3.190000057220459,"b3_h_dak_max":3.25,"b3_h_dak_min":3.0999999046325684,"b3_h_maaiveld":0.47600001096725464,"b3_kas_warenhuis":false,"b3_mutatie_ahn3_ahn4":false,"b3_nodata_fractie_ahn3":0.0,"b3_nodata_fractie_ahn4":0.0,"b3_nodata_radius_ahn3":0.2547723054885864,"b3_nodata_radius_ahn4":0.2834213674068451,"b3_opp_buitenmuur":25.71,"b3_opp_dak_plat":7.22,"b3_opp_dak_schuin":0.0,"b3_opp_grond":7.21,"b3_opp_scheidingsmuur":5.13,"b3_puntdichtheid_ahn3":10.133333206176758,"b3_puntdichtheid_ahn4":39.73333358764648,"b3_pw_bron":"AHN4","b3_pw_datum":2020,"b3_pw_selectie_reden":"PREFERRED_AND_LATEST","b3_reconstructie_onvolledig":false,"b3_rmse_lod12":0.03385167941451073,"b3_rmse_lod13":0.03385167941451073,"b3_rmse_lod22":0.023274119943380356,"b3_val3dity_lod12":"[]","b3_val3dity_lod13":"[]","b3_val3dity_lod22":"[]","b3_volume_lod12":19.593151092529297,"b3_volume_lod13":19.593151092529297,"b3_volume_lod22":19.521385192871097,"begingeldigheid":"1994-04-17","documentdatum":"1994-04-17","documentnummer":"314413.tif","eindgeldigheid":null,"eindregistratie":null,"geconstateerd":false,"identificatie":"NL.IMBAG.Pand.0503100000012869","oorspronkelijkbouwjaar":1994,"status":"Pand in gebruik","tijdstipeindregistratielv":null,"tijdstipinactief":null,"tijdstipinactieflv":null,"tijdstipnietbaglv":null,"tijdstipregistratie":"2010-10-13T12:43:04Z","tijdstipregistratielv":"2010-10-13T13:01:02Z","voorkomenidentificatie":1},"geometry":[{"type":"MultiSurface","lod":"0","boundaries":[[[0,1,2,3]]]}],"children":["NL.IMBAG.Pand.0503100000012869-0"]}},"vertices":[[-495141,67105,-45173],[-494406,65353,-45173],[-490883,66773,-45173],[-491621,68524,-45173],[-494406,65353,-42456],[-490883,66773,-42456],[-491621,68524,-42456],[-495141,67105,-42456],[-494406,65353,-42433],[-490883,66773,-42423],[-491621,68524,-42499],[-495141,67105,-42510]]} +{"type":"CityJSONFeature","id":"NL.IMBAG.Pand.0503100000016459","CityObjects":{"NL.IMBAG.Pand.0503100000016459-0":{"type":"BuildingPart","attributes":{},"geometry":[{"type":"Solid","lod":"1.2","boundaries":[[[[3,2,1,0]],[[4,1,2,5]],[[6,0,1,4]],[[7,3,0,6]],[[5,2,3,7]],[[5,7,6,4]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,1]]}},{"type":"Solid","lod":"1.3","boundaries":[[[[3,2,1,0]],[[4,1,2,5]],[[6,0,1,4]],[[7,3,0,6]],[[5,2,3,7]],[[5,7,6,4]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,1]]}},{"type":"Solid","lod":"2.2","boundaries":[[[[3,2,1,0]],[[8,1,2,9]],[[10,0,1,8]],[[11,3,0,10]],[[9,2,3,11]],[[9,11,10,8]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,1]]}}],"parents":["NL.IMBAG.Pand.0503100000016459"]},"NL.IMBAG.Pand.0503100000016459":{"type":"Building","geographicalExtent":[85563.75,446828.09375,-0.3050000071525574,85566.84375,446832.5,2.4213812351226807],"attributes":{"b3_bag_bag_overlap":0.0,"b3_bouwlagen":null,"b3_dak_type":"horizontal","b3_h_dak_50p":2.380000114440918,"b3_h_dak_70p":2.4000000953674316,"b3_h_dak_max":2.490000009536743,"b3_h_dak_min":2.190000057220459,"b3_h_maaiveld":-0.3050000071525574,"b3_kas_warenhuis":false,"b3_mutatie_ahn3_ahn4":false,"b3_nodata_fractie_ahn3":0.0,"b3_nodata_fractie_ahn4":0.0,"b3_nodata_radius_ahn3":0.29476311802864075,"b3_nodata_radius_ahn4":0.28732994198799133,"b3_opp_buitenmuur":28.81,"b3_opp_dak_plat":10.34,"b3_opp_dak_schuin":0.0,"b3_opp_grond":10.34,"b3_opp_scheidingsmuur":6.74,"b3_puntdichtheid_ahn3":8.09756088256836,"b3_puntdichtheid_ahn4":26.829267501831055,"b3_pw_bron":"AHN4","b3_pw_datum":2020,"b3_pw_selectie_reden":"PREFERRED_AND_LATEST","b3_reconstructie_onvolledig":false,"b3_rmse_lod12":0.034421537071466446,"b3_rmse_lod13":0.034421537071466446,"b3_rmse_lod22":0.030110633000731468,"b3_val3dity_lod12":"[]","b3_val3dity_lod13":"[]","b3_val3dity_lod22":"[]","b3_volume_lod12":27.841793060302734,"b3_volume_lod13":27.841793060302734,"b3_volume_lod22":27.802888870239254,"begingeldigheid":"2010-08-24","documentdatum":"2010-08-24","documentnummer":"1098447","eindgeldigheid":null,"eindregistratie":null,"geconstateerd":false,"identificatie":"NL.IMBAG.Pand.0503100000016459","oorspronkelijkbouwjaar":2006,"status":"Pand in gebruik","tijdstipeindregistratielv":null,"tijdstipinactief":null,"tijdstipinactieflv":null,"tijdstipnietbaglv":null,"tijdstipregistratie":"2010-10-13T12:49:44Z","tijdstipregistratielv":"2010-10-13T13:01:25Z","voorkomenidentificatie":1},"geometry":[{"type":"MultiSurface","lod":"0","boundaries":[[[0,1,2,3]]]}],"children":["NL.IMBAG.Pand.0503100000016459-0"]}},"vertices":[[475362,434196,-45952],[477870,433831,-45952],[478457,437859,-45952],[475951,438241,-45952],[477870,433831,-43261],[478457,437859,-43261],[475362,434196,-43261],[475951,438241,-43261],[477870,433831,-43227],[478457,437859,-43272],[475362,434196,-43257],[475951,438241,-43302]]} +{"type":"CityJSONFeature","id":"NL.IMBAG.Pand.0503100000005156","CityObjects":{"NL.IMBAG.Pand.0503100000005156-0":{"type":"BuildingPart","attributes":{},"geometry":[{"type":"Solid","lod":"1.2","boundaries":[[[[6,1,0,5,4,3,7,8]],[[9,5,0,10]],[[10,0,1,11]],[[12,3,4,13]],[[13,4,5,9]],[[14,7,3,12]],[[15,8,7,14]],[[16,6,8,15]],[[11,1,6,16]],[[11,16,15,14,12,13,9,10]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,2,2,2,2,1]]}},{"type":"Solid","lod":"1.3","boundaries":[[[[3,7,8,6,1,17,0,5,4,18]],[[19,5,0,20]],[[21,22,17,1,23]],[[24,7,3,25]],[[26,8,7,24]],[[20,0,17,22]],[[27,28,22,21]],[[29,4,5,19]],[[30,18,4,29]],[[23,1,6,31]],[[25,3,18,30,32]],[[31,6,8,26]],[[33,34,28,27]],[[32,30,34,33]],[[32,33,27,21,23,31,26,24,25]],[[34,30,29,19,20,22,28]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,2,3,2,2,2,2,2,3,3,1,1]]}},{"type":"Solid","lod":"2.2","boundaries":[[[[1,35,17,0,5,4,18,3,7,8,6]],[[36,5,0,37]],[[38,35,1,39]],[[40,7,3,41]],[[42,8,7,40]],[[37,0,17,43]],[[44,45,43,46]],[[47,4,5,36]],[[48,18,4,47]],[[39,1,6,49]],[[41,3,18,48,50]],[[46,43,17,35,38]],[[49,6,8,42]],[[51,52,45,44]],[[53,54,55]],[[54,53,56]],[[50,48,52,51]],[[53,55,38,39,49,42]],[[54,56,44,46,38,55]],[[50,51,44,56,53,42,40,41]],[[52,48,47,36,37,43,45]]]],"semantics":{"surfaces":[{"type":"GroundSurface"},{"type":"RoofSurface"},{"on_footprint_edge":true,"type":"WallSurface"},{"on_footprint_edge":false,"type":"WallSurface"}],"values":[[0,2,2,2,2,2,3,2,2,2,2,2,2,3,3,3,3,1,1,1,1]]}}],"parents":["NL.IMBAG.Pand.0503100000005156"]},"NL.IMBAG.Pand.0503100000005156":{"type":"Building","geographicalExtent":[84734.8046875,446636.5625,0.6919999718666077,84746.9453125,446651.0625,11.119057655334473],"attributes":{"b3_bag_bag_overlap":0.0,"b3_bouwlagen":3,"b3_dak_type":"slanted","b3_h_dak_50p":8.609999656677246,"b3_h_dak_70p":9.239999771118164,"b3_h_dak_max":10.970000267028809,"b3_h_dak_min":3.890000104904175,"b3_h_maaiveld":0.6919999718666077,"b3_kas_warenhuis":false,"b3_mutatie_ahn3_ahn4":false,"b3_nodata_fractie_ahn3":0.002518891589716077,"b3_nodata_fractie_ahn4":0.0,"b3_nodata_radius_ahn3":0.359510600566864,"b3_nodata_radius_ahn4":0.34349295496940613,"b3_opp_buitenmuur":165.03,"b3_opp_dak_plat":51.38,"b3_opp_dak_schuin":63.5,"b3_opp_grond":99.21,"b3_opp_scheidingsmuur":129.53,"b3_puntdichtheid_ahn3":16.353534698486328,"b3_puntdichtheid_ahn4":46.19647216796875,"b3_pw_bron":"AHN4","b3_pw_datum":2020,"b3_pw_selectie_reden":"PREFERRED_AND_LATEST","b3_reconstructie_onvolledig":false,"b3_rmse_lod12":3.2317864894866943,"b3_rmse_lod13":0.642620861530304,"b3_rmse_lod22":0.09925124794244766,"b3_val3dity_lod12":"[]","b3_val3dity_lod13":"[]","b3_val3dity_lod22":"[]","b3_volume_lod12":845.0095825195312,"b3_volume_lod13":657.8263549804688,"b3_volume_lod22":636.9927368164062,"begingeldigheid":"1999-04-28","documentdatum":"1999-04-28","documentnummer":"408040.tif","eindgeldigheid":null,"eindregistratie":null,"geconstateerd":false,"identificatie":"NL.IMBAG.Pand.0503100000005156","oorspronkelijkbouwjaar":2000,"status":"Pand in gebruik","tijdstipeindregistratielv":null,"tijdstipinactief":null,"tijdstipinactieflv":null,"tijdstipnietbaglv":null,"tijdstipregistratie":"2010-10-13T12:29:24Z","tijdstipregistratielv":"2010-10-13T12:30:50Z","voorkomenidentificatie":1},"geometry":[{"type":"MultiSurface","lod":"0","boundaries":[[[0,1,2,3,4,5]]]}],"children":["NL.IMBAG.Pand.0503100000005156-0"]}},"vertices":[[-353581,253246,-44957],[-348730,242291,-44957],[-343550,244604,-44957],[-344288,246257,-44957],[-341437,247537,-44957],[-345635,256798,-44957],[-343558,244600,-44957],[-343662,244854,-44957],[-343926,244734,-44957],[-345635,256798,-36439],[-353581,253246,-36439],[-348730,242291,-36439],[-344288,246257,-36439],[-341437,247537,-36439],[-343662,244854,-36439],[-343926,244734,-36439],[-343558,244600,-36439],[-352596,251020,-44957],[-344083,246349,-44957],[-345635,256798,-41490],[-353581,253246,-41490],[-352596,251020,-35952],[-352596,251020,-41490],[-348730,242291,-35952],[-343662,244854,-35952],[-344288,246257,-35952],[-343926,244734,-35952],[-347233,253386,-35952],[-347233,253386,-41490],[-341437,247537,-41490],[-344083,246349,-41490],[-343558,244600,-35952],[-344083,246349,-35952],[-347089,253741,-35952],[-347089,253741,-41490],[-350613,246543,-44957],[-345635,256798,-41507],[-353581,253246,-41516],[-350613,246543,-34688],[-348730,242291,-36953],[-343662,244854,-37089],[-344288,246257,-37099],[-343926,244734,-36944],[-352596,251020,-41514],[-347233,253386,-37262],[-347233,253386,-41508],[-352596,251020,-37264],[-341437,247537,-41498],[-344083,246349,-41501],[-343558,244600,-37083],[-344083,246349,-37212],[-347089,253741,-37402],[-347089,253741,-41508],[-349425,246738,-34864],[-349425,246738,-34529],[-349862,246897,-34699],[-349238,248437,-35307]]} From 21c9f192cfbe57ddde35966049a144b6e73d4d28 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:38:43 +0200 Subject: [PATCH 23/65] test: round-trip every real fixture, parse -> serialize -> compare Currently FAILING on tests/data/small.city.jsonl, deliberately: it catches a real bug, fixed in the next commit. A reader compared against another reader's output can agree with it on the wrong answer -- that is precisely how the two bugs motivating this rewrite stayed invisible. Only a trip back out through the writer can catch a member that silently vanished, so that is the shape of this test. Comparison is on parsed Values rather than strings, because several members are backed by HashMap and have no stable serialization order; a byte comparison would fail for reasons that are not data loss. The one further tolerance is the lexical spelling of a number: `metadata.schema.json` types geographicalExtent's items as {"type": "number"}, JSON has a single number type, and a fixture's `0` coming back as `0.0` loses nothing. That tolerance stops short of `a.as_f64() == b.as_f64()`, which would equally forgive 9007199254740993 -> 9007199254740992.0, and it is pinned by its own test. duplicate_keys.city.jsonl is excluded with a comment: a repeated member name is not preserved by any JSON parser, so both sides of the comparison are lossy before cjseq sees them. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/roundtrip.rs | 288 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 tests/roundtrip.rs diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..816b903 --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,288 @@ +//! Real-file round trips. +//! +//! Every other test in this crate is synthetic: hand-built JSON literals +//! written from the spec. These are the first tests that put the typed model +//! in front of files nobody wrote for it. The shape matters: a reader +//! compared against another reader's output can agree on the wrong answer +//! (that is exactly how the two bugs motivating this rewrite stayed +//! invisible), but a parse -> serialize -> compare cycle cannot hide a +//! silently dropped member. +//! +//! Comparison is on parsed `serde_json::Value`s, not on strings: several +//! members are backed by `HashMap` and have no stable serialization order, so +//! a byte comparison would fail for reasons that are not data loss. `Value` +//! equality is order-insensitive for object members and exact for everything +//! else, which is the property this test wants. + +use cjseq::{CityJSON, CityJSONFeature}; +use serde_json::{Number, Value}; +use std::path::{Path, PathBuf}; + +/// Do two JSON numbers denote the same value? +/// +/// `serde_json::Number` compares by representation, not by value: the `0` a +/// fixture writes in `metadata.geographicalExtent` and the `0.0` that comes +/// back out of an `f64`-typed field are `!=` even though JSON has a single +/// number type and both are the number zero. `geographicalExtent`'s items are +/// `{"type": "number"}` in `metadata.schema.json`, so integer-vs-decimal +/// spelling carries no information and re-spelling it is not data loss. +/// +/// This is deliberately *not* `a.as_f64() == b.as_f64()`, which would also +/// call `9007199254740993` equal to `9007199254740992.0` and so hide the one +/// number difference that IS data loss -- an integer too large to survive a +/// round trip through `f64`. The integer must come back exactly. +fn same_number(a: &Number, b: &Number) -> bool { + if a == b { + return true; + } + let exact = |i: i64, f: f64| f == i as f64 && f as i64 == i; + match (a.as_i64(), b.as_f64(), a.as_f64(), b.as_i64()) { + (Some(i), Some(f), _, _) => exact(i, f), + (_, _, Some(f), Some(i)) => exact(i, f), + _ => false, + } +} + +/// CityJSONSeq fixtures: line 1 is a `CityJSON` header, every subsequent +/// line a `CityJSONFeature`. +/// +/// Deliberately excluded: +/// - `duplicate_keys.city.jsonl` (in `tests/data/`, kept for reference but +/// not round-tripped): its point is a JSON object with a repeated member +/// name. No JSON parser preserves both -- `serde_json` keeps the last -- +/// so both sides of the comparison are already lossy before cjseq sees +/// them. Failing here would say nothing about the type model. +/// - `delft.city.jsonl` from flatcitybuf (6.6 MB): too heavy to commit for +/// the coverage it adds over `small.city.jsonl`, which comes from the same +/// 3DBAG pipeline. +fn seq_fixtures() -> Vec { + [ + //-- Extension City Object and semantic-surface types, spelled with a + //-- leading `+` + "tests/data/noise_extension.city.jsonl", + //-- geometry templates, GeometryInstance, header-level appearance + "tests/data/geom_temp.city.jsonl", + //-- an `appearance` object with empty arrays in it + "tests/data/empty_appearance.city.jsonl", + "tests/data/small.city.jsonl", + "tests/data/degenerate_extent.city.jsonl", + "tests/data/inferable_types.city.jsonl", + "tests/data/long_strings.city.jsonl", + "tests/data/single_feature.city.jsonl", + //-- already committed to this repo before this test existed + "data/3dbag_b2.city.jsonl", + ] + .iter() + .map(PathBuf::from) + .collect() +} + +/// Whole-document CityJSON fixtures (not a sequence). +fn cj_fixtures() -> Vec { + ["data/1b_w_texture.city.json", "data/3dbag_b2.city.json"] + .iter() + .map(PathBuf::from) + .collect() +} + +/// `assert_eq!` on two large `Value`s prints both in full, which for a +/// 60 kB feature is unreadable. Walk them instead and report the first +/// differing path. +fn first_difference(a: &Value, b: &Value, path: &str) -> Option { + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + for (k, xv) in x { + match y.get(k) { + Some(yv) => { + if let Some(d) = first_difference(xv, yv, &format!("{path}/{k}")) { + return Some(d); + } + } + None => return Some(format!("{path}/{k}: dropped on round trip ({xv})")), + } + } + for k in y.keys() { + if !x.contains_key(k) { + return Some(format!("{path}/{k}: invented on round trip ({})", y[k])); + } + } + None + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + return Some(format!("{path}: length {} became {}", x.len(), y.len())); + } + for (i, (xv, yv)) in x.iter().zip(y).enumerate() { + if let Some(d) = first_difference(xv, yv, &format!("{path}[{i}]")) { + return Some(d); + } + } + None + } + (Value::Number(x), Value::Number(y)) if same_number(x, y) => None, + _ => { + if a == b { + None + } else { + Some(format!("{path}: {a} became {b}")) + } + } + } +} + +/// Rewrite every number as a canonical string, so that `0` and `0.0` become +/// the same `Value` while `9007199254740993` and `9007199254740992.0` stay +/// different. +/// +/// `first_difference` above is the diagnostic; this exists so the assertion +/// does not rest on that hand-rolled walk being correct. It is a second, +/// independent implementation of the same comparison, and the two must agree. +fn canonical_numbers(v: &Value) -> Value { + match v { + Value::Number(n) => { + let s = if let Some(i) = n.as_i64() { + i.to_string() + } else if let Some(u) = n.as_u64() { + u.to_string() + } else { + let f = n.as_f64().expect("a JSON number is i64, u64, or f64"); + //-- an f64 holding an exact integral value canonicalizes the + //-- same way an integer literal does, but only inside the + //-- range where the conversion is lossless + if f.fract() == 0.0 && f.abs() < 9.007e15 { + (f as i64).to_string() + } else { + format!("{f:?}") + } + }; + Value::String(s) + } + Value::Array(a) => Value::Array(a.iter().map(canonical_numbers).collect()), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, v)| (k.clone(), canonical_numbers(v))) + .collect(), + ), + other => other.clone(), + } +} + +/// Parse `line` as `T`, re-serialize, and require the result to equal the +/// line's own parsed form. Anything that changes on the way through is data +/// loss. +fn assert_roundtrips(line: &str, what: &str) +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + let original: Value = serde_json::from_str(line).expect("fixture is not valid JSON"); + let typed: T = serde_json::from_str(line).unwrap_or_else(|e| panic!("{what}: {e}")); + let reserialized = serde_json::to_value(&typed).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_equivalent(&original, &reserialized, what); +} + +/// The assertion this whole file exists for: nothing may change on the way +/// through except the lexical spelling of a number. +fn assert_equivalent(original: &Value, reserialized: &Value, what: &str) { + if let Some(diff) = first_difference(original, reserialized, "") { + panic!("{what} changed on round trip: {diff}"); + } + assert_eq!( + canonical_numbers(reserialized), + canonical_numbers(original), + "{what} changed on round trip" + ); +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("{}: {e} (run from the crate root)", path.display())) +} + +#[test] +fn every_cityjsonseq_fixture_roundtrips_exactly() { + for path in seq_fixtures() { + let contents = read(&path); + let mut lines = contents + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()); + let (n, header) = lines + .next() + .unwrap_or_else(|| panic!("{} is empty", path.display())); + assert_roundtrips::(header, &format!("{}:{}", path.display(), n + 1)); + let mut features = 0; + for (n, line) in lines { + assert_roundtrips::(line, &format!("{}:{}", path.display(), n + 1)); + features += 1; + } + assert!( + features > 0, + "{} has no features; it would round-trip vacuously", + path.display() + ); + } +} + +#[test] +fn every_cityjson_fixture_roundtrips_exactly() { + for path in cj_fixtures() { + assert_roundtrips::(&read(&path), &path.display().to_string()); + } +} + +/// The comparison forgives an integer literal coming back as a decimal, +/// because JSON has one number type and `metadata.schema.json` types +/// `geographicalExtent`'s items as `{"type": "number"}`. It must not forgive +/// a number whose *value* changed -- in particular an integer too large to +/// survive an `f64`, which is the one case where the difference between +/// "respelled" and "lost" actually matters. +#[test] +fn the_comparison_forgives_respelling_but_not_precision_loss() { + let forgiven = [ + (serde_json::json!({"a": 0}), serde_json::json!({"a": 0.0})), + ( + serde_json::json!({"a": 100}), + serde_json::json!({"a": 100.0}), + ), + (serde_json::json!({"a": -5}), serde_json::json!({"a": -5.0})), + ]; + for (int, float) in forgiven { + assert!( + first_difference(&int, &float, "").is_none(), + "{int} vs {float}" + ); + assert_eq!(canonical_numbers(&int), canonical_numbers(&float)); + } + + let caught = [ + //-- precision loss: 2^53 + 1 is not representable as an f64 + ( + serde_json::json!({"a": 9007199254740993i64}), + serde_json::json!({"a": 9007199254740992.0f64}), + ), + //-- an ordinary wrong value + (serde_json::json!({"a": 1}), serde_json::json!({"a": 2.0})), + (serde_json::json!({"a": 1.5}), serde_json::json!({"a": 1.0})), + ]; + for (before, after) in caught { + assert!( + first_difference(&before, &after, "").is_some(), + "{before} -> {after} is data loss and must be caught" + ); + assert_ne!(canonical_numbers(&before), canonical_numbers(&after)); + } +} + +/// A round trip through `CityJSON::from_str` (which additionally populates +/// the crate's internal `sorted_ids` bookkeeping) must be no lossier than a +/// bare `serde_json::from_str`. +#[test] +fn cityjson_from_str_roundtrips_exactly() { + for path in cj_fixtures() { + let contents = read(&path); + let original: Value = serde_json::from_str(&contents).unwrap(); + let cj = CityJSON::from_str(&contents).unwrap(); + let reserialized = serde_json::to_value(&cj).unwrap(); + assert_equivalent(&original, &reserialized, &path.display().to_string()); + } +} From cc2523f252dfcb87b9084700f6cf5edb5ad89255 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:40:55 +0200 Subject: [PATCH 24/65] fix: keep the metadata members the core spec does not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-tripping tests/data/small.city.jsonl dropped two members: /metadata/fullMetadataUrl: DROPPED ("https://data.3dbag.nl/...") /metadata/version: DROPPED ("v2024.04.20") Both are legal. metadata.schema.json's `metadata` object lists its six properties and then stops -- it declares no `additionalProperties: false`, in contrast to `transform` and `geometry-templates`, which both do. So anything further is valid CityJSON, and the spec itself (§ 5) points at the MetadataExtended Extension as the place to put more of it. Metadata had no catch-all, so every such member vanished on the way through. Every 3DBAG dataset carries these two, which makes this silent data loss on what is probably the most widely distributed CityJSON there is. Fixed with `#[serde(flatten)] other`, matching what CityJSON and CityObject already do -- and deliberately NOT extended to CityJSONFeature, whose schema *does* say `additionalProperties: false`, so its lack of a catch-all is correct and stays. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/metadata.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/metadata.rs b/src/metadata.rs index e44d7d8..af030ba 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,5 +1,7 @@ use crate::error::CjseqError; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; @@ -134,6 +136,21 @@ impl<'de> Deserialize<'de> for ReferenceSystem { } } +/// The `metadata` member of a [`crate::CityJSON`] object (§ 5 Metadata). +/// +/// The six members below are the ones the core spec names. They are *not* the +/// only ones a valid document may carry: `metadata.schema.json`'s `metadata` +/// object declares those six under `properties` and then stops -- there is no +/// `additionalProperties: false`, unlike (say) `transform` or +/// `geometry-templates`, which both do declare it. So any further member is +/// legal CityJSON, and the spec itself points at the MetadataExtended +/// Extension as the place to put more. +/// +/// Hence `other`: without it every such member is silently dropped on the way +/// through. Real files rely on this -- every 3DBAG dataset carries +/// `fullMetadataUrl` and `version` in its metadata (see +/// `tests/data/small.city.jsonl`), and before this field existed both simply +/// vanished. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Metadata { #[serde(rename = "geographicalExtent")] @@ -152,6 +169,9 @@ pub struct Metadata { pub reference_system: Option, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + /// Every metadata member the core spec does not name, kept verbatim. + #[serde(flatten)] + pub other: HashMap, } #[cfg(test)] @@ -218,8 +238,53 @@ mod tests { reference_date: None, reference_system: None, title: Some("dataset".to_string()), + other: HashMap::new(), }; let b = a.clone(); assert_eq!(a, b); } + + /// `metadata.schema.json`'s `metadata` object lists six properties and + /// declares no `additionalProperties: false`, so any further member is + /// legal CityJSON. Real 3DBAG files carry `fullMetadataUrl` and + /// `version`; before `Metadata.other` existed, both were silently dropped + /// on the way through, which `tests/roundtrip.rs` caught on + /// `tests/data/small.city.jsonl`. + #[test] + fn metadata_keeps_members_the_core_spec_does_not_name() { + let input = serde_json::json!({ + "title": "3DBAG", + "fullMetadataUrl": "https://data.3dbag.nl/metadata/v20240420/metadata.json" + }); + let m: Metadata = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(m.title.as_deref(), Some("3DBAG")); + assert_eq!( + m.other.get("fullMetadataUrl").and_then(|v| v.as_str()), + Some("https://data.3dbag.nl/metadata/v20240420/metadata.json") + ); + assert_eq!(serde_json::to_value(&m).unwrap(), input); + } + + /// A nested extra member (the MetadataExtended Extension puts objects + /// here) must survive whole, not be flattened or truncated. + #[test] + fn a_nested_extra_metadata_member_survives() { + let input = serde_json::json!({ + "+metadata-extended": {"lineage": [{"thematicModels": ["Building"]}]} + }); + let m: Metadata = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&m).unwrap(), input); + } + + /// An absent extra member must not reappear as an empty object or a + /// `null`: `#[serde(flatten)]` over an empty map writes nothing. + #[test] + fn an_empty_other_adds_nothing_on_the_way_out() { + let m: Metadata = serde_json::from_value(serde_json::json!({"title": "x"})).unwrap(); + assert!(m.other.is_empty()); + assert_eq!( + serde_json::to_value(&m).unwrap(), + serde_json::json!({"title": "x"}) + ); + } } From 6ca4d1658be07e6831c5b14e1790e8fee9341242 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:45:51 +0200 Subject: [PATCH 25/65] fix: stop rejecting legal addresses and dropping pointOfContact members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the same schema object, both found by reading metadata.schema.json against the code rather than by a fixture -- none of the round-trip fixtures carries a pointOfContact at all, so these were invisible to the new test and would have stayed invisible. 1. cjseq rejected the CityJSON spec's own § 5.3 example: invalid type: string "24", expected i64 Address declared five *required* members, with `thoroughfareNumber` typed i64. The schema types `address` as a bare {"type": "object"} -- no properties, no required, no additionalProperties -- and the prose says why: "any properties can be used, to accommodate the different ways addresses are structured in different countries". Both spec examples spell the number as a string ("134", "24") and the postcode as `postcode`, where cjseq demanded `postalCode`. Structure the model does not actually know is worse than none: it turns valid input into a parse error. Address is now the free-form object the schema describes. 2. `pointOfContact.organization` was silently dropped. It is named by both § 5.3 and the schema's contactDetails.properties, and had no field and no catch-all. Added, along with `other` for anything further -- contactDetails declares no additionalProperties: false either. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/metadata.rs | 139 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 16 deletions(-) diff --git a/src/metadata.rs b/src/metadata.rs index af030ba..cbb5ec5 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -21,18 +21,33 @@ impl Transform { pub type GeographicalExtent = [f64; 6]; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +/// The `address` member of a [`PointOfContact`] (§ 5.3 pointOfContact). +/// +/// Deliberately free-form, and that is what the normative schema asks for: +/// `metadata.schema.json` types `address` as a bare `{"type": "object"}` -- +/// no `properties`, no `required`, no `additionalProperties`. The prose says +/// why in as many words: "any properties can be used, to accommodate the +/// different ways addresses are structured in different countries" (§ 5.3). +/// +/// This type used to declare five *required* members (`thoroughfareNumber` +/// typed `i64`, `thoroughfareName`, `locality`, `postalCode`, `country`) and +/// so rejected nearly every legal address -- including both of the spec's own +/// examples, which spell the number as a *string* (`"134"`, `"24"`) and the +/// postcode as `postcode`, not `postalCode`. Structure the model does not +/// actually know is worse than no structure: it turns valid input into a +/// parse error. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Address { - #[serde(rename = "thoroughfareNumber")] - pub thoroughfare_number: i64, - #[serde(rename = "thoroughfareName")] - pub thoroughfare_name: String, - pub locality: String, - #[serde(rename = "postalCode")] - pub postal_code: String, - pub country: String, + #[serde(flatten)] + pub members: HashMap, } +/// The `pointOfContact` member of [`Metadata`] (§ 5.3). +/// +/// `contactName` and `emailAddress` are the schema's only two `required` +/// members; everything else is optional. As with [`Metadata`], the schema's +/// `contactDetails` definition declares no `additionalProperties: false`, so +/// `other` keeps anything further rather than dropping it. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PointOfContact { #[serde(rename = "contactName")] @@ -49,8 +64,17 @@ pub struct PointOfContact { pub email_address: String, #[serde(skip_serializing_if = "Option::is_none")] pub website: Option, + /// The name of the organisation, "to be used if the `contactName` is the + /// name of a person" (§ 5.3). Named by both the prose and the schema's + /// `contactDetails.properties`; it had no field here at all, so it was + /// silently dropped. + #[serde(skip_serializing_if = "Option::is_none")] + pub organization: Option, #[serde(skip_serializing_if = "Option::is_none")] pub address: Option
, + /// Every contact member the schema does not name, kept verbatim. + #[serde(flatten)] + pub other: HashMap, } /// A reference system following the OGC Name Type Specification. @@ -194,15 +218,17 @@ mod tests { #[test] fn address_with_equal_fields_is_equal() { - let a = Address { - thoroughfare_number: 1, - thoroughfare_name: "rue de la Patate".to_string(), - locality: "Chibougamau".to_string(), - postal_code: "H0H 0H0".to_string(), - country: "Canada".to_string(), - }; + let a: Address = serde_json::from_value(serde_json::json!({ + "thoroughfareNumber": 1, + "thoroughfareName": "rue de la Patate", + "locality": "Chibougamau", + "postalCode": "H0H 0H0", + "country": "Canada" + })) + .unwrap(); let b = a.clone(); assert_eq!(a, b); + assert_ne!(a, Address::default()); } #[test] @@ -214,12 +240,93 @@ mod tests { phone: None, email_address: "jane@example.com".to_string(), website: None, + organization: None, address: None, + other: HashMap::new(), }; let b = a.clone(); assert_eq!(a, b); } + /// The `pointOfContact` example in § 5.3 of the spec, verbatim. cjseq + /// rejected it outright -- `"thoroughfareNumber": "24"` is a string and + /// `Address.thoroughfare_number` was an `i64` -- which is the plainest + /// possible demonstration that the old `Address` invented structure the + /// spec does not have. `metadata.schema.json` types `address` as bare + /// `{"type": "object"}`. + #[test] + fn the_specs_own_point_of_contact_example_parses_and_round_trips() { + let input = serde_json::json!({ + "contactName": "Justin Trudeau", + "emailAddress": "justin.trudeau@parl.gc.ca", + "phone": "+1-613-992-4211", + "address": { + "thoroughfareNumber": "24", + "thoroughfareName": "Sussez Drive", + "postcode": "H0H 0H0", + "locality": "Ottawa", + "country": "Canada" + }, + "contactType": "individual", + "role": "pointOfContact" + }); + let poc: PointOfContact = serde_json::from_value(input.clone()) + .expect("the spec's own example must parse"); + assert_eq!( + poc.address.as_ref().unwrap().members["thoroughfareNumber"], + serde_json::json!("24") + ); + assert_eq!(serde_json::to_value(&poc).unwrap(), input); + } + + /// The § 5 metadata example spells the number as a string too, and uses + /// `postcode`. Both spellings must survive; neither is normalized. + #[test] + fn an_address_keeps_whatever_members_it_was_given() { + for input in [ + serde_json::json!({"thoroughfareNumber": "134", "postcode": "2628BL"}), + serde_json::json!({"thoroughfareNumber": 134, "postalCode": "2628BL"}), + //-- a shape from neither example: the schema forbids nothing + serde_json::json!({"freeform": {"nested": [1, 2]}}), + serde_json::json!({}), + ] { + let a: Address = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&a).unwrap(), input); + } + } + + /// `organization` is named by both § 5.3 and the schema's + /// `contactDetails.properties`, and had no field here, so it vanished. + #[test] + fn point_of_contact_keeps_organization_and_unnamed_members() { + let input = serde_json::json!({ + "contactName": "Jane Doe", + "emailAddress": "jane@example.com", + "organization": "3D geoinformation group, TU Delft", + "somethingElse": 42 + }); + let poc: PointOfContact = serde_json::from_value(input.clone()).unwrap(); + assert_eq!( + poc.organization.as_deref(), + Some("3D geoinformation group, TU Delft") + ); + assert_eq!(serde_json::to_value(&poc).unwrap(), input); + } + + /// The two members the schema marks `required` are still required: this + /// change relaxes `address`, not the contact itself. + #[test] + fn point_of_contact_still_requires_the_two_members_the_schema_requires() { + assert!(serde_json::from_value::( + serde_json::json!({"emailAddress": "a@b.c"}) + ) + .is_err()); + assert!( + serde_json::from_value::(serde_json::json!({"contactName": "A"})) + .is_err() + ); + } + #[test] fn reference_system_with_equal_fields_is_equal() { let a = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap(); From 63d02e6cab8d8bfd64cff963ec91214013438773 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:57:07 +0200 Subject: [PATCH 26/65] fix: keep the extra members the semantics and appearance schemas permit From the codex review (finding 9 of its first pass): three more objects whose schemas leave the door open but whose Rust types shut it. geomprimitives.schema.json declares additionalProperties: false on the *geometry* object, and then declares no such thing on three objects nested inside it: - the `semantics` object (names surfaces, values) - each per-theme `material` object (names value, values) - each per-theme `texture` object (names values) So `{"surfaces":[...],"values":[0],"vendorData":{"x":1}}` is legal CityJSON and cjseq was dropping `vendorData` on the floor. Same for `{"value":0,"vendorData":true}` in a material theme. SemanticsSurface already had an `other` for exactly this reason; its container did not. Added `#[serde(flatten)] other` to Semantics, MaterialReference and TextureReference. The catch-all on MaterialReference sits alongside the `values` double Option, so there is a test pinning that {"values": null} is still distinct from {} and from an absent member -- flatten switches serde to its buffered path and that distinction is exactly what would break silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appearance.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++++ src/semantics.rs | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/appearance.rs b/src/appearance.rs index 6675cc1..1221d06 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; /// One shell's worth of material indices, one per surface. `None` — a whole /// shell with no material — is `null` in JSON, which @@ -141,6 +142,11 @@ pub struct MaterialReference { pub values: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, + /// The per-theme material object names `value` and `values` and declares + /// no `additionalProperties: false`, so anything further is legal + /// CityJSON and is kept rather than dropped. + #[serde(flatten)] + pub other: HashMap, } /// One theme's texture assignment for a geometry. @@ -153,6 +159,10 @@ pub struct MaterialReference { pub struct TextureReference { #[serde(skip_serializing_if = "Option::is_none")] pub values: Option, + /// As with [`MaterialReference`]: the per-theme texture object declares no + /// `additionalProperties: false`, so extra members are legal and kept. + #[serde(flatten)] + pub other: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -235,6 +245,47 @@ impl Appearance { mod tests { use super::*; + /// The per-theme `material` and `texture` objects in + /// `geomprimitives.schema.json` name their members and then stop: neither + /// declares `additionalProperties: false`, unlike the geometry object + /// enclosing them, which does. So an extra member is legal CityJSON, and + /// dropping it is silent data loss. + #[test] + fn a_material_or_texture_theme_keeps_members_the_schema_does_not_name() { + let m: MaterialReference = + serde_json::from_value(serde_json::json!({"value": 0, "vendorData": true})).unwrap(); + assert_eq!(m.value, Some(0)); + assert_eq!( + serde_json::to_value(&m).unwrap(), + serde_json::json!({"value": 0, "vendorData": true}) + ); + + let t: TextureReference = + serde_json::from_value(serde_json::json!({"values": [[[0, 1, 2]]], "note": "x"})) + .unwrap(); + assert_eq!( + serde_json::to_value(&t).unwrap(), + serde_json::json!({"values": [[[0, 1, 2]]], "note": "x"}) + ); + } + + /// The catch-all must not disturb what was already pinned: an absent + /// member stays absent, and `{"values": null}` stays distinct from `{}`. + #[test] + fn the_catch_all_does_not_disturb_absent_versus_null() { + let empty: MaterialReference = serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(empty.other.is_empty()); + assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!({})); + + let null: MaterialReference = + serde_json::from_value(serde_json::json!({"values": null})).unwrap(); + assert_eq!(null.values, Some(None)); + assert_eq!( + serde_json::to_value(&null).unwrap(), + serde_json::json!({"values": null}) + ); + } + #[test] fn material_indices_serialize_as_numbers_and_null() { // cjseq2 0.1.0 emitted [[], [1]] here. That is invalid CityJSON. diff --git a/src/semantics.rs b/src/semantics.rs index f8c7f69..eb8ca86 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -159,16 +159,53 @@ pub enum SemanticsValues { /// it is an `Option` that is serialized even when `None` — writing it out as /// `null` rather than dropping the member, which would produce a document the /// schema rejects. +/// +/// As with [`SemanticsSurface`], the `semantics` object in +/// `geomprimitives.schema.json` names `surfaces` and `values` and declares no +/// `additionalProperties: false` -- unlike its enclosing geometry object, +/// which does -- so any further member is legal and is kept verbatim in +/// `other` rather than dropped. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct Semantics { pub surfaces: Vec, pub values: Option, + #[serde(flatten)] + pub other: HashMap, } #[cfg(test)] mod tests { use super::*; + /// The `semantics` object names `surfaces` and `values` and declares no + /// `additionalProperties: false` -- unlike the geometry object that + /// contains it, which does -- so a further member is legal CityJSON and + /// must not vanish. `SemanticsSurface` already kept its extras; the + /// enclosing `Semantics` did not. + #[test] + fn semantics_keeps_members_the_schema_does_not_name() { + let input = serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}], + "values": [0], + "vendorData": {"x": 1} + }); + let s: Semantics = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(s.other["vendorData"], serde_json::json!({"x": 1})); + assert_eq!(serde_json::to_value(&s).unwrap(), input); + } + + /// And with nothing extra, nothing extra appears. + #[test] + fn semantics_without_extras_serializes_unchanged() { + let input = serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}], + "values": null + }); + let s: Semantics = serde_json::from_value(input.clone()).unwrap(); + assert!(s.other.is_empty()); + assert_eq!(serde_json::to_value(&s).unwrap(), input); + } + #[test] fn solid_semantics_values_are_one_per_surface_per_shell() { let s: Semantics = serde_json::from_value(serde_json::json!({ From 92bf4f6bb0fa1ed1494df8dd1d6f51d04a22c512 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 11:57:07 +0200 Subject: [PATCH 27/65] refactor: derive ReferenceSystem's serde instead of hand-writing it From the codex review: ReferenceSystem hand-wrote both Serialize and Deserialize, which violates the crate's derived-only rule outright. The rule exists because a hand-written impl is the one place a field can be dropped or reshaped with no type-level trace of it having happened -- the same failure this branch's round-trip test was built to catch. The type is a struct in Rust and a bare string in JSON, which is what the hand-written pair was for. `#[serde(try_from = "String", into = "String")]` expresses that within the derive: serde handles a plain String on the wire and calls ordinary TryFrom/From to convert, and those two conversions are just the existing from_url/to_url. No visitor, no shape logic. Pinned by tests, because a derived Serialize on a four-field struct would otherwise quietly start emitting an object where a string belongs: reference_system_is_a_string_in_json_not_an_object, reference_system_round_trips_inside_metadata, and a_non_crs_reference_system_is_rejected (TryFrom's error must still surface, not fall back to a default). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/metadata.rs | 73 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/src/metadata.rs b/src/metadata.rs index cbb5ec5..3963e54 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -86,7 +86,18 @@ pub struct PointOfContact { /// - `{version}` designates the specific version of the CRS /// (use "0" if there is no version) /// - `{code}` is the identifier for the specific coordinate reference system -#[derive(Debug, Clone, PartialEq)] +/// +/// In JSON this is a single string, not an object, so the derive needs +/// telling how to get between the two. `#[serde(try_from/into)]` does exactly +/// that and nothing else: `Serialize` and `Deserialize` stay **derived**, and +/// the conversion is ordinary `TryFrom`/`From` code that serde calls on +/// either side of a plain `String`. This replaces a hand-written `Serialize` +/// and `Deserialize` pair, which violated the crate's derived-only rule -- +/// hand-written impls are the one place a field can be silently dropped or +/// reshaped without any type-level trace, which is the whole reason for the +/// rule. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(try_from = "String", into = "String")] pub struct ReferenceSystem { pub base_url: String, pub authority: String, @@ -141,22 +152,19 @@ impl ReferenceSystem { } } -impl Serialize for ReferenceSystem { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.to_url().serialize(serializer) +/// The two conversions `#[serde(try_from = "String", into = "String")]` calls. +/// They carry no serde logic of their own -- they are the same `to_url` and +/// `from_url` the rest of the crate uses. +impl From for String { + fn from(rs: ReferenceSystem) -> String { + rs.to_url() } } -impl<'de> Deserialize<'de> for ReferenceSystem { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let url = String::deserialize(deserializer)?; - ReferenceSystem::from_url(&url).map_err(serde::de::Error::custom) +impl TryFrom for ReferenceSystem { + type Error = CjseqError; + fn try_from(url: String) -> crate::error::Result { + ReferenceSystem::from_url(&url) } } @@ -270,8 +278,8 @@ mod tests { "contactType": "individual", "role": "pointOfContact" }); - let poc: PointOfContact = serde_json::from_value(input.clone()) - .expect("the spec's own example must parse"); + let poc: PointOfContact = + serde_json::from_value(input.clone()).expect("the spec's own example must parse"); assert_eq!( poc.address.as_ref().unwrap().members["thoroughfareNumber"], serde_json::json!("24") @@ -336,6 +344,39 @@ mod tests { assert_ne!(a, c); } + /// `ReferenceSystem` is a struct in Rust and a bare string in JSON. + /// Swapping its hand-written `Serialize`/`Deserialize` for + /// `#[serde(try_from/into)]` must not change one byte of that: a derived + /// `Serialize` on a four-field struct would otherwise emit an *object*, + /// which is what this pins. + #[test] + fn reference_system_is_a_string_in_json_not_an_object() { + let url = "https://www.opengis.net/def/crs/EPSG/0/7415"; + let rs: ReferenceSystem = serde_json::from_value(serde_json::json!(url)).unwrap(); + assert_eq!(rs.authority, "EPSG"); + assert_eq!(rs.version, "0"); + assert_eq!(rs.code, "7415"); + assert_eq!(serde_json::to_value(&rs).unwrap(), serde_json::json!(url)); + } + + /// And inside the metadata object it composes, which is the only place it + /// is ever actually serialized. + #[test] + fn reference_system_round_trips_inside_metadata() { + let input = serde_json::json!({ + "referenceSystem": "https://www.opengis.net/def/crs/EPSG/0/7415" + }); + let m: Metadata = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&m).unwrap(), input); + } + + /// A string that is not a CRS URL is still an error rather than a silent + /// default -- the conversion is `TryFrom`, and serde surfaces its error. + #[test] + fn a_non_crs_reference_system_is_rejected() { + assert!(serde_json::from_value::(serde_json::json!("not-a-url")).is_err()); + } + #[test] fn metadata_with_equal_fields_is_equal() { let a = Metadata { From 5a96e2af6c6847ae9f9a4cb0c23f1844972c0649 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 12:13:06 +0200 Subject: [PATCH 28/65] test: round-trip duplicate_keys too, and assert no fixture goes unexercised duplicate_keys.city.jsonl was excluded on a rationale I wrote from its filename without reading it: "a JSON object with a repeated member name; no JSON parser preserves both". That is false. Parsing all six lines with an object_pairs_hook that reports repeated names finds none, at any nesting depth. The file is five structurally identical Building features whose `grp` *attribute value* repeats -- the conformance case is duplicate grouping, not duplicate keys. It round-trips cleanly, and now does so in CI. The deeper problem is that nothing would have told me. The fixture lists are hardcoded rather than globbed, which is the right call (a glob passes vacuously when it matches nothing) but leaves a file able to sit in tests/data/ unexercised in silence. every_fixture_on_disk_is_accounted_for closes that: every file on disk must be either round-tripped or listed in EXCLUDED with a reason, and every listed path must exist. EXCLUDED is empty and the comment says it should stay that way -- a fixture that cannot survive a round trip is a finding, not a housekeeping detail. Verified both directions: dropping an unlisted file into tests/data/ fails the new test by name, and injecting an unknown top-level member into duplicate_keys line 2 fails the round trip ("/unknownFeatureMember: dropped on round trip"), proving the fixture is genuinely exercised rather than merely listed. Also two comparison-harness fixes from the same review: - canonical_numbers used 9.007e15 as its lossless boundary, which is below 2^53. Integral values in the gap canonicalized differently depending on whether they arrived float- or integer-spelled, reporting a difference that isn't one. Now spelled 2^53 exactly. - assert_equivalent passed the round-tripped value as assert_eq!'s left argument, so failures labelled the output "left" and the input "right", inverted from how the message reads. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/roundtrip.rs | 85 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 816b903..b9b5b1e 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -46,15 +46,13 @@ fn same_number(a: &Number, b: &Number) -> bool { /// CityJSONSeq fixtures: line 1 is a `CityJSON` header, every subsequent /// line a `CityJSONFeature`. /// -/// Deliberately excluded: -/// - `duplicate_keys.city.jsonl` (in `tests/data/`, kept for reference but -/// not round-tripped): its point is a JSON object with a repeated member -/// name. No JSON parser preserves both -- `serde_json` keeps the last -- -/// so both sides of the comparison are already lossy before cjseq sees -/// them. Failing here would say nothing about the type model. -/// - `delft.city.jsonl` from flatcitybuf (6.6 MB): too heavy to commit for -/// the coverage it adds over `small.city.jsonl`, which comes from the same -/// 3DBAG pipeline. +/// Every fixture is round-tripped; nothing in `tests/data/` is excluded. +/// (`EXCLUDED` below exists so that stays true by assertion rather than by +/// good intentions -- see `every_fixture_on_disk_is_accounted_for`.) +/// +/// `delft.city.jsonl` from flatcitybuf (6.6 MB) was not vendored at all: too +/// heavy to commit for the coverage it adds over `small.city.jsonl`, which +/// comes from the same 3DBAG pipeline. fn seq_fixtures() -> Vec { [ //-- Extension City Object and semantic-surface types, spelled with a @@ -66,6 +64,13 @@ fn seq_fixtures() -> Vec { "tests/data/empty_appearance.city.jsonl", "tests/data/small.city.jsonl", "tests/data/degenerate_extent.city.jsonl", + //-- despite the name, this file contains no repeated JSON member + //-- names at any depth (verified with an `object_pairs_hook` that + //-- reports them). It is five structurally identical Building + //-- features whose `grp` *attribute value* repeats -- a duplicate + //-- grouping case, not a duplicate-key case. It was briefly excluded + //-- on the strength of its filename; that was wrong. + "tests/data/duplicate_keys.city.jsonl", "tests/data/inferable_types.city.jsonl", "tests/data/long_strings.city.jsonl", "tests/data/single_feature.city.jsonl", @@ -77,6 +82,12 @@ fn seq_fixtures() -> Vec { .collect() } +/// Fixtures in `tests/data/` deliberately left out of the round trip, each +/// with the reason it cannot be round-tripped. Empty, and it should stay that +/// way: a fixture that cannot survive a round trip is a finding, not a +/// housekeeping detail. +const EXCLUDED: &[(&str, &str)] = &[]; + /// Whole-document CityJSON fixtures (not a sequence). fn cj_fixtures() -> Vec { ["data/1b_w_texture.city.json", "data/3dbag_b2.city.json"] @@ -148,8 +159,14 @@ fn canonical_numbers(v: &Value) -> Value { let f = n.as_f64().expect("a JSON number is i64, u64, or f64"); //-- an f64 holding an exact integral value canonicalizes the //-- same way an integer literal does, but only inside the - //-- range where the conversion is lossless - if f.fract() == 0.0 && f.abs() < 9.007e15 { + //-- range where the conversion is lossless. That range ends + //-- at 2^53, above which consecutive integers are no longer + //-- all representable -- so spell 2^53, not an approximation + //-- of it: a rounder constant slightly below it would make + //-- integral values in the gap canonicalize differently + //-- depending on whether they arrived float- or + //-- integer-spelled, i.e. report a difference that isn't one. + if f.fract() == 0.0 && f.abs() < 9007199254740992.0 { (f as i64).to_string() } else { format!("{f:?}") @@ -186,9 +203,12 @@ fn assert_equivalent(original: &Value, reserialized: &Value, what: &str) { if let Some(diff) = first_difference(original, reserialized, "") { panic!("{what} changed on round trip: {diff}"); } + //-- `original` goes on the left so that assert_eq!'s "left"/"right" + //-- labels read the way the failure does: left is what the file said, + //-- right is what came back out. assert_eq!( - canonical_numbers(reserialized), canonical_numbers(original), + canonical_numbers(reserialized), "{what} changed on round trip" ); } @@ -230,6 +250,47 @@ fn every_cityjson_fixture_roundtrips_exactly() { } } +/// The fixture lists above are hardcoded rather than globbed, deliberately: a +/// glob silently passes when it matches nothing, so it cannot fail the way a +/// missing fixture should. The cost of hardcoding is that a file can sit in +/// `tests/data/` unexercised and nothing says so -- which is exactly what +/// happened to `duplicate_keys.city.jsonl`, excluded on the strength of its +/// filename and never rechecked. This closes that gap: every file on disk is +/// either round-tripped or explicitly excluded with a reason. +#[test] +fn every_fixture_on_disk_is_accounted_for() { + let listed: std::collections::HashSet = seq_fixtures() + .into_iter() + .chain(cj_fixtures()) + .chain(EXCLUDED.iter().map(|(p, _)| PathBuf::from(*p))) + .collect(); + + let mut on_disk: Vec = std::fs::read_dir("tests/data") + .expect("tests/data must exist (run from the crate root)") + .map(|e| e.expect("readable dir entry").path()) + .filter(|p| p.extension().is_some_and(|e| e == "jsonl" || e == "json")) + .collect(); + on_disk.sort(); + assert!(!on_disk.is_empty(), "tests/data is empty"); + + let unaccounted: Vec<&PathBuf> = on_disk.iter().filter(|p| !listed.contains(*p)).collect(); + assert!( + unaccounted.is_empty(), + "these fixtures are in tests/data/ but are neither round-tripped nor \ + listed in EXCLUDED with a reason: {unaccounted:?}" + ); + + //-- and the reverse: a listed path that no longer exists must not pass + //-- silently either + for p in seq_fixtures().iter().chain(cj_fixtures().iter()) { + assert!( + p.exists(), + "{} is listed as a fixture but is missing", + p.display() + ); + } +} + /// The comparison forgives an integer literal coming back as a decimal, /// because JSON has one number type and `metadata.schema.json` types /// `geographicalExtent`'s items as `{"type": "number"}`. It must not forgive From 5058bb7d2683f50e386eb0c4881d5f9bd7c50615 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 12:13:06 +0200 Subject: [PATCH 29/65] fix: accept every referenceSystem the schema's prefix pattern permits Codex finding A2 had two halves and 92bf4f6 fixed only the first (the hand-written serde). This is the second, which is a rejects-legal defect of exactly the same class as the old Address -- and so the one place my own triage principle went unapplied. metadata.schema.json constrains referenceSystem by a prefix and nothing else: "referenceSystem": { "type": "string", "pattern": "^(http|https)://www.opengis.net/def/crs/" } from_url demanded exactly three `/`-separated elements after `crs/`, so every one of these legal strings was a hard parse error: https://www.opengis.net/def/crs/EPSG/0/4326/extra (codex's example) https://www.opengis.net/def/crs/EPSG/0/7415/ (trailing slash) https://www.opengis.net/def/crs/EPSG/0 https://www.opengis.net/def/crs/ The three-element {authority}/{version}/{code} form is the OGC convention and what every real file uses, but it is not what makes the member valid. ReferenceSystem now stores the path elements verbatim as `segments`, so to_url rebuilds any of the above byte for byte -- a trailing slash is just a final empty element. authority/version/code become accessors returning Option<&str> rather than String fields. Keeping them as fields alongside a verbatim path was the obvious alternative and is a trap: the two can disagree, and then to_url silently contradicts the fields. The prefix itself is still enforced, since that part the schema does constrain: a non-opengis host, a missing /crs/, and a bare `.../def/crs` without the trailing slash are all still rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/metadata.rs | 183 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 146 insertions(+), 37 deletions(-) diff --git a/src/metadata.rs b/src/metadata.rs index 3963e54..3262f4e 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -87,6 +87,33 @@ pub struct PointOfContact { /// (use "0" if there is no version) /// - `{code}` is the identifier for the specific coordinate reference system /// +/// That three-element form is what OGC names and what every real file uses, +/// but it is **not** what makes a `referenceSystem` legal. The normative +/// constraint in `metadata.schema.json` is a prefix pattern and nothing else: +/// +/// ```text +/// "referenceSystem": { +/// "type": "string", +/// "pattern": "^(http|https)://www.opengis.net/def/crs/" +/// } +/// ``` +/// +/// So `.../crs/EPSG/0/4326/extra` and `.../crs/EPSG/0/7415/` are both legal +/// CityJSON, and an earlier version of this type rejected them outright by +/// demanding exactly three `/`-separated elements. That is the same +/// rejects-legal defect as the old [`Address`], and the fix is the same: +/// stop inventing structure the format does not mandate. +/// +/// Hence `segments` rather than three named fields. It holds the path +/// elements after `.../def/crs/` verbatim, so [`ReferenceSystem::to_url`] can +/// rebuild *any* legal URL byte for byte -- including a trailing slash, which +/// is simply a final empty element. The OGC reading is still available, as +/// [`ReferenceSystem::authority`], [`ReferenceSystem::version`] and +/// [`ReferenceSystem::code`], which return `None` when the URL does not have +/// that shape. Keeping the three as plain fields *alongside* the verbatim +/// path was the obvious alternative and is a trap: the two representations +/// can disagree, and then `to_url` silently contradicts the fields. +/// /// In JSON this is a single string, not an object, so the derive needs /// telling how to get between the two. `#[serde(try_from/into)]` does exactly /// that and nothing else: `Serialize` and `Deserialize` stay **derived**, and @@ -99,55 +126,66 @@ pub struct PointOfContact { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(try_from = "String", into = "String")] pub struct ReferenceSystem { + /// Everything up to and including `.../def/crs`, with no trailing slash. pub base_url: String, - pub authority: String, - pub version: String, - pub code: String, + /// The `/`-separated path elements after `.../def/crs/`, verbatim. OGC + /// names the first three `{authority}/{version}/{code}`; the schema + /// requires no particular number of them. + pub segments: Vec, } impl ReferenceSystem { + /// The ordinary three-element OGC form. pub fn new(base_url: Option, authority: String, version: String, code: String) -> Self { - let base_url = base_url.unwrap_or(DEFAULT_CRS_BASE_URL.to_string()); ReferenceSystem { - base_url, - authority, - version, - code, + base_url: base_url.unwrap_or_else(|| DEFAULT_CRS_BASE_URL.to_string()), + segments: vec![authority, version, code], } } - pub fn to_url(&self) -> String { - format!( - "{}/{}/{}/{}", - self.base_url, self.authority, self.version, self.code - ) + /// The `{authority}` element, or `None` if this URL has no such element. + pub fn authority(&self) -> Option<&str> { + self.segments.first().map(String::as_str) } - // OGC Name Type Specification: - // http://www.opengis.net/def/crs/{authority}/{version}/{code} - // where {authority} designates the authority responsible for the definition of this CRS (usually "EPSG" or "OGC"), and where {version} designates the specific version of the CRS ("0" (zero) is used if there is no version). - pub fn from_url(url: &str) -> crate::error::Result { - if !url.contains("//www.opengis.net/def/crs") { - return Err(CjseqError::Validation( - "invalid reference system URL".to_string(), - )); - } + /// The `{version}` element (`"0"` when the CRS has no version). + pub fn version(&self) -> Option<&str> { + self.segments.get(1).map(String::as_str) + } - let i = url.find("crs").unwrap(); - let s = &url[i + 4..]; + /// The `{code}` element identifying the CRS. + pub fn code(&self) -> Option<&str> { + self.segments.get(2).map(String::as_str) + } - let parts: Vec<&str> = s.split("/").collect(); - if parts.len() != 3 { - return Err(CjseqError::Validation( - "invalid reference system URL".to_string(), - )); - } + /// Rebuild the URL. Exact for every input [`ReferenceSystem::from_url`] + /// accepts, whatever its element count -- `segments` is stored verbatim, + /// so there is nothing to reconstruct incorrectly. + pub fn to_url(&self) -> String { + format!("{}/{}", self.base_url, self.segments.join("/")) + } + /// Parse a `referenceSystem` string. + /// + /// The only thing checked is the prefix, because the prefix is the only + /// thing `metadata.schema.json` constrains. The OGC three-element form is + /// a convention this honours (see [`ReferenceSystem::authority`] and + /// friends) but does not enforce, so a longer or shorter path is carried + /// through rather than rejected. + pub fn from_url(url: &str) -> crate::error::Result { + //-- the schema's pattern is `^(http|https)://www.opengis.net/def/crs/` + const PREFIX: &str = "//www.opengis.net/def/crs/"; + let Some(i) = url.find(PREFIX) else { + return Err(CjseqError::Validation(format!( + "not a CRS URL: {url:?} does not match the required prefix \ + (http|https)://www.opengis.net/def/crs/" + ))); + }; + let split = i + PREFIX.len(); Ok(ReferenceSystem { - base_url: url[..i + 3].to_string(), - authority: parts[0].to_string(), - version: parts[1].to_string(), - code: parts[2].to_string(), + //-- everything before the `/` that separates prefix from path + base_url: url[..split - 1].to_string(), + segments: url[split..].split('/').map(str::to_string).collect(), }) } } @@ -353,12 +391,83 @@ mod tests { fn reference_system_is_a_string_in_json_not_an_object() { let url = "https://www.opengis.net/def/crs/EPSG/0/7415"; let rs: ReferenceSystem = serde_json::from_value(serde_json::json!(url)).unwrap(); - assert_eq!(rs.authority, "EPSG"); - assert_eq!(rs.version, "0"); - assert_eq!(rs.code, "7415"); + assert_eq!(rs.authority(), Some("EPSG")); + assert_eq!(rs.version(), Some("0")); + assert_eq!(rs.code(), Some("7415")); assert_eq!(serde_json::to_value(&rs).unwrap(), serde_json::json!(url)); } + /// `metadata.schema.json` constrains `referenceSystem` by a *prefix + /// pattern* and nothing else, so a path with more or fewer than three + /// elements is legal CityJSON. cjseq demanded exactly three and rejected + /// the rest outright -- the same rejects-legal defect as the old + /// `Address`, and caught by the same review (codex finding A2, whose + /// `.../EPSG/0/4326/extra` example is the first case below). + /// + /// Each must survive byte for byte: `to_url` rebuilds from the verbatim + /// path, so a trailing slash (a final empty element) comes back too. + #[test] + fn any_url_matching_the_schemas_prefix_pattern_is_accepted_and_round_trips() { + for url in [ + //-- codex's own example + "https://www.opengis.net/def/crs/EPSG/0/4326/extra", + //-- a trailing slash: a fourth, empty element + "https://www.opengis.net/def/crs/EPSG/0/7415/", + //-- fewer elements than OGC names + "https://www.opengis.net/def/crs/EPSG/0", + "https://www.opengis.net/def/crs/EPSG", + //-- the degenerate case the pattern still matches + "https://www.opengis.net/def/crs/", + //-- the pattern permits plain http too + "http://www.opengis.net/def/crs/EPSG/0/7415", + //-- and the ordinary form must be unaffected + "https://www.opengis.net/def/crs/EPSG/0/7415", + ] { + let rs = ReferenceSystem::from_url(url) + .unwrap_or_else(|e| panic!("{url} matches the schema pattern: {e}")); + assert_eq!(rs.to_url(), url, "{url} must round-trip byte for byte"); + //-- and through serde, which is how it is actually used + let via_serde: ReferenceSystem = + serde_json::from_value(serde_json::json!(url)).unwrap(); + assert_eq!( + serde_json::to_value(&via_serde).unwrap(), + serde_json::json!(url) + ); + } + } + + /// The OGC accessors report absence rather than inventing an element. + #[test] + fn the_ogc_accessors_are_optional() { + let short = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG").unwrap(); + assert_eq!(short.authority(), Some("EPSG")); + assert_eq!(short.version(), None); + assert_eq!(short.code(), None); + + let long = + ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/4326/extra").unwrap(); + assert_eq!(long.code(), Some("4326")); + assert_eq!(long.segments.len(), 4); + } + + /// Relaxing the path must not relax the prefix: the pattern is the one + /// thing the schema does constrain. + #[test] + fn a_url_failing_the_prefix_pattern_is_still_rejected() { + for bad in [ + "https://example.com/def/crs/EPSG/0/7415", + "https://www.opengis.net/def/EPSG/0/7415", + //-- the prefix pattern ends in a slash; `.../crs` alone lacks it + "https://www.opengis.net/def/crs", + "", + ] { + assert!( + ReferenceSystem::from_url(bad).is_err(), + "{bad:?} does not match the schema pattern and must be rejected" + ); + } + } + /// And inside the metadata object it composes, which is the only place it /// is ever actually serialized. #[test] From f6cb990590aab6cf0f215f43654a481d9910592e Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 12:51:08 +0200 Subject: [PATCH 30/65] feat: add CityObject::new, so callers outside the crate can build one `other` is the private `#[serde(flatten)]` catch-all, so a struct literal cannot be written outside this module and there was no other way to construct a `CityObject` in code -- only by deserializing one. `flatcitybuf` builds City Objects field by field while decoding a FlatBuffers feature and had no route at all. The constructor takes only `thetype`, which is the one member with no meaningful empty value; the rest are `pub` and are set by the caller. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/city_object.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/city_object.rs b/src/city_object.rs index 66cafa1..f32d5ef 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -176,6 +176,30 @@ pub struct CityObject { } impl CityObject { + /// A City Object of `thetype` carrying nothing else. + /// + /// `other` is deliberately not a constructor parameter: it is the + /// `#[serde(flatten)]` catch-all for members the schema does not name, and + /// a value built in code has none. Callers that need one set the named + /// fields directly, which are all `pub`. + /// + /// This exists because `other` is private, so a struct literal cannot be + /// written outside this module -- and every field but `thetype` has a + /// meaningful empty value, so a literal would be mostly `None` noise + /// anyway. + pub fn new(thetype: CityObjectType) -> Self { + CityObject { + thetype, + geographical_extent: None, + attributes: None, + geometry: None, + children: None, + children_roles: None, + parents: None, + other: Value::Null, + } + } + pub fn get_type(&self) -> CityObjectType { self.thetype.clone() } From 3f4bfc528a2de5acd81be8b0837ba66b9b0ec880 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 13:13:49 +0200 Subject: [PATCH 31/65] fix: CityObject::new must equal its parsed twin `other` was `Value::Null`, where deserializing a City Object with no unrecognized members yields `Value::Object({})`. Both serialize to nothing, so the difference is invisible in output and visible only under the derived `PartialEq` -- which is exactly what a caller comparing a document against its own round trip reaches for. `flatcitybuf` builds every decoded City Object through this constructor, so none of them could ever have compared equal to the same object parsed from JSON. Pinned by a test that asserts equality rather than equal serializations, since the latter passes either way. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/city_object.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/city_object.rs b/src/city_object.rs index f32d5ef..70505d7 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -187,6 +187,13 @@ impl CityObject { /// written outside this module -- and every field but `thetype` has a /// meaningful empty value, so a literal would be mostly `None` noise /// anyway. + /// + /// `other` is an empty *object*, not `Value::Null`: that is what + /// deserializing a City Object with no unrecognized members produces, and + /// the derived `PartialEq` compares it. A `Null` here would serialize + /// identically but make every constructed object compare unequal to its + /// parsed twin -- silently, and only ever noticed by a test that compares + /// whole objects. pub fn new(thetype: CityObjectType) -> Self { CityObject { thetype, @@ -196,7 +203,7 @@ impl CityObject { children: None, children_roles: None, parents: None, - other: Value::Null, + other: Value::Object(serde_json::Map::new()), } } @@ -233,6 +240,29 @@ impl CityObject { mod tests { use super::*; + /// A constructed City Object must equal the one deserializing the + /// equivalent minimal JSON produces. Serializing both and comparing the + /// output is not enough: `other: Value::Null` and `other: Value::Object({})` + /// both serialize to nothing, so only `==` distinguishes them -- and `==` + /// is what a caller round-tripping a document through its own encoder will + /// reach for. + #[test] + fn a_constructed_city_object_equals_its_parsed_twin() { + let constructed = CityObject::new(CityObjectType::Building); + let parsed: CityObject = serde_json::from_str(r#"{"type":"Building"}"#).unwrap(); + assert_eq!(constructed, parsed); + assert_eq!( + serde_json::to_value(&constructed).unwrap(), + serde_json::json!({"type": "Building"}) + ); + + // And the same for an Extension type, whose name lives in the tag + // rather than in `other`. + let constructed = CityObject::new(CityObjectType::Extension("+Noise".into())); + let parsed: CityObject = serde_json::from_str(r#"{"type":"+Noise"}"#).unwrap(); + assert_eq!(constructed, parsed); + } + #[test] fn extension_city_object_type_roundtrips_with_its_plus() { let t: CityObjectType = From 85e6cf0992acc6b29ae832633b571fcef1439a34 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 13:47:21 +0200 Subject: [PATCH 32/65] feat!: type the appearance library's materials and textures `Appearance.materials` and `Appearance.textures` were `Option>`, which pushed every schema fact about a material or a texture out of the type and into the consumers. FlatCityBuf duly reimplemented all of it as string `match`es with `_` fallbacks, and one of those turned a texture written `"wrapMode": "wrap"` into `"None"` -- caught only by the C++ conformance corpus, by no Rust test. Follow `appearance.schema.json` exactly: - `MaterialObject`: `name` required, the rest optional, each of the three colours a `[f64; 3]` (`minItems`/`maxItems` are both 3). - `TextureObject`: no `required` at all, so every member optional. - `TextureFormat` (`PNG`/`JPG`, upper case), `WrapMode` and `TextureType` (lower case). Plain unit-variant enums: these are closed enumerations, unlike `CityObjectType`, which needs its `Extension(String)` case only because CityJSON Extensions may add to that set. - `BorderColor`, an untagged `Rgb([f64; 3]) | Rgba([f64; 4])`, because `borderColor` is the one colour the schema allows to be three *or* four numbers -- a cardinality that has bitten this project before. Both objects declare `additionalProperties: false`, so neither gets the `#[serde(flatten)] other` catch-all that `MaterialReference` and `Metadata` carry, and both are `deny_unknown_fields`: since every `Texture` member is optional, silently dropping unknown members would let an object of nothing but mis-spelled ones parse as an empty texture. Serialization stays fully derived. Also replaces the palette-slicing loops in `get_metadata`/`get_cjfeature`, which filled a `Vec` with `json!(null)` placeholders before overwriting every slot. A `null` was never a valid palette entry and is now not even representable; `reindex` sorts by the new index instead. 88 tests -> 95, with all nine round-trip fixtures still green -- including `1b_w_texture` and `geom_temp`, which carry a full library. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appearance.rs | 438 ++++++++++++++++++++++++++++++++++++++++++---- src/cityjson.rs | 84 ++++----- 2 files changed, 441 insertions(+), 81 deletions(-) diff --git a/src/appearance.rs b/src/appearance.rs index 1221d06..c4fa650 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -165,12 +165,181 @@ pub struct TextureReference { pub other: HashMap, } +/// A material or border colour: three numbers, per +/// `appearance.schema.json`'s `"minItems": 3, "maxItems": 3` on +/// `diffuseColor`, `emissiveColor` and `specularColor`. +/// +/// A fixed-size array, not a `Vec`, so the cardinality is checked by serde's +/// derived `Deserialize` rather than by a downstream `if len == 3`. The same +/// technique already types `GeometryInstance::transformation_matrix` as +/// `[f64; 16]`. +pub type Color = [f64; 3]; + +/// A texture's `borderColor`, whose schema is the one place a CityJSON colour +/// is *not* fixed at three numbers: +/// +/// ```text +/// "borderColor": { +/// "type": "array", "items": {"type": "number"}, +/// "minItems": 3, "maxItems": 4 +/// } +/// ``` +/// +/// Three or four, and nothing else. A `Vec` would accept two or five and +/// leave the check to a consumer that may not make it; the untagged pair of +/// fixed-size arrays makes serde do it. Serialization is derived, and an +/// untagged enum of arrays serializes as the array itself, so `[0, 0, 0, 1]` +/// comes back spelled exactly as it went in. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum BorderColor { + /// `minItems: 3` + Rgb(Color), + /// `maxItems: 4` + Rgba([f64; 4]), +} + +/// A texture image's file format: `"type": {"enum": ["PNG", "JPG"]}`. +/// +/// Note the case: this member is spelled upper case where its `wrapMode` and +/// `textureType` siblings are lower. Getting that wrong in a hand-written +/// `match` is precisely the defect this type removes. +/// +/// A closed enumeration in the schema -- unlike `CityObjectType` and +/// `SemanticSurfaceType`, which carry an `Extension(String)` case because +/// CityJSON Extensions may add to those two sets. Nothing in the spec lets an +/// Extension add a texture format, so there is no such case here and an +/// unrecognised spelling is an error. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TextureFormat { + PNG, + JPG, +} + +/// How a texture repeats outside `[0, 1]`: +/// `"wrapMode": {"enum": ["none", "wrap", "mirror", "clamp", "border"]}`. +/// +/// All five are lower case. A texture written `"wrapMode": "wrap"` once came +/// back as `"None"` from FlatCityBuf because a downstream `match` expected a +/// different spelling and its `_` arm turned the miss into a default; with the +/// spellings here, the same mistake is a `serde` error at the boundary and a +/// non-exhaustive `match` at every consumer. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WrapMode { + None, + Wrap, + Mirror, + Clamp, + Border, +} + +/// What a texture depicts: +/// `"textureType": {"enum": ["unknown", "specific", "typical"]}`. Lower case, +/// and a closed set. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum TextureType { + Unknown, + Specific, + Typical, +} + +/// One entry of the document-level `appearance.materials` palette, referred to +/// by index from a [`MaterialReference`]. +/// +/// `appearance.schema.json#/Material`, in full: +/// +/// ```text +/// "Material": { +/// "type": "object", +/// "properties": { +/// "name": {"type": "string"}, +/// "ambientIntensity": {"type": "number"}, +/// "diffuseColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, +/// "emissiveColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, +/// "specularColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, +/// "shininess": {"type": "number"}, +/// "transparency": {"type": "number"}, +/// "isSmooth": {"type": "boolean"} +/// }, +/// "required": ["name"], +/// "additionalProperties": false +/// } +/// ``` +/// +/// So `name` is a plain `String` and everything else an `Option`, and there is +/// no `#[serde(flatten)] other` catch-all: `additionalProperties: false` means +/// an unnamed member is not CityJSON at all. `deny_unknown_fields` says so out +/// loud rather than dropping it, which matters because dropping is silent data +/// loss and, for the all-optional [`TextureObject`] below, an object of +/// nothing but mis-spelled members would otherwise parse as an empty one. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct MaterialObject { + /// The schema's one required member. + pub name: String, + #[serde(rename = "ambientIntensity", skip_serializing_if = "Option::is_none")] + pub ambient_intensity: Option, + #[serde(rename = "diffuseColor", skip_serializing_if = "Option::is_none")] + pub diffuse_color: Option, + #[serde(rename = "emissiveColor", skip_serializing_if = "Option::is_none")] + pub emissive_color: Option, + #[serde(rename = "specularColor", skip_serializing_if = "Option::is_none")] + pub specular_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shininess: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transparency: Option, + #[serde(rename = "isSmooth", skip_serializing_if = "Option::is_none")] + pub is_smooth: Option, +} + +/// One entry of the document-level `appearance.textures` palette, referred to +/// by the first entry of each ring in a [`TextureValues`]. +/// +/// `appearance.schema.json#/Texture`, in full: +/// +/// ```text +/// "Texture": { +/// "type": "object", +/// "properties": { +/// "type": {"enum": ["PNG", "JPG"]}, +/// "image": {"type": "string"}, +/// "wrapMode": {"enum": ["none", "wrap", "mirror", "clamp", "border"]}, +/// "textureType": {"enum": ["unknown", "specific", "typical"]}, +/// "borderColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 4} +/// }, +/// "additionalProperties": false +/// } +/// ``` +/// +/// There is no `required` keyword, so *every* member is optional and a bare +/// `{}` is valid CityJSON — including `image`, which FlatCityBuf's `.fbs` +/// currently makes mandatory. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TextureObject { + /// The image file format. Named `thetype` because `type` is a Rust + /// keyword, as in [`crate::CityJSONFeature`]. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub thetype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(rename = "wrapMode", skip_serializing_if = "Option::is_none")] + pub wrap_mode: Option, + #[serde(rename = "textureType", skip_serializing_if = "Option::is_none")] + pub texture_type: Option, + #[serde(rename = "borderColor", skip_serializing_if = "Option::is_none")] + pub border_color: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Appearance { #[serde(skip_serializing_if = "Option::is_none")] - pub materials: Option>, + pub materials: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub textures: Option>, + pub textures: Option>, #[serde(rename = "vertices-texture")] #[serde(skip_serializing_if = "Option::is_none")] pub vertices_texture: Option>>, @@ -191,41 +360,25 @@ impl Appearance { default_theme_material: None, } } - pub(crate) fn add_material(&mut self, jm: Value) -> usize { - let re = match &mut self.materials { - Some(x) => match x.iter().position(|e| *e == jm) { - Some(y) => y, - None => { - x.push(jm); - x.len() - 1 - } - }, + pub(crate) fn add_material(&mut self, jm: MaterialObject) -> usize { + let ls = self.materials.get_or_insert_with(Vec::new); + match ls.iter().position(|e| *e == jm) { + Some(y) => y, None => { - let mut ls: Vec = Vec::new(); ls.push(jm); - self.materials = Some(ls); - 0 + ls.len() - 1 } - }; - re + } } - pub(crate) fn add_texture(&mut self, jm: Value) -> usize { - let re = match &mut self.textures { - Some(x) => match x.iter().position(|e| *e == jm) { - Some(y) => y, - None => { - x.push(jm); - x.len() - 1 - } - }, + pub(crate) fn add_texture(&mut self, jm: TextureObject) -> usize { + let ls = self.textures.get_or_insert_with(Vec::new); + match ls.iter().position(|e| *e == jm) { + Some(y) => y, None => { - let mut ls: Vec = Vec::new(); ls.push(jm); - self.textures = Some(ls); - 0 + ls.len() - 1 } - }; - re + } } pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec>) { match &mut self.vertices_texture { @@ -547,6 +700,231 @@ mod tests { assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!({})); } + //------------------------------------------------------------------- + //-- the document-level appearance *library*: `materials`/`textures` + //------------------------------------------------------------------- + + /// The bug that motivated typing this library: a texture written + /// `"wrapMode": "wrap"` came back as `"None"`, because the consumer's + /// `match` expected a different spelling and its `_` arm coerced the + /// mis-spelling to a valid-looking default. + /// + /// `appearance.schema.json` fixes the five spellings exactly, all lower + /// case: + /// ```text + /// "wrapMode": { "enum": ["none", "wrap", "mirror", "clamp", "border"] } + /// ``` + /// so every one of them must survive a round trip byte-identical, and + /// anything else -- including a differently-cased spelling of a real one + /// -- must be rejected here rather than defaulted downstream. + #[test] + fn every_wrap_mode_round_trips_and_nothing_else_is_accepted() { + for mode in ["none", "wrap", "mirror", "clamp", "border"] { + let input = serde_json::json!({"textures": [{"wrapMode": mode}]}); + let a: Appearance = serde_json::from_value(input.clone()) + .unwrap_or_else(|e| panic!("{mode} is a schema-valid wrapMode: {e}")); + assert_eq!( + serde_json::to_value(&a).unwrap(), + input, + "wrapMode {mode:?} must come back exactly as it went in" + ); + } + + for bad in ["Wrap", "WRAP", "None", "repeat", ""] { + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"wrapMode": bad}]}) + ) + .is_err(), + "{bad:?} is not one of the schema's five wrapMode spellings" + ); + } + } + + /// `"textureType": { "enum": ["unknown", "specific", "typical"] }` and + /// `"type": { "enum": ["PNG", "JPG"] }` -- note the case difference + /// between the two members, which is exactly the kind of detail a string + /// `match` gets wrong. + #[test] + fn every_texture_type_and_format_round_trips_and_nothing_else_is_accepted() { + for t in ["unknown", "specific", "typical"] { + let input = serde_json::json!({"textures": [{"textureType": t}]}); + let a: Appearance = serde_json::from_value(input.clone()) + .unwrap_or_else(|e| panic!("{t} is a schema-valid textureType: {e}")); + assert_eq!(serde_json::to_value(&a).unwrap(), input); + } + for bad in ["Unknown", "SPECIFIC", "other"] { + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"textureType": bad}]}) + ) + .is_err(), + "{bad:?} is not one of the schema's three textureType spellings" + ); + } + + for f in ["PNG", "JPG"] { + let input = serde_json::json!({"textures": [{"type": f}]}); + let a: Appearance = serde_json::from_value(input.clone()) + .unwrap_or_else(|e| panic!("{f} is a schema-valid texture type: {e}")); + assert_eq!(serde_json::to_value(&a).unwrap(), input); + } + //-- the schema spells these upper case, and lists only these two; + //-- `JPEG` and `jpg` are both outside it + for bad in ["png", "jpg", "JPEG", "TIFF"] { + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"type": bad}]}) + ) + .is_err(), + "{bad:?} is not one of the schema's two texture formats" + ); + } + } + + /// A texture object carries all five members, or none of them: the + /// `Texture` schema has no `required` keyword at all, so `{}` is valid, + /// and an absent member must not reappear. + #[test] + fn a_texture_object_keeps_every_member_it_was_given_and_invents_none() { + let full = serde_json::json!({"textures": [{ + "type": "JPG", + "image": "appearances/wood.jpg", + "wrapMode": "wrap", + "textureType": "unknown", + "borderColor": [0.0, 0.0, 0.0, 1.0] + }]}); + let a: Appearance = serde_json::from_value(full.clone()).unwrap(); + assert_eq!(serde_json::to_value(&a).unwrap(), full); + + let bare = serde_json::json!({"textures": [{}]}); + let a: Appearance = serde_json::from_value(bare.clone()).unwrap(); + assert_eq!( + serde_json::to_value(&a).unwrap(), + bare, + "the Texture schema has no `required`, so an empty texture object \ + is valid and must not gain members" + ); + } + + /// `"borderColor": {"type": "array", "items": {"type": "number"}, + /// "minItems": 3, "maxItems": 4}` -- three *or* four, never two, never + /// five. This cardinality has already bitten this project once. + #[test] + fn border_color_is_three_or_four_numbers() { + for ok in [ + serde_json::json!([0.0, 0.0, 0.0]), + serde_json::json!([0.0, 0.0, 0.0, 1.0]), + ] { + let input = serde_json::json!({"textures": [{"borderColor": ok}]}); + let a: Appearance = serde_json::from_value(input.clone()) + .unwrap_or_else(|e| panic!("{ok} is a schema-valid borderColor: {e}")); + assert_eq!(serde_json::to_value(&a).unwrap(), input); + } + for bad in [ + serde_json::json!([]), + serde_json::json!([0.0]), + serde_json::json!([0.0, 0.0]), + serde_json::json!([0.0, 0.0, 0.0, 1.0, 1.0]), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"borderColor": bad}]}) + ) + .is_err(), + "{bad} violates borderColor's minItems 3 / maxItems 4" + ); + } + } + + /// The `Material` schema is the mirror image of `Texture`: it *requires* + /// `name`, and pins each of its three colours at exactly three numbers + /// (`"minItems": 3, "maxItems": 3`). + #[test] + fn a_material_object_requires_a_name_and_three_number_colours() { + let full = serde_json::json!({"materials": [{ + "name": "roof", + "ambientIntensity": 0.4, + "diffuseColor": [0.5, 0.4, 0.3], + "emissiveColor": [0.0, 0.0, 0.0], + "specularColor": [1.0, 1.0, 1.0], + "shininess": 0.2, + "transparency": 0.0, + "isSmooth": false + }]}); + let a: Appearance = serde_json::from_value(full.clone()).unwrap(); + assert_eq!(serde_json::to_value(&a).unwrap(), full); + + //-- `name` is the schema's one required member + assert!( + serde_json::from_value::( + serde_json::json!({"materials": [{"diffuseColor": [0.5, 0.4, 0.3]}]}) + ) + .is_err(), + "`name` is required by the Material schema" + ); + + //-- and a colour is exactly three numbers, never two, never four + for bad in [ + serde_json::json!([0.5, 0.4]), + serde_json::json!([0.5, 0.4, 0.3, 0.2]), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"materials": [{"name": "m", "diffuseColor": bad}]}) + ) + .is_err(), + "{bad} violates diffuseColor's minItems 3 / maxItems 3" + ); + } + + //-- only `name` need be there + let bare = serde_json::json!({"materials": [{"name": "m"}]}); + let a: Appearance = serde_json::from_value(bare.clone()).unwrap(); + assert_eq!(serde_json::to_value(&a).unwrap(), bare); + } + + /// Both `Material` and `Texture` declare `"additionalProperties": false`, + /// unlike the per-theme reference objects above. So an unnamed member is + /// *not* legal CityJSON, and quietly dropping it -- which is what a + /// catch-all-less struct does by default -- would let an invalid file + /// through and lose data on the way. Reject it instead. + #[test] + fn material_and_texture_objects_admit_no_members_the_schema_does_not_name() { + assert!( + serde_json::from_value::( + serde_json::json!({"materials": [{"name": "m", "vendorData": true}]}) + ) + .is_err(), + "the Material schema declares additionalProperties: false" + ); + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"image": "a.jpg", "vendorData": true}]}) + ) + .is_err(), + "the Texture schema declares additionalProperties: false" + ); + //-- a near-miss spelling is an unnamed member, and so is caught by the + //-- same rule: this is the whole point of the exercise + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"wrapmode": "wrap"}]}) + ) + .is_err(), + "`wrapmode` is not `wrapMode`" + ); + } + + /// An `appearance` with empty arrays in it is valid, and the arrays must + /// stay empty rather than becoming absent (`tests/data/empty_appearance`). + #[test] + fn an_empty_appearance_library_stays_empty() { + let input = serde_json::json!({"materials": [], "textures": []}); + let a: Appearance = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&a).unwrap(), input); + } + /// Texture values are nested exactly as deeply as the boundaries of the /// geometry that carries them (CityJSON 2.0, section 6.2), each ring /// becoming `[texture_index, uv_index, ...]`. diff --git a/src/cityjson.rs b/src/cityjson.rs index 65b67e9..5b39289 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,4 +1,4 @@ -use crate::appearance::Appearance; +use crate::appearance::{Appearance, MaterialObject, TextureObject}; use crate::city_object::CityObject; use crate::error::Result; use crate::geometry::GeometryTemplates; @@ -7,6 +7,24 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; +/// Slice a document-level appearance palette down to the entries one feature +/// uses, in the order that feature now refers to them. +/// +/// `oldnew` maps each index used by the feature onto its dense position in the +/// new palette; `update_material`/`update_texture` build it by handing out +/// consecutive new indices, so its values are exactly `0..oldnew.len()` and +/// sorting by them reproduces the palette. +/// +/// This replaces a `resize(len, json!(null))`-then-overwrite loop, which only +/// worked because every slot was subsequently overwritten: the `null` filler +/// was never a valid palette entry, and now that the palette is typed it is +/// not even representable. +fn reindex(all: &[T], oldnew: &HashMap) -> Vec { + let mut pairs: Vec<(usize, usize)> = oldnew.iter().map(|(o, n)| (*n, *o)).collect(); + pairs.sort_unstable(); + pairs.into_iter().map(|(_, old)| all[old].clone()).collect() +} + #[derive(Clone)] pub enum SortingStrategy { Random, @@ -137,32 +155,14 @@ impl CityJSON { let mut acjf: Appearance = Appearance::new(); acjf.default_theme_material = a.default_theme_material.clone(); acjf.default_theme_texture = a.default_theme_texture.clone(); - if a.materials.is_some() { - let am = a.materials.as_ref().unwrap(); - let mut mats2: Vec = Vec::new(); - mats2.resize(m_oldnew.len(), json!(null)); - for (old, new) in &m_oldnew { - mats2[*new] = am[*old].clone(); - } - acjf.materials = Some(mats2); + if let Some(am) = a.materials.as_ref() { + acjf.materials = Some(reindex(am, &m_oldnew)); } - if a.textures.is_some() { - let at = a.textures.as_ref().unwrap(); - let mut texs2: Vec = Vec::new(); - texs2.resize(t_oldnew.len(), json!(null)); - for (old, new) in &t_oldnew { - texs2[*new] = at[*old].clone(); - } - acjf.textures = Some(texs2); + if let Some(at) = a.textures.as_ref() { + acjf.textures = Some(reindex(at, &t_oldnew)); } - if a.vertices_texture.is_some() { - let atv = a.vertices_texture.as_ref().unwrap(); - let mut t_new_vertices: Vec> = Vec::new(); - t_new_vertices.resize(t_v_oldnew.len(), vec![]); - for (old, new) in &t_v_oldnew { - t_new_vertices[*new] = atv[*old].clone(); - } - acjf.vertices_texture = Some(t_new_vertices); + if let Some(atv) = a.vertices_texture.as_ref() { + acjf.vertices_texture = Some(reindex(atv, &t_v_oldnew)); } cj0.appearance = Some(acjf); } @@ -233,32 +233,14 @@ impl CityJSON { let mut acjf: Appearance = Appearance::new(); acjf.default_theme_material = a.default_theme_material.clone(); acjf.default_theme_texture = a.default_theme_texture.clone(); - if a.materials.is_some() { - let am = a.materials.as_ref().unwrap(); - let mut mats2: Vec = Vec::new(); - mats2.resize(m_oldnew.len(), json!(null)); - for (old, new) in &m_oldnew { - mats2[*new] = am[*old].clone(); - } - acjf.materials = Some(mats2); + if let Some(am) = a.materials.as_ref() { + acjf.materials = Some(reindex(am, &m_oldnew)); } - if a.textures.is_some() { - let at = a.textures.as_ref().unwrap(); - let mut texs2: Vec = Vec::new(); - texs2.resize(t_oldnew.len(), json!(null)); - for (old, new) in &t_oldnew { - texs2[*new] = at[*old].clone(); - } - acjf.textures = Some(texs2); + if let Some(at) = a.textures.as_ref() { + acjf.textures = Some(reindex(at, &t_oldnew)); } - if a.vertices_texture.is_some() { - let atv = a.vertices_texture.as_ref().unwrap(); - let mut t_new_vertices: Vec> = Vec::new(); - t_new_vertices.resize(t_v_oldnew.len(), vec![]); - for (old, new) in &t_v_oldnew { - t_new_vertices[*new] = atv[*old].clone(); - } - acjf.vertices_texture = Some(t_new_vertices); + if let Some(atv) = a.vertices_texture.as_ref() { + acjf.vertices_texture = Some(reindex(atv, &t_v_oldnew)); } cjf.appearance = Some(acjf); } @@ -465,7 +447,7 @@ impl CityJSON { } }; } - fn add_material(&mut self, jm: Value) -> usize { + fn add_material(&mut self, jm: MaterialObject) -> usize { let re = match &mut self.appearance { Some(x) => x.add_material(jm), None => { @@ -477,7 +459,7 @@ impl CityJSON { }; re } - fn add_texture(&mut self, jm: Value) -> usize { + fn add_texture(&mut self, jm: TextureObject) -> usize { let re = match &mut self.appearance { Some(x) => x.add_texture(jm), None => { From f148ed7dc260a46c6ce3aee1faa6bc2c6fcbd7bf Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 14:07:04 +0200 Subject: [PATCH 33/65] fix: reject appearance members the schema does not name `cityjson.schema.json#/properties/appearance` names five members and then declares `"additionalProperties": false`, exactly as the `Material` and `Texture` objects inside it do; `cityjsonfeature.schema.json#/properties/appearance` carries the clause verbatim, so it holds at both document levels. `Appearance` was dropping extras silently while the objects it contains rejected them -- asymmetric, and lossy in the one direction the previous commit argued must not be lossy. `deny_unknown_fields` here too. 95 tests -> 96; `tests/roundtrip.rs` untouched and still green, so no real fixture carries an unnamed appearance member. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appearance.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/appearance.rs b/src/appearance.rs index c4fa650..5a56b2b 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -334,7 +334,18 @@ pub struct TextureObject { pub border_color: Option, } +/// The document-level appearance library. +/// +/// `cityjson.schema.json#/properties/appearance` names five members and then +/// declares `"additionalProperties": false`, exactly as the [`MaterialObject`] +/// and [`TextureObject`] it contains do — +/// `cityjsonfeature.schema.json#/properties/appearance` carries the same +/// clause verbatim, so the rule holds at both document levels. Hence +/// `deny_unknown_fields` here too: an extra member is not CityJSON, and +/// dropping it silently at this level while rejecting it one level down would +/// be inconsistent as well as lossy. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] pub struct Appearance { #[serde(skip_serializing_if = "Option::is_none")] pub materials: Option>, @@ -916,6 +927,61 @@ mod tests { ); } + /// The `appearance` object itself declares `additionalProperties: false`, + /// exactly as the `Material` and `Texture` objects inside it do. + /// `cityjson.schema.json#/properties/appearance`: + /// + /// ```text + /// "appearance": { + /// "type": "object", + /// "properties": { + /// "default-theme-texture": {"type": "string"}, + /// "default-theme-material": {"type": "string"}, + /// "materials": {"type": "array", "items": {"$ref": "appearance.schema.json#/Material"}}, + /// "textures": {"type": "array", "items": {"$ref": "appearance.schema.json#/Texture"}}, + /// "vertices-texture": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}} + /// }, + /// "additionalProperties": false + /// } + /// ``` + /// + /// `cityjsonfeature.schema.json#/properties/appearance` carries the same + /// clause verbatim, so the rule holds at both document levels. Dropping an + /// extra member here while rejecting one a level down, inside `Material`, + /// would be asymmetric: the schema forbids the member either way. + #[test] + fn the_appearance_object_admits_no_members_the_schema_does_not_name() { + for bad in [ + serde_json::json!({"vendorData": true}), + //-- a near-miss on each of the two hyphenated names, which are the + //-- easiest of the five to mistype + serde_json::json!({"default_theme_texture": "winter"}), + serde_json::json!({"vertices_texture": [[0.0, 0.0]]}), + //-- and a near-miss alongside a member that is correct, so the + //-- rejection cannot be an accident of the object being otherwise + //-- empty + serde_json::json!({"materials": [], "texture": []}), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "{bad} carries a member `appearance` does not name, and the \ + schema declares additionalProperties: false" + ); + } + + //-- the control: all five named members together are accepted, so the + //-- loop above is not passing merely because everything is rejected + let all_five = serde_json::json!({ + "materials": [{"name": "m"}], + "textures": [{"image": "a.jpg"}], + "vertices-texture": [[0.0, 0.0]], + "default-theme-texture": "winter", + "default-theme-material": "irradiation" + }); + let a: Appearance = serde_json::from_value(all_five.clone()).unwrap(); + assert_eq!(serde_json::to_value(&a).unwrap(), all_five); + } + /// An `appearance` with empty arrays in it is valid, and the arrays must /// stay empty rather than becoming absent (`tests/data/empty_appearance`). #[test] From ac4a120d98fbd76a8fa6606f8bb000db37439ea1 Mon Sep 17 00:00:00 2001 From: HideBa Date: Tue, 21 Jul 2026 15:13:11 +0200 Subject: [PATCH 34/65] refactor!: apply the branch's own typing principles to the last few gaps Six review findings, all in the public API, all cheaper to fix before the upstream PR than after it. `vertices-texture` is `Vec<[f64; 2]>`. The schema pins each texture vertex to `minItems: 2, maxItems: 2` -- the doc comment three lines above quoted that very clause while the field stayed `Vec>`. This was the one place the branch's principle was not applied; `Color`, `BorderColor`, `GeographicalExtent` and `transformationMatrix` all already carry theirs. `ReferenceSystem::from_url` anchors its prefix check. It used `find`, a substring search, so `ftp://www.opengis.net/def/crs/x` was accepted while the doc comment cited the anchored schema pattern `^(http|https)://www.opengis.net/def/crs/` as what it enforced. Now `starts_with`, once per permitted scheme, keeping the byte-exact `to_url(from_url(s)) == s` property. `CjseqError::GeometryDepth.found` is a `usize`, not prose. A caller could not recover the depth programmatically, and the test had to assert on a substring of the message. `Result` is no longer re-exported at the crate root. `pub use error::*` meant `use cjseq::*` shadowed `std::result::Result` in the importing module. `CjseqError` is re-exported by name; the alias stays at `cjseq::error::Result`. `CityObject.other` is `pub`, like every other `#[serde(flatten)]` catch-all in the crate. `CityObject::new` stays -- it is used -- but is now a convenience rather than the only way to construct the type. The associated-`const`-not-variant caveat moved from the `impl` block onto `CityObjectType` and `SemanticSurfaceType` themselves, as a runnable doctest. Rustdoc renders an `impl` block's doc below the fold, and the `E0308` trap it describes -- where rustc suggests renaming the arm to a binding, silently producing a catch-all -- is the sort of thing a stranger needs before writing the match, not after. --- src/appearance.rs | 51 +++++++++++++++++++++++++++++++---------- src/city_object.rs | 57 +++++++++++++++++++++++++++++++--------------- src/cityjson.rs | 2 +- src/error.rs | 6 ++--- src/geometry.rs | 19 ++++++++-------- src/lib.rs | 13 ++++++++--- src/main.rs | 2 +- src/metadata.rs | 22 ++++++++++++++---- src/semantics.rs | 40 ++++++++++++++++++++++++-------- 9 files changed, 150 insertions(+), 62 deletions(-) diff --git a/src/appearance.rs b/src/appearance.rs index 5a56b2b..3c59116 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -351,9 +351,14 @@ pub struct Appearance { pub materials: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub textures: Option>, + /// The (u, v) texture vertices. The schema pins each one to + /// `minItems: 2, maxItems: 2`, so it is `[f64; 2]` and not `Vec`: + /// a fixed-size array rejects a 1- or 3-element vertex on read and makes + /// one unwritable, exactly as `[f64; 3]` does for a colour and `[f64; 6]` + /// for a geographical extent. #[serde(rename = "vertices-texture")] #[serde(skip_serializing_if = "Option::is_none")] - pub vertices_texture: Option>>, + pub vertices_texture: Option>, #[serde(rename = "default-theme-texture")] #[serde(skip_serializing_if = "Option::is_none")] pub default_theme_texture: Option, @@ -391,17 +396,10 @@ impl Appearance { } } } - pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec>) { - match &mut self.vertices_texture { - Some(x) => { - x.append(&mut vs); - } - None => { - let mut ls: Vec> = Vec::new(); - ls.append(&mut vs); - self.vertices_texture = Some(ls); - } - }; + pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec<[f64; 2]>) { + self.vertices_texture + .get_or_insert_with(Vec::new) + .append(&mut vs); } } @@ -982,6 +980,35 @@ mod tests { assert_eq!(serde_json::to_value(&a).unwrap(), all_five); } + /// `cityjson.schema.json` pins each texture vertex to + /// `{"type": "array", "items": {"type": "number"}, "minItems": 2, + /// "maxItems": 2}`, so `vertices-texture` is `Vec<[f64; 2]>`: a vertex + /// with any other length is not CityJSON and must not parse, nor be + /// constructible in Rust. This is the same principle `Color` (`[f64; 3]`), + /// `GeographicalExtent` (`[f64; 6]`) and `transformationMatrix` + /// (`[f64; 16]`) already apply. + #[test] + fn a_texture_vertex_is_exactly_two_numbers() { + for bad in [ + serde_json::json!({"vertices-texture": [[0.5]]}), + serde_json::json!({"vertices-texture": [[0.5, 0.5, 0.5]]}), + serde_json::json!({"vertices-texture": [[]]}), + //-- one good vertex is not enough to rescue a bad one + serde_json::json!({"vertices-texture": [[0.0, 0.0], [1.0, 1.0, 1.0]]}), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "{bad} has a texture vertex that is not exactly two numbers" + ); + } + + //-- the control, round-tripping through the fixed-size array + let good = serde_json::json!({"vertices-texture": [[0.0, 0.5], [1.0, 1.0]]}); + let a: Appearance = serde_json::from_value(good.clone()).unwrap(); + assert_eq!(a.vertices_texture, Some(vec![[0.0, 0.5], [1.0, 1.0]])); + assert_eq!(serde_json::to_value(&a).unwrap(), good); + } + /// An `appearance` with empty arrays in it is valid, and the arrays must /// stay empty rather than becoming absent (`tests/data/empty_appearance`). #[test] diff --git a/src/city_object.rs b/src/city_object.rs index 70505d7..2cba182 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -65,6 +65,34 @@ pub enum KnownCityObjectType { /// unit-only enum sidesteps this, because *that* enum, derived without /// `#[serde(untagged)]`, gets the ordinary external-tagging collapse (a /// unit-only enum's tag+content is just the bare name string). +/// +/// # Match on the value, not on a reference +/// +/// Each known name is also available flat, as an associated `const` (eg +/// [`CityObjectType::BuildingPart`]) -- see the `impl` block below for the +/// full list. They are `const`s, not variants: this enum has exactly the two +/// variants above. That distinction bites in exactly one place, and it bites +/// silently, so it belongs here rather than in the small print: +/// +/// ``` +/// # use cjseq::{CityObjectType, KnownCityObjectType}; +/// # let t = &CityObjectType::Building; +/// match *t { +/// CityObjectType::Building => { /* .. */ } +/// CityObjectType::Extension(ref s) => { let _ = s; } +/// _ => {} +/// } +/// ``` +/// +/// Note the `*`. Matching a `const` path against a `&CityObjectType` -- ie +/// `match t` where `t: &CityObjectType`, or `match &t` -- is `E0308` +/// ("mismatched types"), and rustc's fix-it suggests renaming the arm to a +/// fresh binding. That *compiles*, and silently turns the arm into a +/// catch-all that matches everything, because `Building` in pattern position +/// reads as a new irrefutable binding rather than as the constant. The `_` +/// arm is still required after `Extension(ref s)`: the match is not +/// exhaustive over just the known names, since `Known` and `Extension` are +/// the real variants. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(untagged)] pub enum CityObjectType { @@ -80,19 +108,10 @@ pub enum CityObjectType { /// These are associated `const`s, not enum variants -- `CityObjectType` /// itself has exactly the two variants above -- but a `const` path is usable /// wherever a variant path would be: in expressions (`x == CityObjectType::Bridge`) -/// and, because the type derives `PartialEq`/`Eq`, in match patterns too. -/// -/// Match on the value, not a reference: `match *t { CityObjectType::Building -/// => .., CityObjectType::Extension(ref s) => .., _ => .. }`, not `match &t` -/// or `match t` where `t: &CityObjectType`. Matching a `const` path against a -/// `&CityObjectType` is `E0308` ("mismatched types"), and rustc's fix-it -/// suggests renaming the arm to a fresh binding -- which compiles, but -/// silently turns that arm into a catch-all that matches everything, since -/// `CityObjectType::Building` (with binding-like syntax accepted) reads as a -/// new irrefutable binding named `Building`, not the constant. `_` is still -/// required after `Extension(ref s)` because these are two real variants -/// (`Known`, `Extension`), so the match isn't exhaustive over just the known -/// names. +/// and, because the type derives `PartialEq`/`Eq`, in match patterns too -- +/// but only when matching on a *value*. See +/// [`CityObjectType`][CityObjectType#match-on-the-value-not-on-a-reference] +/// for why that matters and how it fails silently otherwise. #[allow(non_upper_case_globals)] impl CityObjectType { pub const Bridge: CityObjectType = CityObjectType::Known(KnownCityObjectType::Bridge); @@ -171,8 +190,11 @@ pub struct CityObject { pub children_roles: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub parents: Option>, + /// Members the schema does not name, carried verbatim so they survive a + /// round trip. `pub`, like every other `#[serde(flatten)]` catch-all in + /// this crate, so a `CityObject` can be written as a struct literal. #[serde(flatten)] - other: serde_json::Value, + pub other: serde_json::Value, } impl CityObject { @@ -183,10 +205,9 @@ impl CityObject { /// a value built in code has none. Callers that need one set the named /// fields directly, which are all `pub`. /// - /// This exists because `other` is private, so a struct literal cannot be - /// written outside this module -- and every field but `thetype` has a - /// meaningful empty value, so a literal would be mostly `None` noise - /// anyway. + /// This is a convenience, not a necessity: every field is `pub`, so a + /// struct literal works too. It saves writing seven `None`s and getting + /// the `other` sentinel right (see below). /// /// `other` is an empty *object*, not `Value::Null`: that is what /// deserializing a City Object with no unrecognized members produces, and diff --git a/src/cityjson.rs b/src/cityjson.rs index 5b39289..1cb1e2e 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -437,7 +437,7 @@ impl CityJSON { } } } - fn add_vertices_texture(&mut self, vs: Vec>) { + fn add_vertices_texture(&mut self, vs: Vec<[f64; 2]>) { match &mut self.appearance { Some(x) => x.add_vertices_texture(vs), None => { diff --git a/src/error.rs b/src/error.rs index fbcea3c..8073b95 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,8 +18,8 @@ pub enum CjseqError { geometry_type: GeometryType, /// The nesting depth the geometry type requires. expected: usize, - /// What was found instead, in words. - found: String, + /// The nesting depth the `boundaries` actually had. + found: usize, }, /// An I/O operation failed, eg opening or reading a CityJSON(Seq) file. Io(std::io::Error), @@ -40,7 +40,7 @@ impl fmt::Display for CjseqError { found, } => write!( f, - "a {geometry_type:?} nests its boundaries {expected} levels deep, but {found}" + "a {geometry_type:?} nests its boundaries {expected} levels deep, but these boundaries are nested {found} levels deep" ), CjseqError::Io(e) => write!(f, "I/O error: {e}"), CjseqError::Validation(msg) => write!(f, "invalid CityJSON: {msg}"), diff --git a/src/geometry.rs b/src/geometry.rs index 7e24d60..3289efe 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -156,7 +156,7 @@ impl Geometry { return Err(CjseqError::GeometryDepth { geometry_type: thetype, expected, - found: format!("these boundaries are nested {found} levels deep"), + found, }); } } @@ -366,7 +366,10 @@ mod tests { serde_json::json!([[[0, 1, 2]]]), ), (GeometryType::Solid, serde_json::json!([[[[0, 1, 2]]]])), - (GeometryType::MultiSolid, serde_json::json!([[[[[0, 1, 2]]]]])), + ( + GeometryType::MultiSolid, + serde_json::json!([[[[[0, 1, 2]]]]]), + ), ( GeometryType::CompositeSolid, serde_json::json!([[[[[0, 1, 2]]]]]), @@ -512,11 +515,7 @@ mod tests { .unwrap(); assert_eq!( values, - serde_json::json!([ - [[0, 0, 1, 2, 100]], - [[0, 102, 101, 3, 102]], - [[null]] - ]), + serde_json::json!([[[0, 0, 1, 2, 100]], [[0, 102, 101, 3, 102]], [[null]]]), "texture values must renumber exactly as the old implementation did" ); @@ -586,9 +585,9 @@ mod tests { } => { assert_eq!(*geometry_type, GeometryType::MultiSurface); assert_eq!(*expected, 3, "a MultiSurface nests boundaries 3 deep"); - assert!( - found.contains('4'), - "the message must say what depth was actually found, got {found:?}" + assert_eq!( + *found, 4, + "the depth actually found must be reported as a number, not prose" ); } other => panic!("expected a GeometryDepth error, got {other:?}"), diff --git a/src/lib.rs b/src/lib.rs index ded15d0..3802989 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,12 +17,14 @@ //! //! Every type is re-exported at the crate root, so the module boundaries //! above are an internal organisational detail, not part of the public -//! import paths. +//! import paths. The one exception is the `Result` alias, which stays at +//! [`error::Result`] so that a glob `use cjseq::*` cannot shadow +//! `std::result::Result`. mod appearance; mod city_object; mod cityjson; -mod error; +pub mod error; mod geometry; mod metadata; mod semantics; @@ -30,7 +32,12 @@ mod semantics; pub use appearance::*; pub use city_object::*; pub use cityjson::*; -pub use error::*; +//-- Deliberately *not* `pub use error::*`. That would put a `Result` alias +//-- at the crate root, and `use cjseq::*` would then shadow +//-- `std::result::Result` in the importing module -- a surprise this crate has +//-- no business inflicting on its callers. `CjseqError` is re-exported by +//-- name; the alias stays reachable as `cjseq::error::Result`. +pub use error::CjseqError; pub use geometry::*; pub use metadata::*; pub use semantics::*; diff --git a/src/main.rs b/src/main.rs index 08a204a..56fef6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ +use cjseq::error::Result; use cjseq::CityJSON; use cjseq::CityJSONFeature; use cjseq::CityJSONType; use cjseq::CityObjectType; use cjseq::CjseqError; -use cjseq::Result; use cjseq::Transform; extern crate clap; diff --git a/src/metadata.rs b/src/metadata.rs index 3262f4e..4b62d63 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -173,15 +173,23 @@ impl ReferenceSystem { /// friends) but does not enforce, so a longer or shorter path is carried /// through rather than rejected. pub fn from_url(url: &str) -> crate::error::Result { - //-- the schema's pattern is `^(http|https)://www.opengis.net/def/crs/` - const PREFIX: &str = "//www.opengis.net/def/crs/"; - let Some(i) = url.find(PREFIX) else { + //-- the schema's pattern is `^(http|https)://www.opengis.net/def/crs/`, + //-- and the `^` is load-bearing: it anchors the scheme. Searching for + //-- the host anywhere in the string would accept `ftp://www.opengis + //-- .net/def/crs/x`, or any URL that merely embeds the host in a query + //-- string, neither of which the pattern matches. So: `starts_with`, + //-- once per permitted scheme. + const PREFIXES: [&str; 2] = [ + "http://www.opengis.net/def/crs/", + "https://www.opengis.net/def/crs/", + ]; + let Some(prefix) = PREFIXES.iter().find(|p| url.starts_with(**p)) else { return Err(CjseqError::Validation(format!( "not a CRS URL: {url:?} does not match the required prefix \ (http|https)://www.opengis.net/def/crs/" ))); }; - let split = i + PREFIX.len(); + let split = prefix.len(); Ok(ReferenceSystem { //-- everything before the `/` that separates prefix from path base_url: url[..split - 1].to_string(), @@ -460,6 +468,12 @@ mod tests { //-- the prefix pattern ends in a slash; `.../crs` alone lacks it "https://www.opengis.net/def/crs", "", + //-- the pattern is anchored, and names exactly two schemes. A + //-- substring search for the host would have let all three of + //-- these through with a nonsense `base_url`. + "ftp://www.opengis.net/def/crs/EPSG/0/7415", + "www.opengis.net/def/crs/EPSG/0/7415", + "https://example.com/?redirect=https://www.opengis.net/def/crs/EPSG/0/7415", ] { assert!( ReferenceSystem::from_url(bad).is_err(), diff --git a/src/semantics.rs b/src/semantics.rs index eb8ca86..2bd8e9d 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -43,6 +43,31 @@ pub enum KnownSemanticSurfaceType { /// for why a flat mix cannot round-trip (a unit variant under `untagged` /// (de)serializes as `null`, not its name; this was verified empirically /// while building `CityObjectType`, which has the identical shape). +/// +/// # Match on the value, not on a reference +/// +/// Each known name is also available flat, as an associated `const` (eg +/// [`SemanticSurfaceType::RoofSurface`]) -- see the `impl` block below for +/// the full list. They are `const`s, not variants, and that distinction bites +/// exactly once, silently: +/// +/// ``` +/// # use cjseq::SemanticSurfaceType; +/// # let t = &SemanticSurfaceType::RoofSurface; +/// match *t { +/// SemanticSurfaceType::RoofSurface => { /* .. */ } +/// SemanticSurfaceType::Extension(ref s) => { let _ = s; } +/// _ => {} +/// } +/// ``` +/// +/// Note the `*`. `match t` where `t: &SemanticSurfaceType`, or `match &t`, is +/// `E0308` ("mismatched types"), and rustc's fix-it suggests renaming the arm +/// to a fresh binding -- which compiles, and silently turns that arm into a +/// catch-all matching everything, because `RoofSurface` in pattern position +/// reads as a new irrefutable binding rather than as the constant. The `_` +/// arm is still required: `Known` and `Extension` are the real variants, so +/// the known names alone are not exhaustive. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(untagged)] pub enum SemanticSurfaceType { @@ -55,15 +80,9 @@ pub enum SemanticSurfaceType { /// Flat, spec-spelling access to each known variant (eg /// `SemanticSurfaceType::RoofSurface`); see [`crate::CityObjectType`]'s /// equivalent block for why these are associated `const`s rather than enum -/// variants. -/// -/// Match on the value, not a reference: `match *t { -/// SemanticSurfaceType::RoofSurface => .., SemanticSurfaceType::Extension(ref -/// s) => .., _ => .. }`, not `match &t`/`match t` where `t: -/// &SemanticSurfaceType`. Matching a `const` path against a reference is -/// `E0308`, and rustc's fix-it suggestion (rename the arm to a binding) -/// compiles into a silent catch-all instead of the intended check -- see -/// [`crate::CityObjectType`]'s equivalent block for the full explanation. +/// variants -- and, on +/// [`SemanticSurfaceType`][SemanticSurfaceType#match-on-the-value-not-on-a-reference], +/// for why they must be matched on a value rather than on a reference. #[allow(non_upper_case_globals)] impl SemanticSurfaceType { pub const RoofSurface: SemanticSurfaceType = @@ -291,7 +310,8 @@ mod tests { /// variant its depth implies, not merely re-serialize to the same JSON. #[test] fn each_shape_lands_in_the_variant_its_depth_implies() { - let parse = |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() }; + let parse = + |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() }; assert!(matches!( parse(serde_json::json!([0, 1, null])), From 7584db6872b91cfb9f0b65f6e625d794b9abdf4a Mon Sep 17 00:00:00 2001 From: HideBa Date: Wed, 22 Jul 2026 14:12:27 +0200 Subject: [PATCH 35/65] fix: gate wasm bindings behind an off-by-default `wasm` feature The wasm-bindgen bindings in src/wasm.rs were exposed via a bare target_arch = "wasm32" cfg, so every downstream library consumer building for wasm32 inherited them automatically. That collided with flatcitybuf's fcb_wasm crate, which defines its own cjseqToCj export, producing a duplicate-symbol link error. Move wasm-bindgen, serde-wasm-bindgen, web-sys, and console_error_panic_hook behind a new off-by-default `wasm` feature, and gate src/lib.rs's wasm module on both the target and the feature. getrandom stays unconditional under the wasm32 target table, since rand needs its `js` feature regardless of whether the bindings are compiled. cjseq's own npm build now passes --features wasm to opt in explicitly. Bump to 0.2.0-alpha.1. --- .github/workflows/publish-npm.yml | 2 +- CHANGELOG.md | 4 ++++ Cargo.toml | 12 ++++++++---- src/lib.rs | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 7cc0c86..5592381 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -30,7 +30,7 @@ jobs: - name: Build WASM run: | - wasm-pack build --target web --scope cityjson + wasm-pack build --target web --scope cityjson -- --features wasm - name: Publish to NPM env: diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a27b9..2ff8e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [0.2.0-alpha.1] - 2026-07-22 +### Modified +- wasm-bindgen bindings are now behind an off-by-default `wasm` cargo feature, so library consumers building for wasm32 no longer inherit them; fixes a duplicate-symbol clash for downstream wasm consumers that define their own bindings. + ## [0.4.0] - 2025-09-12 ### Modified - cjseq is now a library + has one binary. Some the functionalities that were in the `main.rs` are now in the `lib.rs` and the binary basically just adds functions to filter and calls the functions of `lib.rs`. This was done also to easily create a Python package. diff --git a/Cargo.toml b/Cargo.toml index 1a08645..e2206df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cjseq2" -version = "0.2.0-alpha.0" +version = "0.2.0-alpha.1" description = "Typed fork of cjseq (create+process+modify+convert CityJSONSeq) pending upstream merge" authors = ["Hugo Ledoux "] edition = "2021" @@ -23,14 +23,18 @@ serde_json = "1.0" # serde_derive = "1.0" # WASM dependencies -wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } -serde-wasm-bindgen = "0.6" +wasm-bindgen = { version = "0.2", features = ["serde-serialize"], optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } [dependencies.web-sys] version = "0.3" features = ["console"] +optional = true # WASM-specific dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] -console_error_panic_hook = "0.1" +console_error_panic_hook = { version = "0.1", optional = true } getrandom = { version = "0.2", features = ["js"] } + +[features] +wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:web-sys", "dep:console_error_panic_hook"] diff --git a/src/lib.rs b/src/lib.rs index 3802989..671db22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,9 +43,9 @@ pub use metadata::*; pub use semantics::*; // WASM bindings module -#[cfg(target_arch = "wasm32")] +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] pub mod wasm; // Re-export WASM functions for convenience -#[cfg(target_arch = "wasm32")] +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] pub use wasm::*; From 0d4e4dd854f933c1a5b035317d69676cb12ce276 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 13:20:48 +0000 Subject: [PATCH 36/65] chore!: publish as cjseq 0.5.0 again, not cjseq2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed rewrite carried the fork's package identity (cjseq2 0.2.0-alpha.x, "typed fork … pending upstream merge"). Upstream publishes cjseq, so the package, version and crate docs say so again. The binary is `cjseq` again too. --- CHANGELOG.md | 18 +++++++++++++++--- Cargo.toml | 7 +++---- src/lib.rs | 9 ++------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff8e1f..f431a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,20 @@ # Changelog -## [0.2.0-alpha.1] - 2026-07-22 -### Modified -- wasm-bindgen bindings are now behind an off-by-default `wasm` cargo feature, so library consumers building for wasm32 no longer inherit them; fixes a duplicate-symbol clash for downstream wasm consumers that define their own bindings. +## [0.5.0] - unreleased +### Modified (breaking) +- The library is split into per-concern modules (`geometry`, `semantics`, `appearance`, `city_object`, `metadata`, `error`, `cityjson`); every type is still re-exported at the crate root. +- Geometry boundaries, semantics values and appearance values carry their nesting depth in their type; `NestedArray` is removed. Null sub-arrays are accepted where the CityJSON schema allows them, and an explicit `null` material `values` stays distinct from an absent one. +- CityObject and semantic-surface types are typed enums, with support for extension types (`+Name`). +- Materials and textures in the appearance library are typed structs; members the schema does not name are rejected. +- `CityObject.geographicalExtent` is `[f64; 6]`, texture vertices are `[f64; 2]`. +- Errors are a `CjseqError` type; `Result` lives at `cjseq::error::Result` and is no longer re-exported at the root. +- wasm-bindgen bindings are behind an off-by-default `wasm` cargo feature, so library consumers building for wasm32 no longer inherit them. +### Added +- `CityObject::new`, `children_roles` on `CityObject`, `PartialEq`/`Eq` on the public structs. +- Round-trip tests over real CityJSONSeq fixtures. +### Fixed +- Metadata, address, `pointOfContact`, semantics and appearance members that the schema permits are kept instead of dropped or rejected. +- Every `referenceSystem` the schema's URL pattern permits is accepted. ## [0.4.0] - 2025-09-12 ### Modified diff --git a/Cargo.toml b/Cargo.toml index e2206df..2c47cb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "cjseq2" -version = "0.2.0-alpha.1" -description = "Typed fork of cjseq (create+process+modify+convert CityJSONSeq) pending upstream merge" +name = "cjseq" +version = "0.5.0" +description = "Create+process+modify+convert CityJSONSeq" authors = ["Hugo Ledoux "] edition = "2021" license = "MIT" @@ -10,7 +10,6 @@ homepage = "https://github.com/cityjson/cjseq" repository = "https://github.com/cityjson/cjseq" [lib] -name = "cjseq" crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/lib.rs b/src/lib.rs index 671db22..ccdb12e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,5 @@ -//! `cjseq2` is a typed fork of [`cjseq`](https://github.com/cityjson/cjseq), -//! for reading, writing, and converting between CityJSON and CityJSONSeq. -//! -//! The library crate is still named `cjseq` (see `Cargo.toml`'s `[lib]` -//! section), so existing code that does `use cjseq::CityJSON` keeps working -//! unchanged. The package itself is published as `cjseq2` pending an -//! eventual merge upstream. +//! `cjseq` reads, writes, and converts between CityJSON and CityJSONSeq, +//! with the CityJSON schema's structure carried in its types. //! //! Internally the crate is organised into one module per concern: //! - `geometry` — `Geometry`, `GeometryType`, `GeometryTemplates` From 448314279c133c85f0881a2771f48cb818a6767e Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:01:22 +0200 Subject: [PATCH 37/65] ci: run fmt, clippy -D warnings, tests and the wasm build Replaces the deprecated actions-rs toolchain action with dtolnay/rust-toolchain, runs clippy over all targets with warnings denied, and builds the wasm feature for wasm32. The clippy fixes this needed include implementing FromStr and Default for CityJSON and CityJSONFeature in place of inherent from_str/new. --- .github/workflows/ci.yml | 16 ++- .github/workflows/publish-crate.yml | 5 +- .github/workflows/publish-npm.yml | 5 +- src/city_object.rs | 19 +-- src/cityjson.rs | 180 ++++++++++++++-------------- src/main.rs | 76 ++++++------ tests/cj_input.rs | 8 +- tests/cjf_input.rs | 10 +- tests/roundtrip.rs | 1 + 9 files changed, 154 insertions(+), 166 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2212ddc..f1628be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ -name: Cargo Check +name: CI on: push: branches: - main + - develop pull_request: jobs: check: @@ -10,12 +11,15 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Rust - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@stable with: - toolchain: stable + components: rustfmt, clippy + targets: wasm32-unknown-unknown - name: Run fmt run: cargo fmt -- --check - name: Run clippy - run: cargo clippy - - name: Run check - run: cargo check + run: cargo clippy --all-targets -- -D warnings + - name: Run tests + run: cargo test + - name: Build wasm + run: cargo build --target wasm32-unknown-unknown --features wasm diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index 8e518d7..f2844f3 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -23,10 +23,7 @@ jobs: fetch-depth: 0 - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true + uses: dtolnay/rust-toolchain@stable - name: Install cargo-workspaces run: cargo install cargo-workspaces diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 5592381..b0ae177 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -19,10 +19,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true + uses: dtolnay/rust-toolchain@stable - name: Install wasm-pack run: | diff --git a/src/city_object.rs b/src/city_object.rs index 2cba182..45cf42d 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -233,25 +233,16 @@ impl CityObject { } pub(crate) fn is_toplevel(&self) -> bool { match &self.parents { - Some(x) => { - if x.is_empty() { - return true; - } else { - return false; - } - } - None => return true, + Some(x) => x.is_empty(), + None => true, } } pub(crate) fn get_children_keys(&self) -> Vec { let mut re: Vec = Vec::new(); - match &self.children { - Some(x) => { - for each in x { - re.push(each.to_string()); - } + if let Some(x) = &self.children { + for each in x { + re.push(each.to_string()); } - None => (), } re } diff --git a/src/cityjson.rs b/src/cityjson.rs index 1cb1e2e..feaf69e 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,11 +1,12 @@ use crate::appearance::{Appearance, MaterialObject, TextureObject}; use crate::city_object::CityObject; -use crate::error::Result; +use crate::error::{CjseqError, Result}; use crate::geometry::GeometryTemplates; use crate::metadata::{Metadata, Transform}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::str::FromStr; /// Slice a document-level appearance palette down to the entries one feature /// uses, in the order that feature now refers to them. @@ -86,6 +87,27 @@ impl PartialEq for CityJSON { } } +impl Default for CityJSON { + fn default() -> Self { + Self::new() + } +} + +impl FromStr for CityJSON { + type Err = CjseqError; + /// Parse a CityJSON document. + fn from_str(s: &str) -> Result { + let mut cjj: CityJSON = serde_json::from_str(s)?; + //-- check if CO exists, then add them to the sorted_ids + for (key, co) in &cjj.city_objects { + if co.is_toplevel() { + cjj.sorted_ids.push(key.clone()); + } + } + Ok(cjj) + } +} + impl CityJSON { /// Create a new CityJSON instance with default values. pub fn new() -> Self { @@ -107,18 +129,6 @@ impl CityJSON { transform_correction: None, } } - /// Create a new CityJSON instance from a string. - pub fn from_str(s: &str) -> Result { - let mut cjj: CityJSON = serde_json::from_str(s)?; - //-- check if CO exists, then add them to the sorted_ids - for (key, co) in &cjj.city_objects { - if co.is_toplevel() { - cjj.sorted_ids.push(key.clone()); - } - } - Ok(cjj) - } - /// Get the "first line" (aka metadata or header) of a CityJSONSeq pub fn get_metadata(&self) -> Self { let co: HashMap = HashMap::new(); @@ -139,35 +149,31 @@ impl CityJSON { }; //-- if geometry-templates have material/textures then these need to be //-- added to 1st line (metadata) - match &self.geometry_templates { - Some(x) => { - let mut gts2: GeometryTemplates = x.clone(); - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); - for g in &mut gts2.templates { - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + if let Some(x) = &self.geometry_templates { + let mut gts2: GeometryTemplates = x.clone(); + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); + for g in &mut gts2.templates { + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + } + //-- "slice" materials + if let Some(a) = &self.appearance { + let mut acjf: Appearance = Appearance::new(); + acjf.default_theme_material = a.default_theme_material.clone(); + acjf.default_theme_texture = a.default_theme_texture.clone(); + if let Some(am) = a.materials.as_ref() { + acjf.materials = Some(reindex(am, &m_oldnew)); } - //-- "slice" materials - if self.appearance.is_some() { - let a = self.appearance.as_ref().unwrap(); - let mut acjf: Appearance = Appearance::new(); - acjf.default_theme_material = a.default_theme_material.clone(); - acjf.default_theme_texture = a.default_theme_texture.clone(); - if let Some(am) = a.materials.as_ref() { - acjf.materials = Some(reindex(am, &m_oldnew)); - } - if let Some(at) = a.textures.as_ref() { - acjf.textures = Some(reindex(at, &t_oldnew)); - } - if let Some(atv) = a.vertices_texture.as_ref() { - acjf.vertices_texture = Some(reindex(atv, &t_v_oldnew)); - } - cj0.appearance = Some(acjf); + if let Some(at) = a.textures.as_ref() { + acjf.textures = Some(reindex(at, &t_oldnew)); + } + if let Some(atv) = a.vertices_texture.as_ref() { + acjf.vertices_texture = Some(reindex(atv, &t_v_oldnew)); } + cj0.appearance = Some(acjf); } - None => (), } cj0 } @@ -175,13 +181,9 @@ impl CityJSON { /// Starts at 0, and return Option::None if the index is out of bounds. pub fn get_cjfeature(&self, i: usize) -> Option { let i2 = self.sorted_ids.get(i); - if i2.is_none() { - return None; - } + i2?; let obj = self.city_objects.get(i2.unwrap()); - if obj.is_none() { - return None; - } + obj?; let co = obj.unwrap(); //-- the other lines let mut cjf = CityJSONFeature::new(); @@ -190,15 +192,12 @@ impl CityJSON { let mut m_oldnew: HashMap = HashMap::new(); let mut t_oldnew: HashMap = HashMap::new(); let mut t_v_oldnew: HashMap = HashMap::new(); - match &mut co2.geometry { - Some(x) => { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut g_vi_oldnew); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); - } + if let Some(x) = &mut co2.geometry { + for g in x.iter_mut() { + g.update_geometry_boundaries(&mut g_vi_oldnew); + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); } - None => (), } cjf.add_co(self.sorted_ids[i].clone(), co2); cjf.id = self.sorted_ids[i].to_string(); @@ -207,15 +206,12 @@ impl CityJSON { for childkey in co.get_children_keys() { let coc = self.city_objects.get(&childkey).unwrap(); let mut coc2: CityObject = coc.clone(); - match &mut coc2.geometry { - Some(x) => { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut g_vi_oldnew); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); - } + if let Some(x) = &mut coc2.geometry { + for g in x.iter_mut() { + g.update_geometry_boundaries(&mut g_vi_oldnew); + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); } - None => (), } cjf.add_co(childkey.clone(), coc2); } @@ -228,8 +224,7 @@ impl CityJSON { } cjf.vertices = g_new_vertices; //-- "slice" materials - if self.appearance.is_some() { - let a = self.appearance.as_ref().unwrap(); + if let Some(a) = &self.appearance { let mut acjf: Appearance = Appearance::new(); acjf.default_theme_material = a.default_theme_material.clone(); acjf.default_theme_texture = a.default_theme_texture.clone(); @@ -317,13 +312,10 @@ impl CityJSON { //-- update indices let cos = &mut self.city_objects; for (_key, co) in cos.iter_mut() { - match &mut co.geometry { - Some(x) => { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut newids); - } + if let Some(x) = &mut co.geometry { + for g in x.iter_mut() { + g.update_geometry_boundaries(&mut newids); } - None => (), } } //-- replace the vertices, innit? @@ -381,7 +373,7 @@ impl CityJSON { } pub fn number_of_city_objects(&self) -> usize { let mut total: usize = 0; - for (_key, co) in &self.city_objects { + for co in self.city_objects.values() { if co.is_toplevel() { total += 1; } @@ -415,11 +407,8 @@ impl CityJSON { self.city_objects.insert(id.clone(), co); } fn add_vertices(&mut self, vs: &mut Vec>) { - if self.transform_correction.is_none() { - self.vertices.append(vs); - } else { - //-- the transfrom correction needs to be applied - let c = self.transform_correction.as_ref().unwrap(); + if let Some(c) = &self.transform_correction { + //-- the transform correction needs to be applied for v in vs { let cx: i64 = (((v[0] as f64 * c.scale[0]) + c.translate[0] - self.transform.translate[0]) @@ -435,6 +424,8 @@ impl CityJSON { .round() as i64; self.vertices.push(vec![cx, cy, cz]); } + } else { + self.vertices.append(vs); } } fn add_vertices_texture(&mut self, vs: Vec<[f64; 2]>) { @@ -448,7 +439,7 @@ impl CityJSON { }; } fn add_material(&mut self, jm: MaterialObject) -> usize { - let re = match &mut self.appearance { + match &mut self.appearance { Some(x) => x.add_material(jm), None => { let mut a: Appearance = Appearance::new(); @@ -456,11 +447,10 @@ impl CityJSON { self.appearance = Some(a); re } - }; - re + } } fn add_texture(&mut self, jm: TextureObject) -> usize { - let re = match &mut self.appearance { + match &mut self.appearance { Some(x) => x.add_texture(jm), None => { let mut a: Appearance = Appearance::new(); @@ -468,8 +458,7 @@ impl CityJSON { self.appearance = Some(a); re } - }; - re + } } } @@ -484,6 +473,21 @@ pub struct CityJSONFeature { #[serde(skip_serializing_if = "Option::is_none")] pub appearance: Option, } + +impl Default for CityJSONFeature { + fn default() -> Self { + Self::new() + } +} + +impl FromStr for CityJSONFeature { + type Err = CjseqError; + /// Parse one CityJSONFeature (one line of a CityJSONSeq). + fn from_str(s: &str) -> Result { + Ok(serde_json::from_str(s)?) + } +} + impl CityJSONFeature { pub fn new() -> Self { let co: HashMap = HashMap::new(); @@ -496,24 +500,20 @@ impl CityJSONFeature { appearance: None, } } - pub fn from_str(s: &str) -> Result { - let cjf: CityJSONFeature = serde_json::from_str(s)?; - Ok(cjf) - } pub fn add_co(&mut self, id: String, co: CityObject) { self.city_objects.insert(id, co); } pub fn centroid(&self) -> Vec { let mut totals: Vec = vec![0., 0., 0.]; for v in &self.vertices { - for i in 0..3 { - totals[i] += v[i] as f64; + for (t, c) in totals.iter_mut().zip(v) { + *t += *c as f64; } } - for i in 0..3 { - totals[i] /= self.vertices.len() as f64; + for t in &mut totals { + *t /= self.vertices.len() as f64; } - return totals; + totals } } diff --git a/src/main.rs b/src/main.rs index 56fef6a..f2f3d44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use cjseq::CityJSONType; use cjseq::CityObjectType; use cjseq::CjseqError; use cjseq::Transform; +use std::str::FromStr; extern crate clap; use clap::{Parser, Subcommand, ValueEnum}; @@ -15,7 +16,7 @@ use std::fs::File; use std::io::BufRead; use std::io::BufReader; use std::io::{self, Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Parser)] #[command(version, about = "Create/process/modify CityJSONSeq files", long_about = None)] @@ -162,14 +163,14 @@ fn filter_random(exclude: bool, rand_factor: u32) -> Result<()> { if let Ok(l) = line { let j: Value = serde_json::from_str(&l)?; if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + io::stdout().write_all(format!("{}\n", l).as_bytes())?; } else { let r: u32 = rng.gen_range(1..=rand_factor); if r == 1 { w = true; } - if (w == true && !exclude) || (w == false && exclude) { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + if (w && !exclude) || (!w && exclude) { + io::stdout().write_all(format!("{}\n", l).as_bytes())?; } } } @@ -189,14 +190,14 @@ fn filter_cotype(exclude: bool, cotype: String) -> Result<()> { if let Ok(l) = line { let j: Value = serde_json::from_str(&l)?; if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + io::stdout().write_all(format!("{}\n", l).as_bytes())?; } else { let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; if cjf.city_objects[&cjf.id].get_type() == cotype { w = true; } - if (w == true && !exclude) || (w == false && exclude) { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + if (w && !exclude) || (!w && exclude) { + io::stdout().write_all(format!("{}\n", l).as_bytes())?; } } } @@ -205,7 +206,7 @@ fn filter_cotype(exclude: bool, cotype: String) -> Result<()> { Ok(()) } -fn filter_bbox(exclude: bool, bbox: &Vec) -> Result<()> { +fn filter_bbox(exclude: bool, bbox: &[f64]) -> Result<()> { let stdin = std::io::stdin(); let mut cj: CityJSON = CityJSON::new(); for line in stdin.lock().lines() { @@ -213,7 +214,7 @@ fn filter_bbox(exclude: bool, bbox: &Vec) -> Result<()> { if let Ok(l) = line { let j: Value = serde_json::from_str(&l)?; if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + io::stdout().write_all(format!("{}\n", l).as_bytes())?; cj = CityJSON::from_str(&l)?; } else { let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; @@ -223,8 +224,8 @@ fn filter_bbox(exclude: bool, bbox: &Vec) -> Result<()> { if (cx > bbox[0]) && (cx < bbox[2]) && (cy > bbox[1]) && (cy < bbox[3]) { w = true; } - if (w == true && !exclude) || (w == false && exclude) { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + if (w && !exclude) || (!w && exclude) { + io::stdout().write_all(format!("{}\n", l).as_bytes())?; } } } @@ -240,7 +241,7 @@ fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<()> { if let Ok(l) = line { let j: Value = serde_json::from_str(&l)?; if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + io::stdout().write_all(format!("{}\n", l).as_bytes())?; cj = CityJSON::from_str(&l)?; } else { let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; @@ -251,8 +252,8 @@ fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<()> { if d2 <= (r * r) { w = true; } - if (w == true && !exclude) || (w == false && exclude) { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; + if (w && !exclude) || (!w && exclude) { + io::stdout().write_all(format!("{}\n", l).as_bytes())?; } } } @@ -264,31 +265,29 @@ fn collect_from_stdin() -> Result<()> { let stdin = std::io::stdin(); let mut cjj = CityJSON::new(); let mut cjj_init: bool = false; - for line in stdin.lock().lines() { - if let Ok(json_line) = line { - let j: Value = serde_json::from_str(&json_line)?; - if j.get("type").unwrap() == "CityJSON" { - if cjj_init == false { - cjj = CityJSON::from_str(&json_line)?; - cjj_init = true; - } else { - let t: Transform = serde_json::from_value(j.get("transform").unwrap().clone())?; - cjj.add_transform_correction(t); - } + for json_line in stdin.lock().lines().map_while(std::result::Result::ok) { + let j: Value = serde_json::from_str(&json_line)?; + if j.get("type").unwrap() == "CityJSON" { + if !cjj_init { + cjj = CityJSON::from_str(&json_line)?; + cjj_init = true; } else { - let mut cjf = CityJSONFeature::from_str(&json_line)?; - cjj.add_cjfeature(&mut cjf); + let t: Transform = serde_json::from_value(j.get("transform").unwrap().clone())?; + cjj.add_transform_correction(t); } + } else { + let mut cjf = CityJSONFeature::from_str(&json_line)?; + cjj.add_cjfeature(&mut cjf); } } cjj.remove_duplicate_vertices(); cjj.update_transform(); cjj.update_geographicalextent(); - io::stdout().write_all(&format!("{}\n", serde_json::to_string(&cjj).unwrap()).as_bytes())?; + io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjj).unwrap()).as_bytes())?; Ok(()) } -fn collect_from_files(files: &Vec) -> Result<()> { +fn collect_from_files(files: &[String]) -> Result<()> { let mut cjj = CityJSON::new(); let mut cjj_init: bool = false; for file in files { @@ -299,16 +298,16 @@ fn collect_from_files(files: &Vec) -> Result<()> { match &line { Ok(l) => { if i == 0 { - if cjj_init == false { - cjj = CityJSON::from_str(&l)?; + if !cjj_init { + cjj = CityJSON::from_str(l)?; cjj_init = true; } else { - let cjj2 = CityJSON::from_str(&l)?; + let cjj2 = CityJSON::from_str(l)?; let t: Transform = cjj2.transform; cjj.add_transform_correction(t); } } else { - let mut cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; + let mut cjf: CityJSONFeature = CityJSONFeature::from_str(l)?; cjj.add_cjfeature(&mut cjf); } } @@ -319,7 +318,7 @@ fn collect_from_files(files: &Vec) -> Result<()> { cjj.remove_duplicate_vertices(); cjj.update_transform(); cjj.update_geographicalextent(); - io::stdout().write_all(&format!("{}\n", serde_json::to_string(&cjj).unwrap()).as_bytes())?; + io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjj).unwrap()).as_bytes())?; Ok(()) } @@ -328,7 +327,7 @@ fn cat_from_stdin(order: cjseq::SortingStrategy) -> Result<()> { match std::io::stdin().read_to_string(&mut input) { Ok(_) => { let mut cjj: CityJSON = CityJSON::from_str(&input)?; - let _ = cat(&mut cjj, order)?; + cat(&mut cjj, order)?; } Err(error) => { eprintln!("Error: {}", error); @@ -337,7 +336,7 @@ fn cat_from_stdin(order: cjseq::SortingStrategy) -> Result<()> { Ok(()) } -fn cat_from_file(file: &PathBuf, order: cjseq::SortingStrategy) -> Result<()> { +fn cat_from_file(file: &Path, order: cjseq::SortingStrategy) -> Result<()> { let f = File::open(file.canonicalize()?)?; let mut br = BufReader::new(f); let mut json_content = String::new(); @@ -361,13 +360,12 @@ fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { cjj.sort_cjfeatures(order); //-- first line: the CityJSON "metadata" let cj1 = cjj.get_metadata(); - io::stdout().write_all(&format!("{}\n", serde_json::to_string(&cj1).unwrap()).as_bytes())?; + io::stdout().write_all(format!("{}\n", serde_json::to_string(&cj1).unwrap()).as_bytes())?; //-- the other lines for each CityJSONSeq let mut i: usize = 0; while let Some(cjf) = cjj.get_cjfeature(i) { i += 1; - io::stdout() - .write_all(&format!("{}\n", serde_json::to_string(&cjf).unwrap()).as_bytes())?; + io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjf).unwrap()).as_bytes())?; } Ok(()) } diff --git a/tests/cj_input.rs b/tests/cj_input.rs index c414af4..a906ee9 100755 --- a/tests/cj_input.rs +++ b/tests/cj_input.rs @@ -1,5 +1,5 @@ -use crate::cjseq::CityJSON; -use cjseq; +use cjseq::CityJSON; +use std::str::FromStr; use serde_json::Value; use std::fs::File; @@ -37,8 +37,8 @@ fn get_empty_file() -> Value { } } "#; - let v = serde_json::from_str(&j_1).unwrap(); - v + + serde_json::from_str(j_1).unwrap() } #[test] diff --git a/tests/cjf_input.rs b/tests/cjf_input.rs index 51ec149..aaa88f5 100755 --- a/tests/cjf_input.rs +++ b/tests/cjf_input.rs @@ -1,6 +1,6 @@ -use crate::cjseq::{CityJSON, CityJSONFeature}; -use cjseq; +use cjseq::{CityJSON, CityJSONFeature}; use std::path::PathBuf; +use std::str::FromStr; use std::fs::File; use std::io::BufRead; @@ -16,12 +16,12 @@ fn test1() { match &line { Ok(l) => { if i == 0 { - cjj = CityJSON::from_str(&l).unwrap(); + cjj = CityJSON::from_str(l).unwrap(); assert!(cjj.number_of_city_objects() == 0); assert!(cjj.get_cjfeature(0).is_none()); - assert_eq!(cjj.vertices.is_empty(), true); + assert!(cjj.vertices.is_empty()); } else { - let mut cjf: CityJSONFeature = CityJSONFeature::from_str(&l).unwrap(); + let mut cjf: CityJSONFeature = CityJSONFeature::from_str(l).unwrap(); cjj.add_cjfeature(&mut cjf); assert_eq!(cjj.number_of_city_objects(), i); } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index b9b5b1e..5c2bfa4 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -17,6 +17,7 @@ use cjseq::{CityJSON, CityJSONFeature}; use serde_json::{Number, Value}; use std::path::{Path, PathBuf}; +use std::str::FromStr; /// Do two JSON numbers denote the same value? /// From b1e7407caafd762e0a8d5ccc54d544c9ee56ff7d Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:03:22 +0200 Subject: [PATCH 38/65] fix: keep texture coordinates when collecting several features add_cjfeature shifted a feature's texture-vertex indices by the feature's own UV count instead of the number of UVs already collected, and update_texture wrote the first use of a UV unshifted and later uses shifted. Every textured feature after the first came back pointing at the wrong UVs (8 of 9 rings on a two-building test). Collecting now maps each UV index to old + the collected count; slicing a document into features still compacts UVs in first-use order. The unit test that pinned the old numbering is replaced by tests of the correct behaviour, plus integration tests over several merged features, out-of-order UV use and a cat/collect round trip. serde_json's float_roundtrip feature is on, so coordinates such as 107072.02818522777 no longer drift by one ulp. --- Cargo.toml | 2 +- src/cityjson.rs | 21 ++++-- src/geometry.rs | 76 ++++++++++----------- tests/texture_merge.rs | 152 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 46 deletions(-) create mode 100644 tests/texture_merge.rs diff --git a/Cargo.toml b/Cargo.toml index 2c47cb6..3eca21c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib", "rlib"] clap = { version = "4.5.4", features = ["derive"] } rand = "0.8.5" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip"] } # serde_derive = "1.0" # WASM dependencies diff --git a/src/cityjson.rs b/src/cityjson.rs index feaf69e..5c8d45b 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -156,7 +156,7 @@ impl CityJSON { let mut t_v_oldnew: HashMap = HashMap::new(); for g in &mut gts2.templates { g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } //-- "slice" materials if let Some(a) = &self.appearance { @@ -196,7 +196,7 @@ impl CityJSON { for g in x.iter_mut() { g.update_geometry_boundaries(&mut g_vi_oldnew); g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } } cjf.add_co(self.sorted_ids[i].clone(), co2); @@ -210,7 +210,7 @@ impl CityJSON { for g in x.iter_mut() { g.update_geometry_boundaries(&mut g_vi_oldnew); g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 0); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } } cjf.add_co(childkey.clone(), coc2); @@ -251,7 +251,6 @@ impl CityJSON { let mut t_oldnew: HashMap = HashMap::new(); let mut t_v_oldnew: HashMap = HashMap::new(); let g_offset = self.vertices.len(); - let mut t_offset = 0; if let Some(cjf_app) = &cjf.appearance { if let Some(cjf_mat) = &cjf_app.materials { for (i, m) in cjf_mat.iter().enumerate() { @@ -264,7 +263,17 @@ impl CityJSON { } } if let Some(cjf_v_tex) = &cjf_app.vertices_texture { - t_offset = cjf_v_tex.len(); + //-- the feature's texture vertices are appended verbatim, so + //-- each one's new index is its old one shifted by however + //-- many were already collected + let t_v_offset = self + .appearance + .as_ref() + .and_then(|a| a.vertices_texture.as_ref()) + .map_or(0, Vec::len); + for i in 0..cjf_v_tex.len() { + t_v_oldnew.insert(i, t_v_offset + i); + } self.add_vertices_texture(cjf_v_tex.clone()); } } @@ -279,7 +288,7 @@ impl CityJSON { //-- material g.update_material(&mut m_oldnew); //-- texture - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, t_offset); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } } //-- update the collected json object by adding the CityObjects diff --git a/src/geometry.rs b/src/geometry.rs index 3289efe..6ff67f3 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -292,14 +292,14 @@ impl Geometry { } /// Renumber every texture index against `t_oldnew` and every texture - /// vertex against `t_v_oldnew`. As with `update_material`, the depth comes - /// from the variant, so every geometry that can carry a texture is walked - /// the same way. + /// vertex against `t_v_oldnew`, both in first-encounter order. An index + /// already in a map keeps the value the map gives it, so a caller that + /// knows the target numbering up front (as `CityJSON::add_cjfeature` + /// does) fills the map first. pub(crate) fn update_texture( &mut self, t_oldnew: &mut HashMap, t_v_oldnew: &mut HashMap, - offset: usize, ) { let Some(common) = self.common_mut() else { return; @@ -310,19 +310,13 @@ impl Geometry { //-- the first index of the innermost array is a texture, the rest are //-- texture vertices; they are renumbered against different maps. let mut remap = |is_texture: bool, thevalue: usize| -> usize { - if is_texture { - let l = t_oldnew.len(); - *t_oldnew.entry(thevalue).or_insert(l) + let map = if is_texture { + &mut *t_oldnew } else { - match t_v_oldnew.get(&thevalue) { - Some(y) => *y, - None => { - let l = t_v_oldnew.len(); - t_v_oldnew.insert(thevalue, l + offset); - l - } - } - } + &mut *t_v_oldnew + }; + let l = map.len(); + *map.entry(thevalue).or_insert(l) }; for tex in textures.values_mut() { //-- a theme may legally carry no `values` at all @@ -483,17 +477,11 @@ mod tests { } /// `update_texture` renumbers the first entry of each innermost array - /// against the texture map and the rest against the texture-vertex map. - /// - /// Every expected value below was produced by running the pre-rewrite - /// implementation (commit a97bc2d) on this exact input; the test exists to - /// prove the rewrite is behaviour-preserving. Note in particular that the - /// texture-vertex numbering is asymmetric: the *first* occurrence of a - /// vertex is written as `l` while later occurrences are written as the - /// stored `l + offset` (see 20 -> 0 then 20 -> 100 below). That is upstream - /// behaviour, quirk included, and it is pinned here deliberately. + /// against the texture map and the rest against the texture-vertex map, + /// both in first-encounter order, and a repeated index maps to the same + /// new index every time. #[test] - fn update_texture_renumbers_textures_and_texture_vertices() { + fn update_texture_compacts_textures_and_texture_vertices() { let mut g: Geometry = serde_json::from_value(serde_json::json!({ "type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2, 3]], [[4, 5, 6, 7]], [[8, 9, 10, 11]]], @@ -506,29 +494,39 @@ mod tests { .unwrap(); let mut t_oldnew: HashMap = HashMap::new(); let mut t_v_oldnew: HashMap = HashMap::new(); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew, 100); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); - //-- `values` is now typed, so compare its serialization: the expected - //-- values below are untouched from the oracle run at a97bc2d. let values = serde_json::to_value(&g.common().unwrap().texture.as_ref().unwrap()["winter"].values) .unwrap(); assert_eq!( values, - serde_json::json!([[[0, 0, 1, 2, 100]], [[0, 102, 101, 3, 102]], [[null]]]), - "texture values must renumber exactly as the old implementation did" + serde_json::json!([[[0, 0, 1, 2, 0]], [[0, 2, 1, 3, 2]], [[null]]]) ); - - let mut t: Vec<(usize, usize)> = t_oldnew.into_iter().collect(); - t.sort(); - assert_eq!(t, vec![(5, 0)], "the repeated texture index 5 maps once"); - let mut tv: Vec<(usize, usize)> = t_v_oldnew.into_iter().collect(); tv.sort(); + assert_eq!(tv, vec![(20, 0), (21, 1), (22, 2), (30, 3)]); + let t: Vec<(usize, usize)> = t_oldnew.into_iter().collect(); + assert_eq!(t, vec![(5, 0)]); + } + + /// An index the caller has already mapped keeps that mapping; this is how + /// collecting shifts a feature's texture vertices past those already + /// collected. + #[test] + fn update_texture_honours_a_prefilled_map() { + let mut g: Geometry = serde_json::from_value(serde_json::json!({ + "type": "MultiSurface", + "boundaries": [[[0, 1, 2]]], + "texture": {"t": {"values": [[[0, 2, 0, 1]]]}} + })) + .unwrap(); + let mut t_oldnew: HashMap = HashMap::from([(0, 3)]); + let mut t_v_oldnew: HashMap = (0..3).map(|i| (i, 10 + i)).collect(); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); assert_eq!( - tv, - vec![(20, 100), (21, 101), (22, 102), (30, 103)], - "texture vertices are stored offset by `offset`" + serde_json::to_value(&g).unwrap()["texture"]["t"]["values"], + serde_json::json!([[[3, 12, 10, 11]]]) ); } diff --git a/tests/texture_merge.rs b/tests/texture_merge.rs new file mode 100644 index 0000000..be5ca9c --- /dev/null +++ b/tests/texture_merge.rs @@ -0,0 +1,152 @@ +//! Texture coordinates must survive slicing a document into features and +//! collecting features back into a document. + +use cjseq::{cjseq_to_cj, CityJSON, CityJSONFeature}; +use serde_json::{json, Value}; +use std::str::FromStr; + +/// One textured MultiSurface feature. `uvs` is the feature's own +/// `vertices-texture` palette; `rings` lists, per surface, the palette +/// indices its exterior ring uses. +fn feature(id: &str, uvs: &[[f64; 2]], rings: &[[usize; 3]]) -> CityJSONFeature { + let boundaries: Vec = (0..rings.len()) + .map(|i| json!([[3 * i, 3 * i + 1, 3 * i + 2]])) + .collect(); + let values: Vec = rings + .iter() + .map(|r| json!([[0, r[0], r[1], r[2]]])) + .collect(); + let vertices: Vec = (0..3 * rings.len() as i64) + .map(|i| json!([i, i * 10, 0])) + .collect(); + serde_json::from_value(json!({ + "type": "CityJSONFeature", + "id": id, + "CityObjects": {id: {"type": "Building", "geometry": [{ + "type": "MultiSurface", "lod": "2", + "boundaries": boundaries, + "texture": {"t": {"values": values}} + }]}}, + "vertices": vertices, + "appearance": { + "textures": [{"type": "PNG", "image": "a.png"}], + "vertices-texture": uvs + } + })) + .unwrap() +} + +/// The UV coordinates each surface of `id` resolves to, in surface order. +fn resolved_uvs(cj: &CityJSON, id: &str) -> Vec> { + let v = serde_json::to_value(cj).unwrap(); + let palette: Vec<[f64; 2]> = + serde_json::from_value(v["appearance"]["vertices-texture"].clone()).unwrap(); + v["CityObjects"][id]["geometry"][0]["texture"]["t"]["values"] + .as_array() + .unwrap() + .iter() + .map(|surface| { + surface[0].as_array().unwrap()[1..] + .iter() + .map(|i| palette[i.as_u64().unwrap() as usize]) + .collect() + }) + .collect() +} + +fn header() -> CityJSON { + CityJSON::from_str( + &json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [] + }) + .to_string(), + ) + .unwrap() +} + +#[test] +fn collecting_several_textured_features_keeps_every_uv() { + let a_uvs = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1]]; + //-- b's palette is used out of first-use order, and reused across surfaces + let b_uvs = [[0.5, 0.5], [0.6, 0.5], [0.6, 0.6], [0.5, 0.6]]; + let a = feature("a", &a_uvs, &[[0, 1, 2]]); + let b = feature("b", &b_uvs, &[[3, 1, 2], [2, 0, 3]]); + let c = feature("c", &a_uvs, &[[2, 1, 0]]); + + let cj = cjseq_to_cj(header(), vec![a, b, c]); + + assert_eq!( + resolved_uvs(&cj, "a"), + vec![vec![a_uvs[0], a_uvs[1], a_uvs[2]]] + ); + assert_eq!( + resolved_uvs(&cj, "b"), + vec![ + vec![b_uvs[3], b_uvs[1], b_uvs[2]], + vec![b_uvs[2], b_uvs[0], b_uvs[3]] + ] + ); + assert_eq!( + resolved_uvs(&cj, "c"), + vec![vec![a_uvs[2], a_uvs[1], a_uvs[0]]] + ); +} + +#[test] +fn slicing_a_feature_out_keeps_only_the_uvs_it_uses() { + let uvs = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.9, 0.9], [0.8, 0.8]]; + //-- uses palette entries 4, 1, 2 only + let cj = cjseq_to_cj(header(), vec![feature("a", &uvs, &[[4, 1, 2]])]); + let cj = CityJSON::from_str(&serde_json::to_string(&cj).unwrap()).unwrap(); + + let f = cj.get_cjfeature(0).unwrap(); + let app = f.appearance.as_ref().unwrap(); + assert_eq!( + app.vertices_texture.as_deref(), + Some(&[uvs[4], uvs[1], uvs[2]][..]), + "a sliced feature's palette holds exactly the UVs it uses, in first-use order" + ); +} + +#[test] +fn a_cat_then_collect_round_trip_keeps_every_uv() { + let uvs = [[0.5, 0.5], [0.6, 0.5], [0.6, 0.6], [0.5, 0.6]]; + let doc = cjseq_to_cj( + header(), + vec![ + feature("a", &uvs, &[[3, 1, 2]]), + feature("b", &uvs, &[[0, 2, 1], [1, 3, 0]]), + ], + ); + let expected_a = resolved_uvs(&doc, "a"); + let expected_b = resolved_uvs(&doc, "b"); + + let mut doc = CityJSON::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + doc.sort_cjfeatures(cjseq::SortingStrategy::Lexicographical); + let mut features = Vec::new(); + while let Some(f) = doc.get_cjfeature(features.len()) { + features.push(f); + } + let back = cjseq_to_cj(doc.get_metadata(), features); + + assert_eq!(resolved_uvs(&back, "a"), expected_a); + assert_eq!(resolved_uvs(&back, "b"), expected_b); +} + +/// serde_json's default float parser can land one ulp off; with +/// `float_roundtrip` a coordinate survives a parse/serialize cycle exactly. +/// 107072.02818522777 is one the default parser reads as ...775. +#[test] +fn a_coordinate_float_round_trips_exactly() { + let input = r#"{"type":"CityJSON","version":"2.0","transform":{"scale":[0.001,0.001,0.001],"translate":[107072.02818522777,446000.0,0.0]},"CityObjects":{},"vertices":[]}"#; + let cj = CityJSON::from_str(input).unwrap(); + assert_eq!( + cj.transform.translate[0], + "107072.02818522777".parse::().unwrap() + ); + assert!(serde_json::to_string(&cj) + .unwrap() + .contains("107072.02818522777")); +} From 0f4f4de28b3e82b04590e19761653089531e8ee9 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:08:51 +0200 Subject: [PATCH 39/65] fix!: fixed-size arrays, ordered maps, and errors instead of panics - vertices are [i64; 3], transform scale/translate [f64; 3], vertices-templates Vec<[f64; 3]> and a GeometryInstance's boundaries [usize; 1], so malformed arrays fail to parse instead of panicking later. - CityObjects and the material/texture theme maps are IndexMaps, and serde_json keeps object order, so output follows input order and is deterministic. - get_cjfeature returns Result>: it takes every descendant of a top-level object (not only its children), survives parent/child cycles, and reports a dangling child (DanglingReference) or an out-of-range vertex or palette index (IndexOutOfRange) as an error. - update_transform, update_geographicalextent and centroid (now Option<[f64; 3]>) handle a document with no vertices. - SortingStrategy is AsIs | Random | Lexicographical. AsIs is the input order and the CLI default; Random really shuffles. The unimplemented Morton/Hilbert variants are gone. - remove_duplicate_vertices keys on the vertex itself, not a String, and the appearance-slicing code shared by get_metadata and get_cjfeature is one function. --- Cargo.toml | 3 +- src/city_object.rs | 9 - src/cityjson.rs | 488 ++++++++++++++++++----------------------- src/error.rs | 35 ++- src/geometry.rs | 16 +- src/lib.rs | 2 +- src/main.rs | 33 +-- src/metadata.rs | 10 +- tests/cj_input.rs | 6 +- tests/cjf_input.rs | 2 +- tests/robustness.rs | 210 ++++++++++++++++++ tests/texture_merge.rs | 4 +- 12 files changed, 506 insertions(+), 312 deletions(-) create mode 100644 tests/robustness.rs diff --git a/Cargo.toml b/Cargo.toml index 3eca21c..d5bd53a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,10 @@ crate-type = ["cdylib", "rlib"] [dependencies] clap = { version = "4.5.4", features = ["derive"] } +indexmap = { version = "2", features = ["serde"] } rand = "0.8.5" serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0", features = ["float_roundtrip"] } +serde_json = { version = "1.0", features = ["float_roundtrip", "preserve_order"] } # serde_derive = "1.0" # WASM dependencies diff --git a/src/city_object.rs b/src/city_object.rs index 45cf42d..b47e430 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -237,15 +237,6 @@ impl CityObject { None => true, } } - pub(crate) fn get_children_keys(&self) -> Vec { - let mut re: Vec = Vec::new(); - if let Some(x) = &self.children { - for each in x { - re.push(each.to_string()); - } - } - re - } } #[cfg(test)] diff --git a/src/cityjson.rs b/src/cityjson.rs index 5c8d45b..ab8f784 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,37 +1,72 @@ use crate::appearance::{Appearance, MaterialObject, TextureObject}; use crate::city_object::CityObject; -use crate::error::{CjseqError, Result}; +use crate::error::{CjseqError, IndexKind, Result}; use crate::geometry::GeometryTemplates; use crate::metadata::{Metadata, Transform}; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; -/// Slice a document-level appearance palette down to the entries one feature -/// uses, in the order that feature now refers to them. +/// One vertex: integer coordinates, to be scaled and translated by the +/// document's [`Transform`]. +pub type Vertex = [i64; 3]; + +/// Slice a palette down to the entries one feature uses, in the order that +/// feature now refers to them. /// /// `oldnew` maps each index used by the feature onto its dense position in the /// new palette; `update_material`/`update_texture` build it by handing out /// consecutive new indices, so its values are exactly `0..oldnew.len()` and /// sorting by them reproduces the palette. -/// -/// This replaces a `resize(len, json!(null))`-then-overwrite loop, which only -/// worked because every slot was subsequently overwritten: the `null` filler -/// was never a valid palette entry, and now that the palette is typed it is -/// not even representable. -fn reindex(all: &[T], oldnew: &HashMap) -> Vec { +fn reindex(all: &[T], oldnew: &HashMap, kind: IndexKind) -> Result> { let mut pairs: Vec<(usize, usize)> = oldnew.iter().map(|(o, n)| (*n, *o)).collect(); pairs.sort_unstable(); - pairs.into_iter().map(|(_, old)| all[old].clone()).collect() + pairs + .into_iter() + .map(|(_, old)| { + all.get(old).cloned().ok_or(CjseqError::IndexOutOfRange { + kind, + index: old, + len: all.len(), + }) + }) + .collect() +} + +/// The part of `all` that the renumbering maps `m_oldnew`, `t_oldnew` and +/// `t_v_oldnew` refer to, with the default themes carried over. +fn slice_appearance( + all: &Appearance, + m_oldnew: &HashMap, + t_oldnew: &HashMap, + t_v_oldnew: &HashMap, +) -> Result { + let mut a = Appearance::new(); + a.default_theme_material = all.default_theme_material.clone(); + a.default_theme_texture = all.default_theme_texture.clone(); + if let Some(m) = &all.materials { + a.materials = Some(reindex(m, m_oldnew, IndexKind::Material)?); + } + if let Some(t) = &all.textures { + a.textures = Some(reindex(t, t_oldnew, IndexKind::Texture)?); + } + if let Some(tv) = &all.vertices_texture { + a.vertices_texture = Some(reindex(tv, t_v_oldnew, IndexKind::TextureVertex)?); + } + Ok(a) } -#[derive(Clone)] +/// The order in which [`CityJSON::get_cjfeature`] hands out features. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SortingStrategy { + /// The order the top-level City Objects appear in the document. + AsIs, + /// A random permutation, different on every call. Random, + /// Sorted by City Object id. Lexicographical, - Morton, //-- TODO implement Morton sorting - Hilbert, //-- TODO implement Hilbert sorting } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -41,8 +76,8 @@ pub struct CityJSON { pub version: String, pub transform: Transform, #[serde(rename = "CityObjects")] - pub city_objects: HashMap, - pub vertices: Vec>, + pub city_objects: IndexMap, + pub vertices: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -60,18 +95,8 @@ pub struct CityJSON { transform_correction: Option, } -/// Hand-written rather than derived: `sorted_ids` and `transform_correction` -/// are `#[serde(skip)]` internal bookkeeping, not part of the CityJSON -/// document. `sorted_ids` in particular is populated by iterating -/// `city_objects` (a `HashMap`, whose iteration order is not guaranteed -/// stable across independently-constructed maps) and can be further -/// reordered by `sort_cjfeatures(SortingStrategy::Random)`, so a derived -/// `PartialEq` that included it would make `==` spuriously `false` for two -/// values representing the identical document. Excluding it here is what -/// makes equality mean "same document", which is the only sense of equality -/// this crate (or its callers) should ever want. This does not violate the -/// "never hand-write" constraint, which is about `Serialize`/`Deserialize` -/// -- a hand-written `PartialEq` cannot corrupt output. +/// Equality is over the document only: `sorted_ids` and +/// `transform_correction` are sequencing state, not CityJSON members. impl PartialEq for CityJSON { fn eq(&self, other: &Self) -> bool { self.thetype == other.thetype @@ -95,15 +120,11 @@ impl Default for CityJSON { impl FromStr for CityJSON { type Err = CjseqError; - /// Parse a CityJSON document. + /// Parse a CityJSON document. Its features are handed out in document + /// order until [`CityJSON::sort_cjfeatures`] says otherwise. fn from_str(s: &str) -> Result { let mut cjj: CityJSON = serde_json::from_str(s)?; - //-- check if CO exists, then add them to the sorted_ids - for (key, co) in &cjj.city_objects { - if co.is_toplevel() { - cjj.sorted_ids.push(key.clone()); - } - } + cjj.sort_cjfeatures(SortingStrategy::AsIs); Ok(cjj) } } @@ -111,15 +132,12 @@ impl FromStr for CityJSON { impl CityJSON { /// Create a new CityJSON instance with default values. pub fn new() -> Self { - let co: HashMap = HashMap::new(); - let v: Vec> = Vec::new(); - let tr = Transform::new(); CityJSON { thetype: CityJSONType::CityJSON, version: "2.0".to_string(), - transform: tr, - city_objects: co, - vertices: v, + transform: Transform::new(), + city_objects: IndexMap::new(), + vertices: Vec::new(), metadata: None, appearance: None, geometry_templates: None, @@ -131,15 +149,13 @@ impl CityJSON { } /// Get the "first line" (aka metadata or header) of a CityJSONSeq pub fn get_metadata(&self) -> Self { - let co: HashMap = HashMap::new(); - let v: Vec> = Vec::new(); let mut cj0 = CityJSON { thetype: self.thetype.clone(), version: self.version.clone(), transform: self.transform.clone(), metadata: self.metadata.clone(), - city_objects: co, - vertices: v, + city_objects: IndexMap::new(), + vertices: Vec::new(), appearance: None, geometry_templates: self.geometry_templates.clone(), other: self.other.clone(), @@ -158,88 +174,69 @@ impl CityJSON { g.update_material(&mut m_oldnew); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } - //-- "slice" materials if let Some(a) = &self.appearance { - let mut acjf: Appearance = Appearance::new(); - acjf.default_theme_material = a.default_theme_material.clone(); - acjf.default_theme_texture = a.default_theme_texture.clone(); - if let Some(am) = a.materials.as_ref() { - acjf.materials = Some(reindex(am, &m_oldnew)); - } - if let Some(at) = a.textures.as_ref() { - acjf.textures = Some(reindex(at, &t_oldnew)); - } - if let Some(atv) = a.vertices_texture.as_ref() { - acjf.vertices_texture = Some(reindex(atv, &t_v_oldnew)); - } - cj0.appearance = Some(acjf); + cj0.appearance = slice_appearance(a, &m_oldnew, &t_oldnew, &t_v_oldnew).ok(); } } cj0 } - /// Getter for the features in a CityJSON dataset. - /// Starts at 0, and return Option::None if the index is out of bounds. - pub fn get_cjfeature(&self, i: usize) -> Option { - let i2 = self.sorted_ids.get(i); - i2?; - let obj = self.city_objects.get(i2.unwrap()); - obj?; - let co = obj.unwrap(); - //-- the other lines + + /// The `i`th feature, in the order set by [`CityJSON::sort_cjfeatures`]: + /// a top-level City Object with all of its descendants, and only the + /// vertices and appearance entries they use. + /// + /// `Ok(None)` once `i` is past the last feature. A child id that names no + /// City Object, or an index past the end of `vertices` or of an appearance + /// palette, is an error. + pub fn get_cjfeature(&self, i: usize) -> Result> { + let Some(id) = self.sorted_ids.get(i) else { + return Ok(None); + }; + let Some(root) = self.city_objects.get(id) else { + return Ok(None); + }; + //-- the root, then every descendant depth-first; `seen` stops a + //-- parent/child cycle from looping + let mut members: Vec<(&String, &CityObject)> = vec![(id, root)]; + let mut seen: HashSet<&str> = HashSet::from([id.as_str()]); + let mut next = 0; + while next < members.len() { + let (parent_id, parent) = members[next]; + next += 1; + for child_id in parent.children.iter().flatten() { + if !seen.insert(child_id.as_str()) { + continue; + } + let child = self.city_objects.get(child_id).ok_or_else(|| { + CjseqError::DanglingReference { + from: parent_id.clone(), + to: child_id.clone(), + } + })?; + members.push((child_id, child)); + } + } + let mut cjf = CityJSONFeature::new(); - let mut co2: CityObject = co.clone(); + cjf.id = id.clone(); let mut g_vi_oldnew: HashMap = HashMap::new(); let mut m_oldnew: HashMap = HashMap::new(); let mut t_oldnew: HashMap = HashMap::new(); let mut t_v_oldnew: HashMap = HashMap::new(); - if let Some(x) = &mut co2.geometry { - for g in x.iter_mut() { + for (key, co) in members { + let mut co2 = co.clone(); + for g in co2.geometry.iter_mut().flatten() { g.update_geometry_boundaries(&mut g_vi_oldnew); g.update_material(&mut m_oldnew); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } + cjf.add_co(key.clone(), co2); } - cjf.add_co(self.sorted_ids[i].clone(), co2); - cjf.id = self.sorted_ids[i].to_string(); - //-- TODO: to fix: children-of-children? - //-- process all the children (only one-level lower) - for childkey in co.get_children_keys() { - let coc = self.city_objects.get(&childkey).unwrap(); - let mut coc2: CityObject = coc.clone(); - if let Some(x) = &mut coc2.geometry { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut g_vi_oldnew); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew); - } - } - cjf.add_co(childkey.clone(), coc2); - } - //-- "slice" geometry vertices - let allvertices = &self.vertices; - let mut g_new_vertices: Vec> = Vec::new(); - g_new_vertices.resize(g_vi_oldnew.len(), vec![]); - for (old, new) in &g_vi_oldnew { - g_new_vertices[*new] = allvertices[*old].clone(); - } - cjf.vertices = g_new_vertices; - //-- "slice" materials + cjf.vertices = reindex(&self.vertices, &g_vi_oldnew, IndexKind::Vertex)?; if let Some(a) = &self.appearance { - let mut acjf: Appearance = Appearance::new(); - acjf.default_theme_material = a.default_theme_material.clone(); - acjf.default_theme_texture = a.default_theme_texture.clone(); - if let Some(am) = a.materials.as_ref() { - acjf.materials = Some(reindex(am, &m_oldnew)); - } - if let Some(at) = a.textures.as_ref() { - acjf.textures = Some(reindex(at, &t_oldnew)); - } - if let Some(atv) = a.vertices_texture.as_ref() { - acjf.vertices_texture = Some(reindex(atv, &t_v_oldnew)); - } - cjf.appearance = Some(acjf); + cjf.appearance = Some(slice_appearance(a, &m_oldnew, &t_oldnew, &t_v_oldnew)?); } - Some(cjf) + Ok(Some(cjf)) } /// Used when many CityJSONSeq are used, the "transform" can /// be modified (the new value is a "correction"). @@ -279,195 +276,146 @@ impl CityJSON { } for (key, co) in &mut cjf.city_objects { - //-- boundaries - if let Some(ref mut geoms) = &mut co.geometry { - for g in geoms.iter_mut() { - //-- boundaries - g.offset_geometry_boundaries(g_offset); - // g.update_geometry_boundaries(&mut g_oldnew, g_offset); - //-- material - g.update_material(&mut m_oldnew); - //-- texture - g.update_texture(&mut t_oldnew, &mut t_v_oldnew); - } + for g in co.geometry.iter_mut().flatten() { + g.offset_geometry_boundaries(g_offset); + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } - //-- update the collected json object by adding the CityObjects self.add_co(key.to_string(), co.clone()); } - //-- add the new vertices self.add_vertices(&mut cjf.vertices); - //-- add the CO id to the list self.sorted_ids.push(cjf.id.clone()); } pub fn remove_duplicate_vertices(&mut self) { - // let totalinput = self.vertices.len(); - let mut h: HashMap = HashMap::new(); + let mut h: HashMap = HashMap::new(); let mut newids: HashMap = HashMap::new(); - let mut newvertices: Vec> = Vec::new(); + let mut newvertices: Vec = Vec::new(); for (i, v) in self.vertices.iter().enumerate() { - // println!("{:?}", v); - let k = format!("{} {} {}", v[0], v[1], v[2]); - match h.get(&k) { - Some(x) => { - let _ = newids.insert(i, *x); - } - None => { - newids.insert(i, newvertices.len()); - h.insert(k.clone(), newvertices.len()); - newvertices.push(v.clone()); - } - } + let new = *h.entry(*v).or_insert_with(|| { + newvertices.push(*v); + newvertices.len() - 1 + }); + newids.insert(i, new); } - //-- update indices - let cos = &mut self.city_objects; - for (_key, co) in cos.iter_mut() { - if let Some(x) = &mut co.geometry { - for g in x.iter_mut() { - g.update_geometry_boundaries(&mut newids); - } + for co in self.city_objects.values_mut() { + for g in co.geometry.iter_mut().flatten() { + g.update_geometry_boundaries(&mut newids); } } - //-- replace the vertices, innit? self.vertices = newvertices; } - pub fn update_geographicalextent(&mut self) { - if let Some(m) = &mut self.metadata { - if let Some(ref mut ge) = m.geographical_extent { - let mut mins: Vec = vec![i64::MAX, i64::MAX, i64::MAX]; - let mut maxs: Vec = vec![i64::MIN, i64::MIN, i64::MIN]; - for v in &self.vertices { - for i in 0..3 { - if v[i] < mins[i] { - mins[i] = v[i]; - } - if v[i] > maxs[i] { - maxs[i] = v[i]; - } + /// The per-axis minimum and maximum of `vertices`, or `None` if there are + /// none. + fn vertex_bounds(&self) -> Option<(Vertex, Vertex)> { + let first = *self.vertices.first()?; + Some( + self.vertices + .iter() + .fold((first, first), |(mut lo, mut hi), v| { + for k in 0..3 { + lo[k] = lo[k].min(v[k]); + hi[k] = hi[k].max(v[k]); } - } - *ge = [ - mins[0] as f64 * self.transform.scale[0] + self.transform.translate[0], - mins[1] as f64 * self.transform.scale[1] + self.transform.translate[1], - mins[2] as f64 * self.transform.scale[2] + self.transform.translate[2], - maxs[0] as f64 * self.transform.scale[0] + self.transform.translate[0], - maxs[1] as f64 * self.transform.scale[1] + self.transform.translate[1], - maxs[2] as f64 * self.transform.scale[2] + self.transform.translate[2], - ]; - } + (lo, hi) + }), + ) + } + /// Recompute `metadata.geographicalExtent` from the vertices, if the + /// document has one. With no vertices it is left as it is. + pub fn update_geographicalextent(&mut self) { + let Some((mins, maxs)) = self.vertex_bounds() else { + return; + }; + let t = &self.transform; + let real = |v: Vertex, k: usize| v[k] as f64 * t.scale[k] + t.translate[k]; + if let Some(ge) = self + .metadata + .as_mut() + .and_then(|m| m.geographical_extent.as_mut()) + { + *ge = [ + real(mins, 0), + real(mins, 1), + real(mins, 2), + real(maxs, 0), + real(maxs, 1), + real(maxs, 2), + ]; } } + /// Move `transform.translate` to the vertices' minimum corner, so the + /// stored integers start at 0. With no vertices nothing changes. pub fn update_transform(&mut self) { - let mut newvertices: Vec> = Vec::new(); - let mut mins: Vec = vec![i64::MAX, i64::MAX, i64::MAX]; - //-- find min-xyz - for v in &self.vertices { - for i in 0..3 { - if v[i] < mins[i] { - mins[i] = v[i]; - } + let Some((mins, _)) = self.vertex_bounds() else { + return; + }; + for v in &mut self.vertices { + for (c, m) in v.iter_mut().zip(mins) { + *c -= m; } } - //-- subtract the mins from each vertex - for v in &self.vertices { - let v: Vec = vec![v[0] - mins[0], v[1] - mins[1], v[2] - mins[2]]; - newvertices.push(v); + let t = &mut self.transform; + for ((tr, sc), m) in t.translate.iter_mut().zip(t.scale).zip(mins) { + *tr += m as f64 * sc; } - //-- replace the vertices, innit? - self.vertices = newvertices; - //-- update the transform/translate - let ttx = (mins[0] as f64 * self.transform.scale[0]) + self.transform.translate[0]; - let tty = (mins[1] as f64 * self.transform.scale[1]) + self.transform.translate[1]; - let ttz = (mins[2] as f64 * self.transform.scale[2]) + self.transform.translate[2]; - self.transform.translate = vec![ttx, tty, ttz]; } pub fn number_of_city_objects(&self) -> usize { - let mut total: usize = 0; - for co in self.city_objects.values() { - if co.is_toplevel() { - total += 1; - } - } - total + self.city_objects + .values() + .filter(|co| co.is_toplevel()) + .count() } - /// When getting the CityJSONFeatures, this controls the order in which - /// they are returned. By default they are returned in the order they were added. + /// Set the order in which [`CityJSON::get_cjfeature`] hands out features. pub fn sort_cjfeatures(&mut self, ss: SortingStrategy) { - self.sorted_ids.clear(); + self.sorted_ids = self + .city_objects + .iter() + .filter(|(_, co)| co.is_toplevel()) + .map(|(key, _)| key.clone()) + .collect(); match ss { + SortingStrategy::AsIs => {} SortingStrategy::Random => { - for (key, co) in &self.city_objects { - if co.is_toplevel() { - self.sorted_ids.push(key.clone()); - } - } - } - SortingStrategy::Lexicographical => { - for (key, co) in &self.city_objects { - if co.is_toplevel() { - self.sorted_ids.push(key.clone()); - } - } - self.sorted_ids.sort(); + use rand::seq::SliceRandom; + self.sorted_ids.shuffle(&mut rand::thread_rng()); } - _ => todo!(), + SortingStrategy::Lexicographical => self.sorted_ids.sort(), } } fn add_co(&mut self, id: String, co: CityObject) { - self.city_objects.insert(id.clone(), co); + self.city_objects.insert(id, co); } - fn add_vertices(&mut self, vs: &mut Vec>) { + fn add_vertices(&mut self, vs: &mut Vec) { if let Some(c) = &self.transform_correction { - //-- the transform correction needs to be applied - for v in vs { - let cx: i64 = (((v[0] as f64 * c.scale[0]) + c.translate[0] - - self.transform.translate[0]) - / self.transform.scale[0]) - .round() as i64; - let cy: i64 = (((v[1] as f64 * c.scale[1]) + c.translate[1] - - self.transform.translate[1]) - / self.transform.scale[1]) - .round() as i64; - let cz: i64 = (((v[2] as f64 * c.scale[2]) + c.translate[2] - - self.transform.translate[2]) - / self.transform.scale[2]) - .round() as i64; - self.vertices.push(vec![cx, cy, cz]); + //-- re-express each vertex in this document's transform + let t = &self.transform; + for v in vs.iter() { + let mut out = [0; 3]; + for k in 0..3 { + out[k] = (((v[k] as f64 * c.scale[k]) + c.translate[k] - t.translate[k]) + / t.scale[k]) + .round() as i64; + } + self.vertices.push(out); } } else { self.vertices.append(vs); } } fn add_vertices_texture(&mut self, vs: Vec<[f64; 2]>) { - match &mut self.appearance { - Some(x) => x.add_vertices_texture(vs), - None => { - let mut a: Appearance = Appearance::new(); - a.add_vertices_texture(vs); - self.appearance = Some(a); - } - }; + self.appearance + .get_or_insert_with(Appearance::new) + .add_vertices_texture(vs); } fn add_material(&mut self, jm: MaterialObject) -> usize { - match &mut self.appearance { - Some(x) => x.add_material(jm), - None => { - let mut a: Appearance = Appearance::new(); - let re = a.add_material(jm); - self.appearance = Some(a); - re - } - } + self.appearance + .get_or_insert_with(Appearance::new) + .add_material(jm) } fn add_texture(&mut self, jm: TextureObject) -> usize { - match &mut self.appearance { - Some(x) => x.add_texture(jm), - None => { - let mut a: Appearance = Appearance::new(); - let re = a.add_texture(jm); - self.appearance = Some(a); - re - } - } + self.appearance + .get_or_insert_with(Appearance::new) + .add_texture(jm) } } @@ -477,8 +425,8 @@ pub struct CityJSONFeature { pub thetype: CityJSONFeatureType, pub id: String, #[serde(rename = "CityObjects")] - pub city_objects: HashMap, - pub vertices: Vec>, + pub city_objects: IndexMap, + pub vertices: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub appearance: Option, } @@ -499,30 +447,30 @@ impl FromStr for CityJSONFeature { impl CityJSONFeature { pub fn new() -> Self { - let co: HashMap = HashMap::new(); - let v: Vec> = Vec::new(); CityJSONFeature { thetype: CityJSONFeatureType::CityJSONFeature, id: "".to_string(), - city_objects: co, - vertices: v, + city_objects: IndexMap::new(), + vertices: Vec::new(), appearance: None, } } pub fn add_co(&mut self, id: String, co: CityObject) { self.city_objects.insert(id, co); } - pub fn centroid(&self) -> Vec { - let mut totals: Vec = vec![0., 0., 0.]; + /// The mean of the feature's vertices, in the integer (untransformed) + /// coordinates they are stored in; `None` if it has none. + pub fn centroid(&self) -> Option<[f64; 3]> { + if self.vertices.is_empty() { + return None; + } + let mut totals = [0.0; 3]; for v in &self.vertices { - for (t, c) in totals.iter_mut().zip(v) { - *t += *c as f64; + for k in 0..3 { + totals[k] += v[k] as f64; } } - for t in &mut totals { - *t /= self.vertices.len() as f64; - } - totals + Some(totals.map(|t| t / self.vertices.len() as f64)) } } diff --git a/src/error.rs b/src/error.rs index 8073b95..d7cef7b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,6 +28,30 @@ pub enum CjseqError { /// what serde alone can express -- eg its `type` is not `"CityJSON"`, or /// its `version` is not one this crate supports. Validation(String), + /// A City Object names, as one of its children, an id that is not in the + /// document. + DanglingReference { + /// The City Object holding the reference. + from: String, + /// The id it refers to. + to: String, + }, + /// An index points past the end of the array it indexes. + IndexOutOfRange { + kind: IndexKind, + index: usize, + /// The length of the array indexed. + len: usize, + }, +} + +/// What an out-of-range index was indexing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexKind { + Vertex, + TextureVertex, + Material, + Texture, } impl fmt::Display for CjseqError { @@ -44,6 +68,13 @@ impl fmt::Display for CjseqError { ), CjseqError::Io(e) => write!(f, "I/O error: {e}"), CjseqError::Validation(msg) => write!(f, "invalid CityJSON: {msg}"), + CjseqError::DanglingReference { from, to } => write!( + f, + "City Object {from:?} refers to {to:?}, which is not in the document" + ), + CjseqError::IndexOutOfRange { kind, index, len } => { + write!(f, "{kind:?} index {index} is out of range (there are {len})") + } } } } @@ -54,7 +85,9 @@ impl std::error::Error for CjseqError { CjseqError::Json(e) => Some(e), CjseqError::GeometryDepth { .. } => None, CjseqError::Io(e) => Some(e), - CjseqError::Validation(_) => None, + CjseqError::Validation(_) + | CjseqError::DanglingReference { .. } + | CjseqError::IndexOutOfRange { .. } => None, } } } diff --git a/src/geometry.rs b/src/geometry.rs index 6ff67f3..af972d5 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,6 +1,7 @@ use crate::appearance::{MaterialReference, TextureReference}; use crate::error::{CjseqError, Result}; use crate::semantics::Semantics; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -47,9 +48,9 @@ pub struct GeometryCommon { #[serde(skip_serializing_if = "Option::is_none")] pub semantics: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub material: Option>, + pub material: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub texture: Option>, + pub texture: Option>, } /// The number of array levels between `v` and the deepest non-array value @@ -120,7 +121,8 @@ pub enum Geometry { common: GeometryCommon, }, GeometryInstance { - boundaries: Ring, + /// The one vertex the template is placed at. + boundaries: [usize; 1], template: usize, #[serde(rename = "transformationMatrix")] transformation_matrix: [f64; 16], @@ -224,8 +226,8 @@ impl Geometry { /// variant, so no runtime inspection of the boundaries is needed. fn boundary_indices_mut(&mut self) -> Box + '_> { match self { - Geometry::MultiPoint { boundaries, .. } - | Geometry::GeometryInstance { boundaries, .. } => Box::new(boundaries.iter_mut()), + Geometry::MultiPoint { boundaries, .. } => Box::new(boundaries.iter_mut()), + Geometry::GeometryInstance { boundaries, .. } => Box::new(boundaries.iter_mut()), Geometry::MultiLineString { boundaries, .. } => { Box::new(boundaries.iter_mut().flatten()) } @@ -337,8 +339,10 @@ impl Geometry { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct GeometryTemplates { pub templates: Vec, + /// Real coordinates, not transformed integers: templates are placed by + /// their instances' transformation matrices. #[serde(rename = "vertices-templates")] - pub vertices_templates: Value, + pub vertices_templates: Vec<[f64; 3]>, } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index ccdb12e..9e9f3eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,7 +32,7 @@ pub use cityjson::*; //-- `std::result::Result` in the importing module -- a surprise this crate has //-- no business inflicting on its callers. `CjseqError` is re-exported by //-- name; the alias stays reachable as `cjseq::error::Result`. -pub use error::CjseqError; +pub use error::{CjseqError, IndexKind}; pub use geometry::*; pub use metadata::*; pub use semantics::*; diff --git a/src/main.rs b/src/main.rs index f2f3d44..f76d506 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,8 @@ struct Cli { #[derive(Clone, ValueEnum)] pub enum SortingStrategy { + /// The order of the input + AsIs, Random, Lexicographical, } @@ -78,7 +80,8 @@ fn main() { match &cli.command { //-- cat Commands::Cat { file, order } => { - let o2 = match order.clone().unwrap_or(SortingStrategy::Random) { + let o2 = match order.clone().unwrap_or(SortingStrategy::AsIs) { + SortingStrategy::AsIs => cjseq::SortingStrategy::AsIs, SortingStrategy::Random => cjseq::SortingStrategy::Random, SortingStrategy::Lexicographical => cjseq::SortingStrategy::Lexicographical, }; @@ -218,11 +221,13 @@ fn filter_bbox(exclude: bool, bbox: &[f64]) -> Result<()> { cj = CityJSON::from_str(&l)?; } else { let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; - let ci = cjf.centroid(); - let cx = (ci[0] * cj.transform.scale[0]) + cj.transform.translate[0]; - let cy = (ci[1] * cj.transform.scale[1]) + cj.transform.translate[1]; - if (cx > bbox[0]) && (cx < bbox[2]) && (cy > bbox[1]) && (cy < bbox[3]) { - w = true; + //-- a feature with no vertices is nowhere, so never inside + if let Some(ci) = cjf.centroid() { + let cx = (ci[0] * cj.transform.scale[0]) + cj.transform.translate[0]; + let cy = (ci[1] * cj.transform.scale[1]) + cj.transform.translate[1]; + if (cx > bbox[0]) && (cx < bbox[2]) && (cy > bbox[1]) && (cy < bbox[3]) { + w = true; + } } if (w && !exclude) || (!w && exclude) { io::stdout().write_all(format!("{}\n", l).as_bytes())?; @@ -245,12 +250,14 @@ fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<()> { cj = CityJSON::from_str(&l)?; } else { let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; - let ci = cjf.centroid(); - let cx = (ci[0] * cj.transform.scale[0]) + cj.transform.translate[0]; - let cy = (ci[1] * cj.transform.scale[1]) + cj.transform.translate[1]; - let d2 = (cx - x).powf(2.0) + (cy - y).powf(2.0); - if d2 <= (r * r) { - w = true; + //-- a feature with no vertices is nowhere, so never inside + if let Some(ci) = cjf.centroid() { + let cx = (ci[0] * cj.transform.scale[0]) + cj.transform.translate[0]; + let cy = (ci[1] * cj.transform.scale[1]) + cj.transform.translate[1]; + let d2 = (cx - x).powf(2.0) + (cy - y).powf(2.0); + if d2 <= (r * r) { + w = true; + } } if (w && !exclude) || (!w && exclude) { io::stdout().write_all(format!("{}\n", l).as_bytes())?; @@ -363,7 +370,7 @@ fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { io::stdout().write_all(format!("{}\n", serde_json::to_string(&cj1).unwrap()).as_bytes())?; //-- the other lines for each CityJSONSeq let mut i: usize = 0; - while let Some(cjf) = cjj.get_cjfeature(i) { + while let Some(cjf) = cjj.get_cjfeature(i)? { i += 1; io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjf).unwrap()).as_bytes())?; } diff --git a/src/metadata.rs b/src/metadata.rs index 4b62d63..f2b04ca 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -7,14 +7,14 @@ const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Transform { - pub scale: Vec, - pub translate: Vec, + pub scale: [f64; 3], + pub translate: [f64; 3], } impl Transform { pub(crate) fn new() -> Self { Transform { - scale: vec![1.0, 1.0, 1.0], - translate: vec![0., 0., 0.], + scale: [1.0, 1.0, 1.0], + translate: [0.0, 0.0, 0.0], } } } @@ -266,7 +266,7 @@ mod tests { let b = Transform::new(); assert_eq!(a, b); let mut c = Transform::new(); - c.translate = vec![1.0, 0.0, 0.0]; + c.translate = [1.0, 0.0, 0.0]; assert_ne!(a, c); } diff --git a/tests/cj_input.rs b/tests/cj_input.rs index a906ee9..9c529f2 100755 --- a/tests/cj_input.rs +++ b/tests/cj_input.rs @@ -46,7 +46,7 @@ fn empty() { let j = get_empty_file(); let cjj = CityJSON::from_str(&j.to_string()).unwrap(); assert!(cjj.number_of_city_objects() == 0); - assert!(cjj.get_cjfeature(0).is_none()); + assert!(cjj.get_cjfeature(0).unwrap().is_none()); } #[test] @@ -63,10 +63,10 @@ fn basic_reading_of_cj() { assert!(cj1.vertices.is_empty()); cjj.sort_cjfeatures(cjseq::SortingStrategy::Lexicographical); - let mut cjnext = cjj.get_cjfeature(0).unwrap(); + let mut cjnext = cjj.get_cjfeature(0).unwrap().unwrap(); assert_eq!(cjnext.thetype, cjseq::CityJSONFeatureType::CityJSONFeature); assert_eq!(cjnext.id, "NL.IMBAG.Pand.0503100000028341"); - cjnext = cjj.get_cjfeature(1).unwrap(); + cjnext = cjj.get_cjfeature(1).unwrap().unwrap(); assert_eq!(cjnext.id, "NL.IMBAG.Pand.0503100000031927"); } diff --git a/tests/cjf_input.rs b/tests/cjf_input.rs index aaa88f5..32e09e2 100755 --- a/tests/cjf_input.rs +++ b/tests/cjf_input.rs @@ -18,7 +18,7 @@ fn test1() { if i == 0 { cjj = CityJSON::from_str(l).unwrap(); assert!(cjj.number_of_city_objects() == 0); - assert!(cjj.get_cjfeature(0).is_none()); + assert!(cjj.get_cjfeature(0).unwrap().is_none()); assert!(cjj.vertices.is_empty()); } else { let mut cjf: CityJSONFeature = CityJSONFeature::from_str(l).unwrap(); diff --git a/tests/robustness.rs b/tests/robustness.rs new file mode 100644 index 0000000..468710b --- /dev/null +++ b/tests/robustness.rs @@ -0,0 +1,210 @@ +//! Malformed or unusual input is rejected with an error or handled, never a +//! panic, and output order is deterministic. + +use cjseq::{CityJSON, CityJSONFeature, CjseqError, Geometry, SortingStrategy}; +use serde_json::{json, Value}; +use std::str::FromStr; + +fn doc(city_objects: Value, vertices: Value) -> CityJSON { + CityJSON::from_str( + &json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [10.0, 20.0, 30.0]}, + "CityObjects": city_objects, + "vertices": vertices + }) + .to_string(), + ) + .unwrap() +} + +fn features(cj: &mut CityJSON, order: SortingStrategy) -> Vec { + cj.sort_cjfeatures(order); + let mut out = Vec::new(); + while let Some(f) = cj.get_cjfeature(out.len()).unwrap() { + out.push(f); + } + out +} + +#[test] +fn a_vertex_must_have_three_coordinates() { + let s = json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [[1, 2]] + }) + .to_string(); + assert!(CityJSON::from_str(&s).is_err()); + let f = json!({"type": "CityJSONFeature", "id": "a", "CityObjects": {}, "vertices": [[1, 2, 3, 4]]}) + .to_string(); + assert!(CityJSONFeature::from_str(&f).is_err()); +} + +#[test] +fn a_transform_must_have_three_components() { + let s = json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [] + }) + .to_string(); + assert!(CityJSON::from_str(&s).is_err()); +} + +#[test] +fn a_geometry_instance_refers_to_exactly_one_vertex() { + let gi = |b: Value| { + serde_json::from_value::(json!({ + "type": "GeometryInstance", "template": 0, "boundaries": b, + "transformationMatrix": [1.0,0.0,0.0,0.0, 0.0,1.0,0.0,0.0, 0.0,0.0,1.0,0.0, 0.0,0.0,0.0,1.0] + })) + }; + assert!(gi(json!([3])).is_ok()); + assert!(gi(json!([1, 2])).is_err()); + assert!(gi(json!([])).is_err()); +} + +#[test] +fn vertices_templates_are_three_coordinate_points() { + let s = json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [], + "geometry-templates": {"templates": [], "vertices-templates": [[0.0, 1.0]]} + }) + .to_string(); + assert!(CityJSON::from_str(&s).is_err()); +} + +#[test] +fn city_objects_keep_input_order_and_output_is_deterministic() { + let ids = ["e", "b", "d", "a", "c"]; + let cos: Vec = ids + .iter() + .map(|id| format!(r#""{id}":{{"type":"Building"}}"#)) + .collect(); + let input = format!( + r#"{{"type":"CityJSON","version":"2.0","transform":{{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]}},"CityObjects":{{{}}},"vertices":[]}}"#, + cos.join(",") + ); + let mut cj = CityJSON::from_str(&input).unwrap(); + let got: Vec = features(&mut cj, SortingStrategy::AsIs) + .into_iter() + .map(|f| f.id) + .collect(); + assert_eq!(got, ids); + //-- and serializing twice gives byte-identical output, in input order + let a = serde_json::to_string(&cj).unwrap(); + let b = serde_json::to_string(&cj).unwrap(); + assert_eq!(a, b); + let positions: Vec = ids + .iter() + .map(|id| a.find(&format!("\"{id}\":{{")).unwrap()) + .collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{a}"); +} + +#[test] +fn a_feature_carries_every_descendant_not_just_children() { + let mut cj = doc( + json!({ + "b": {"type": "Building", "children": ["p"]}, + "p": {"type": "BuildingPart", "parents": ["b"], "children": ["i"]}, + "i": {"type": "BuildingInstallation", "parents": ["p"], "geometry": [ + {"type": "MultiPoint", "boundaries": [0]} + ]} + }), + json!([[1, 2, 3]]), + ); + let fs = features(&mut cj, SortingStrategy::AsIs); + assert_eq!(fs.len(), 1); + let mut keys: Vec<&String> = fs[0].city_objects.keys().collect(); + keys.sort(); + assert_eq!(keys, ["b", "i", "p"]); + assert_eq!(fs[0].vertices, vec![[1, 2, 3]]); +} + +#[test] +fn a_parent_child_cycle_does_not_loop_forever() { + let mut cj = doc( + json!({ + "b": {"type": "Building", "children": ["p"]}, + "p": {"type": "BuildingPart", "parents": ["b"], "children": ["b"]} + }), + json!([]), + ); + let fs = features(&mut cj, SortingStrategy::AsIs); + assert_eq!(fs[0].city_objects.len(), 2); +} + +#[test] +fn a_dangling_child_is_an_error_not_a_panic() { + let cj = doc( + json!({"b": {"type": "Building", "children": ["nope"]}}), + json!([]), + ); + match cj.get_cjfeature(0) { + Err(CjseqError::DanglingReference { from, to }) => { + assert_eq!(from, "b"); + assert_eq!(to, "nope"); + } + other => panic!("expected DanglingReference, got {other:?}"), + } +} + +#[test] +fn an_out_of_range_vertex_index_is_an_error_not_a_panic() { + let cj = doc( + json!({"b": {"type": "Building", "geometry": [{"type": "MultiPoint", "boundaries": [0, 7]}]}}), + json!([[1, 2, 3]]), + ); + assert!(matches!( + cj.get_cjfeature(0), + Err(CjseqError::IndexOutOfRange { .. }) + )); +} + +#[test] +fn an_index_past_the_last_feature_is_none() { + let cj = doc(json!({"b": {"type": "Building"}}), json!([])); + assert!(cj.get_cjfeature(1).unwrap().is_none()); +} + +#[test] +fn update_transform_on_no_vertices_leaves_the_transform_alone() { + let mut cj = doc(json!({}), json!([])); + cj.update_transform(); + assert_eq!(cj.transform.translate, [10.0, 20.0, 30.0]); +} + +#[test] +fn update_geographicalextent_on_no_vertices_leaves_the_extent_alone() { + let mut cj = CityJSON::from_str( + &json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "metadata": {"geographicalExtent": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]}, + "CityObjects": {}, "vertices": [] + }) + .to_string(), + ) + .unwrap(); + cj.update_geographicalextent(); + assert_eq!( + cj.metadata.unwrap().geographical_extent, + Some([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + ); +} + +#[test] +fn the_centroid_of_no_vertices_is_none() { + let f = CityJSONFeature::new(); + assert_eq!(f.centroid(), None); + let f = CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": "a", "CityObjects": {}, "vertices": [[0, 0, 0], [2, 4, 6]]}) + .to_string(), + ) + .unwrap(); + assert_eq!(f.centroid(), Some([1.0, 2.0, 3.0])); +} diff --git a/tests/texture_merge.rs b/tests/texture_merge.rs index be5ca9c..5519091 100644 --- a/tests/texture_merge.rs +++ b/tests/texture_merge.rs @@ -101,7 +101,7 @@ fn slicing_a_feature_out_keeps_only_the_uvs_it_uses() { let cj = cjseq_to_cj(header(), vec![feature("a", &uvs, &[[4, 1, 2]])]); let cj = CityJSON::from_str(&serde_json::to_string(&cj).unwrap()).unwrap(); - let f = cj.get_cjfeature(0).unwrap(); + let f = cj.get_cjfeature(0).unwrap().unwrap(); let app = f.appearance.as_ref().unwrap(); assert_eq!( app.vertices_texture.as_deref(), @@ -126,7 +126,7 @@ fn a_cat_then_collect_round_trip_keeps_every_uv() { let mut doc = CityJSON::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); doc.sort_cjfeatures(cjseq::SortingStrategy::Lexicographical); let mut features = Vec::new(); - while let Some(f) = doc.get_cjfeature(features.len()) { + while let Some(f) = doc.get_cjfeature(features.len()).unwrap() { features.push(f); } let back = cjseq_to_cj(doc.get_metadata(), features); From aaf832b9fe74de4f9097ad07aef470d457b8529c Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:11:22 +0200 Subject: [PATCH 40/65] fix!: one unknown-member policy, following the schema Objects the CityJSON 2.0 schemas close (additionalProperties: false) reject members they do not name: every geometry object (including a GeometryInstance, which takes no lod), transform, appearance and geometry-templates. Every other object keeps unnamed members verbatim in an `other` field, which is now a serde_json::Map everywhere (CityJSON, CityJSONFeature, CityObject, Metadata, PointOfContact, Address, Semantics, semantic surfaces and theme references). - CityJSONFeature keeps extra root members; they used to be dropped. - CityJSON::new() and CityJSONFeature::new() equal their parsed twins. - Geometry variants carry their own semantics/material/texture fields instead of a flattened GeometryCommon; MultiPoint and MultiLineString have no material or texture, as in the schema. GeometryCommon, common() and common_mut() are replaced by semantics(), material() and texture(). --- src/appearance.rs | 7 +- src/city_object.rs | 6 +- src/cityjson.rs | 11 ++- src/geometry.rs | 158 ++++++++++++++++++++++++--------------- src/metadata.rs | 14 ++-- src/semantics.rs | 7 +- tests/unknown_members.rs | 90 ++++++++++++++++++++++ 7 files changed, 213 insertions(+), 80 deletions(-) create mode 100644 tests/unknown_members.rs diff --git a/src/appearance.rs b/src/appearance.rs index 3c59116..e029e84 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; +use serde_json::{Map, Value}; /// One shell's worth of material indices, one per surface. `None` — a whole /// shell with no material — is `null` in JSON, which @@ -146,7 +145,7 @@ pub struct MaterialReference { /// no `additionalProperties: false`, so anything further is legal /// CityJSON and is kept rather than dropped. #[serde(flatten)] - pub other: HashMap, + pub other: Map, } /// One theme's texture assignment for a geometry. @@ -162,7 +161,7 @@ pub struct TextureReference { /// As with [`MaterialReference`]: the per-theme texture object declares no /// `additionalProperties: false`, so extra members are legal and kept. #[serde(flatten)] - pub other: HashMap, + pub other: Map, } /// A material or border colour: three numbers, per diff --git a/src/city_object.rs b/src/city_object.rs index b47e430..5af5989 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -1,7 +1,7 @@ use crate::geometry::Geometry; use crate::metadata::GeographicalExtent; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; /// The known 1st- and 2nd-level City Object types (§ 2 The different City /// Objects). Every variant is a unit variant and every identifier is already @@ -194,7 +194,7 @@ pub struct CityObject { /// round trip. `pub`, like every other `#[serde(flatten)]` catch-all in /// this crate, so a `CityObject` can be written as a struct literal. #[serde(flatten)] - pub other: serde_json::Value, + pub other: Map, } impl CityObject { @@ -224,7 +224,7 @@ impl CityObject { children: None, children_roles: None, parents: None, - other: Value::Object(serde_json::Map::new()), + other: Map::new(), } } diff --git a/src/cityjson.rs b/src/cityjson.rs index ab8f784..f5da883 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -5,7 +5,7 @@ use crate::geometry::GeometryTemplates; use crate::metadata::{Metadata, Transform}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use std::str::FromStr; @@ -87,8 +87,9 @@ pub struct CityJSON { pub geometry_templates: Option, #[serde(skip_serializing_if = "Option::is_none")] pub extensions: Option, + /// Root members the schema does not name, kept verbatim. #[serde(flatten)] - pub other: serde_json::Value, + pub other: Map, #[serde(skip)] sorted_ids: Vec, #[serde(skip)] @@ -142,7 +143,7 @@ impl CityJSON { appearance: None, geometry_templates: None, extensions: None, - other: json!(null), + other: Map::new(), sorted_ids: vec![], transform_correction: None, } @@ -429,6 +430,9 @@ pub struct CityJSONFeature { pub vertices: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub appearance: Option, + /// Root members the schema does not name, kept verbatim. + #[serde(flatten)] + pub other: Map, } impl Default for CityJSONFeature { @@ -453,6 +457,7 @@ impl CityJSONFeature { city_objects: IndexMap::new(), vertices: Vec::new(), appearance: None, + other: Map::new(), } } pub fn add_co(&mut self, id: String, co: CityObject) { diff --git a/src/geometry.rs b/src/geometry.rs index af972d5..b47ab5f 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -41,17 +41,8 @@ impl GeometryType { } } -/// The members every geometry may carry that are neither its type, its lod, -/// nor its boundaries. The appearance values are depth-typed, keyed by theme. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -pub struct GeometryCommon { - #[serde(skip_serializing_if = "Option::is_none")] - pub semantics: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub material: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub texture: Option>, -} +/// Per-theme appearance references of a geometry, keyed by theme name. +pub type Themes = IndexMap; /// The number of array levels between `v` and the deepest non-array value /// inside it. A non-array is 0 deep; `[]` is 1 deep. @@ -68,57 +59,81 @@ fn nesting_depth(v: &Value) -> usize { /// whose boundaries are nested one level too deep does not deserialize, where /// previously it decoded happily into an untyped `serde_json::Value` and left /// every consumer to guess the depth. +/// +/// Each variant has exactly the members the schema names for it, and the +/// schema closes every geometry object, so any other member is rejected. A +/// MultiPoint or MultiLineString carries no material or texture. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] pub enum Geometry { MultiPoint { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Ring, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, }, MultiLineString { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Vec, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, }, MultiSurface { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Vec, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + material: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + texture: Option>, }, CompositeSurface { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Vec, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + material: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + texture: Option>, }, Solid { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Vec, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + material: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + texture: Option>, }, MultiSolid { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Vec>, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + material: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + texture: Option>, }, CompositeSolid { #[serde(skip_serializing_if = "Option::is_none")] lod: Option, boundaries: Vec>, - #[serde(flatten)] - common: GeometryCommon, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + material: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + texture: Option>, }, GeometryInstance { /// The one vertex the template is placed at. @@ -192,32 +207,64 @@ impl Geometry { } } - /// The semantics/material/texture members, if this variant has them. - /// A [`Geometry::GeometryInstance`] carries none: its appearance lives on - /// the template it refers to. - pub fn common(&self) -> Option<&GeometryCommon> { + pub fn semantics(&self) -> Option<&Semantics> { match self { - Geometry::MultiPoint { common, .. } - | Geometry::MultiLineString { common, .. } - | Geometry::MultiSurface { common, .. } - | Geometry::CompositeSurface { common, .. } - | Geometry::Solid { common, .. } - | Geometry::MultiSolid { common, .. } - | Geometry::CompositeSolid { common, .. } => Some(common), + Geometry::MultiPoint { semantics, .. } + | Geometry::MultiLineString { semantics, .. } + | Geometry::MultiSurface { semantics, .. } + | Geometry::CompositeSurface { semantics, .. } + | Geometry::Solid { semantics, .. } + | Geometry::MultiSolid { semantics, .. } + | Geometry::CompositeSolid { semantics, .. } => semantics.as_ref(), Geometry::GeometryInstance { .. } => None, } } - pub fn common_mut(&mut self) -> Option<&mut GeometryCommon> { + /// The material themes. Only surface and solid geometries can carry + /// them; a [`Geometry::GeometryInstance`]'s appearance lives on its + /// template. + pub fn material(&self) -> Option<&Themes> { match self { - Geometry::MultiPoint { common, .. } - | Geometry::MultiLineString { common, .. } - | Geometry::MultiSurface { common, .. } - | Geometry::CompositeSurface { common, .. } - | Geometry::Solid { common, .. } - | Geometry::MultiSolid { common, .. } - | Geometry::CompositeSolid { common, .. } => Some(common), - Geometry::GeometryInstance { .. } => None, + Geometry::MultiSurface { material, .. } + | Geometry::CompositeSurface { material, .. } + | Geometry::Solid { material, .. } + | Geometry::MultiSolid { material, .. } + | Geometry::CompositeSolid { material, .. } => material.as_ref(), + _ => None, + } + } + + fn material_mut(&mut self) -> Option<&mut Themes> { + match self { + Geometry::MultiSurface { material, .. } + | Geometry::CompositeSurface { material, .. } + | Geometry::Solid { material, .. } + | Geometry::MultiSolid { material, .. } + | Geometry::CompositeSolid { material, .. } => material.as_mut(), + _ => None, + } + } + + /// The texture themes; as for [`Geometry::material`]. + pub fn texture(&self) -> Option<&Themes> { + match self { + Geometry::MultiSurface { texture, .. } + | Geometry::CompositeSurface { texture, .. } + | Geometry::Solid { texture, .. } + | Geometry::MultiSolid { texture, .. } + | Geometry::CompositeSolid { texture, .. } => texture.as_ref(), + _ => None, + } + } + + fn texture_mut(&mut self) -> Option<&mut Themes> { + match self { + Geometry::MultiSurface { texture, .. } + | Geometry::CompositeSurface { texture, .. } + | Geometry::Solid { texture, .. } + | Geometry::MultiSolid { texture, .. } + | Geometry::CompositeSolid { texture, .. } => texture.as_mut(), + _ => None, } } @@ -267,10 +314,7 @@ impl Geometry { /// order. The depth of `values` comes from its variant, so the geometry /// type is never consulted. pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { - let Some(common) = self.common_mut() else { - return; - }; - let Some(materials) = common.material.as_mut() else { + let Some(materials) = self.material_mut() else { return; }; for mat in materials.values_mut() { @@ -303,10 +347,7 @@ impl Geometry { t_oldnew: &mut HashMap, t_v_oldnew: &mut HashMap, ) { - let Some(common) = self.common_mut() else { - return; - }; - let Some(textures) = common.texture.as_mut() else { + let Some(textures) = self.texture_mut() else { return; }; //-- the first index of the innermost array is a texture, the rest are @@ -337,6 +378,7 @@ impl Geometry { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] pub struct GeometryTemplates { pub templates: Vec, /// Real coordinates, not transformed integers: templates are placed by @@ -425,7 +467,7 @@ mod tests { }); let g: Geometry = serde_json::from_value(input.clone()).unwrap(); assert_eq!(g.geometry_type(), GeometryType::GeometryInstance); - assert!(g.common().is_none()); + assert!(g.semantics().is_none() && g.material().is_none()); assert_eq!(serde_json::to_value(&g).unwrap(), input); } @@ -500,9 +542,7 @@ mod tests { let mut t_v_oldnew: HashMap = HashMap::new(); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); - let values = - serde_json::to_value(&g.common().unwrap().texture.as_ref().unwrap()["winter"].values) - .unwrap(); + let values = serde_json::to_value(&g.texture().unwrap()["winter"].values).unwrap(); assert_eq!( values, serde_json::json!([[[0, 0, 1, 2, 0]], [[0, 2, 1, 3, 2]], [[null]]]) @@ -551,7 +591,7 @@ mod tests { //-- as above: typed `values`, oracle expectations unchanged. let values = serde_json::to_value( - g.common().unwrap().material.as_ref().unwrap()["irradiation"] + g.material().unwrap()["irradiation"] .values .as_ref() .unwrap(), diff --git a/src/metadata.rs b/src/metadata.rs index f2b04ca..49b3605 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,11 +1,11 @@ use crate::error::CjseqError; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; +use serde_json::{Map, Value}; const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] pub struct Transform { pub scale: [f64; 3], pub translate: [f64; 3], @@ -39,7 +39,7 @@ pub type GeographicalExtent = [f64; 6]; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Address { #[serde(flatten)] - pub members: HashMap, + pub members: Map, } /// The `pointOfContact` member of [`Metadata`] (§ 5.3). @@ -74,7 +74,7 @@ pub struct PointOfContact { pub address: Option
, /// Every contact member the schema does not name, kept verbatim. #[serde(flatten)] - pub other: HashMap, + pub other: Map, } /// A reference system following the OGC Name Type Specification. @@ -249,7 +249,7 @@ pub struct Metadata { pub title: Option, /// Every metadata member the core spec does not name, kept verbatim. #[serde(flatten)] - pub other: HashMap, + pub other: Map, } #[cfg(test)] @@ -296,7 +296,7 @@ mod tests { website: None, organization: None, address: None, - other: HashMap::new(), + other: Map::new(), }; let b = a.clone(); assert_eq!(a, b); @@ -509,7 +509,7 @@ mod tests { reference_date: None, reference_system: None, title: Some("dataset".to_string()), - other: HashMap::new(), + other: Map::new(), }; let b = a.clone(); assert_eq!(a, b); diff --git a/src/semantics.rs b/src/semantics.rs index 2bd8e9d..5f75257 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; +use serde_json::{Map, Value}; /// The known surface types the spec assigns to Building-, WaterBody-, and /// Transportation-family City Objects (§ 3.3 Semantics of geometric @@ -137,7 +136,7 @@ pub struct SemanticsSurface { /// (`slope`, `direction`, ...); they are kept verbatim so that a file /// round-trips without losing them. #[serde(flatten)] - pub other: HashMap, + pub other: Map, } /// One shell's worth of semantic-surface indices, one per surface. `None` — a @@ -189,7 +188,7 @@ pub struct Semantics { pub surfaces: Vec, pub values: Option, #[serde(flatten)] - pub other: HashMap, + pub other: Map, } #[cfg(test)] diff --git a/tests/unknown_members.rs b/tests/unknown_members.rs new file mode 100644 index 0000000..4d41627 --- /dev/null +++ b/tests/unknown_members.rs @@ -0,0 +1,90 @@ +//! One policy for members the schema does not name: objects the schema closes +//! (`additionalProperties: false`) reject them, every other object keeps them +//! verbatim in an `other` map. + +use cjseq::{ + Appearance, CityJSON, CityJSONFeature, CityObject, Geometry, Metadata, PointOfContact, + Semantics, SemanticsSurface, Transform, +}; +use serde_json::{json, Map, Value}; +use std::str::FromStr; + +#[test] +fn a_geometry_rejects_a_member_the_schema_does_not_name() { + let r = serde_json::from_value::(json!({ + "type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]]], "colour": "red" + })); + assert!(r.is_err()); +} + +#[test] +fn a_geometry_instance_rejects_a_lod() { + let r = serde_json::from_value::(json!({ + "type": "GeometryInstance", "lod": "2", "template": 0, "boundaries": [0], + "transformationMatrix": [1.0,0.0,0.0,0.0, 0.0,1.0,0.0,0.0, 0.0,0.0,1.0,0.0, 0.0,0.0,0.0,1.0] + })); + assert!(r.is_err()); +} + +#[test] +fn closed_objects_reject_unknown_members() { + assert!(serde_json::from_value::(json!({ + "scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0], "rotate": 1 + })) + .is_err()); + assert!(serde_json::from_value::(json!({"foo": 1})).is_err()); + let doc = json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [], + "geometry-templates": {"templates": [], "vertices-templates": [], "x": 1} + }); + assert!(CityJSON::from_str(&doc.to_string()).is_err()); +} + +#[test] +fn a_feature_keeps_extra_root_members() { + let input = r#"{"type":"CityJSONFeature","id":"a","CityObjects":{},"vertices":[],"+provenance":{"run":7}}"#; + let f = CityJSONFeature::from_str(input).unwrap(); + assert_eq!(f.other["+provenance"], json!({"run": 7})); + assert_eq!(serde_json::to_string(&f).unwrap(), input); +} + +#[test] +fn a_document_keeps_extra_root_members_in_order() { + let input = r#"{"type":"CityJSON","version":"2.0","transform":{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]},"CityObjects":{},"vertices":[],"+b":1,"+a":2}"#; + let cj = CityJSON::from_str(input).unwrap(); + assert_eq!(serde_json::to_string(&cj).unwrap(), input); +} + +#[test] +fn a_new_document_equals_its_parsed_twin() { + let built = CityJSON::new(); + let parsed = CityJSON::from_str(&serde_json::to_string(&built).unwrap()).unwrap(); + assert_eq!(built, parsed); + let built = CityJSONFeature::new(); + let parsed = CityJSONFeature::from_str(&serde_json::to_string(&built).unwrap()).unwrap(); + assert_eq!(built, parsed); +} + +/// Every `other` is the same type, so callers handle extras one way. +#[test] +fn every_catch_all_is_a_json_object_map() { + fn is_map(_: &Map) {} + let cj = CityJSON::new(); + is_map(&cj.other); + is_map(&CityJSONFeature::new().other); + let co: CityObject = serde_json::from_value(json!({"type": "Building"})).unwrap(); + is_map(&co.other); + let m: Metadata = serde_json::from_value(json!({})).unwrap(); + is_map(&m.other); + let p: PointOfContact = + serde_json::from_value(json!({"contactName": "a", "emailAddress": "b"})).unwrap(); + is_map(&p.other); + let s: Semantics = + serde_json::from_value(json!({"surfaces": [{"type": "RoofSurface"}], "values": null})) + .unwrap(); + is_map(&s.other); + let ss: &SemanticsSurface = &s.surfaces[0]; + is_map(&ss.other); +} From e6def695886b5b8e80f3f4b7af0b68e2ff0b4107 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:14:33 +0200 Subject: [PATCH 41/65] feat!: type the strings the schema constrains - Lod: one of the twenty values the schema enumerates ("0"-"3", "0.0"-"3.3"), with level() and refinement(). Every geometry primitive requires one, as the schema does. - CityJSON.version is a CityJSONVersion (1.1 or 2.0); any other value fails to parse, so the CLI's own version check is gone. - CityObject.attributes is a JSON object map. - CityJSON.extensions maps a name to an Extension { url, version }. - A CityObjectType or SemanticSurfaceType that is neither a known name nor '+' followed by a name is an error, so "Buildng" no longer passes as an Extension. - pointOfContact contactType and role are the spec's enums (ContactType, ContactRole). - A texture ring is TexturedRing::Untextured ([null]) or Textured { texture, uvs }; a null texture-vertex index is rejected. Texture type and image stay optional: appearance.schema.json requires neither. --- src/appearance.rs | 51 +++++++++++-- src/city_object.rs | 52 +++++++++----- src/cityjson.rs | 30 ++++++-- src/geometry.rs | 114 +++++++++++++++++++++++------- src/main.rs | 26 ------- src/metadata.rs | 40 ++++++++++- src/semantics.rs | 46 +++++++++--- tests/robustness.rs | 4 +- tests/typed_strings.rs | 157 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 430 insertions(+), 90 deletions(-) create mode 100644 tests/typed_strings.rs diff --git a/src/appearance.rs b/src/appearance.rs index e029e84..a94a134 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -54,10 +54,53 @@ impl MaterialValues { } } -/// One ring's texture entry: the texture index first, then one UV-vertex -/// index per vertex of the ring — so one entry more than the ring has -/// vertices. `[null]` means the ring is not textured. -pub type TexturedRing = Vec>; +/// One ring's texture entry. In JSON it is `[texture, uv, uv, ...]` -- the +/// texture index first, then one texture-vertex index per vertex of the ring +/// -- or `[null]` for a ring with no texture. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(try_from = "Vec>", into = "Vec>")] +pub enum TexturedRing { + /// `[null]` + Untextured, + /// `[texture, uvs...]` + Textured { texture: usize, uvs: Vec }, +} + +impl TryFrom>> for TexturedRing { + type Error = crate::error::CjseqError; + fn try_from(v: Vec>) -> crate::error::Result { + match v.split_first() { + Some((None, [])) => Ok(TexturedRing::Untextured), + Some((Some(texture), rest)) if !rest.is_empty() => Ok(TexturedRing::Textured { + texture: *texture, + uvs: rest + .iter() + .map(|uv| { + uv.ok_or_else(|| { + crate::error::CjseqError::Validation( + "a textured ring has a null texture-vertex index".to_string(), + ) + }) + }) + .collect::>()?, + }), + _ => Err(crate::error::CjseqError::Validation(format!( + "{v:?} is not a texture ring: expected [null] or [texture, uv, ...]" + ))), + } + } +} + +impl From for Vec> { + fn from(r: TexturedRing) -> Self { + match r { + TexturedRing::Untextured => vec![None], + TexturedRing::Textured { texture, uvs } => std::iter::once(Some(texture)) + .chain(uvs.into_iter().map(Some)) + .collect(), + } + } +} /// One face's rings, exterior first, mirroring [`crate::Surface`]. pub type TexturedSurface = Vec; /// One volume's bounding faces, mirroring [`crate::Shell`]. diff --git a/src/city_object.rs b/src/city_object.rs index 5af5989..867e85d 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -6,8 +6,7 @@ use serde_json::{Map, Value}; /// The known 1st- and 2nd-level City Object types (§ 2 The different City /// Objects). Every variant is a unit variant and every identifier is already /// spelled exactly as the spec requires (eg `TINRelief`, `WaterBody`, -/// `CityFurniture`), so serde's default (non-untagged) derive collapses each -/// one to its bare name string with no `#[serde(rename)]` needed. +/// `CityFurniture`), so each serializes as its bare name. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum KnownCityObjectType { Bridge, @@ -53,18 +52,10 @@ pub enum KnownCityObjectType { /// carries that string verbatim so a file using an Extension (eg /// `"+NoiseCityFurnitureSegment"`) round-trips byte for byte. /// -/// This is `#[serde(untagged)]` over a *nested* known-variants enum, not a -/// single flat enum mixing unit variants with `Extension(String)` directly: -/// under `#[serde(untagged)]`, a bare unit variant (de)serializes as JSON -/// `null`, not its name (this is a documented serde quirk -- untagged -/// dispatch tries each variant's own `Deserialize` standalone, and a unit -/// variant's is `deserialize_unit`, which only accepts `null`). A flat -/// mixed enum was tried and empirically failed this exact way: every known -/// name fell through to `Extension` because none of the unit variants ever -/// matched a JSON string. Nesting the known names inside their own -/// unit-only enum sidesteps this, because *that* enum, derived without -/// `#[serde(untagged)]`, gets the ordinary external-tagging collapse (a -/// unit-only enum's tag+content is just the bare name string). +/// In JSON it is a plain string, converted through `TryFrom`: a +/// string that is neither a known name nor `+` followed by a name is an +/// error, so a misspelt core type (`"Buildng"`) does not pass as an +/// Extension. /// /// # Match on the value, not on a reference /// @@ -94,7 +85,7 @@ pub enum KnownCityObjectType { /// exhaustive over just the known names, since `Known` and `Extension` are /// the real variants. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] -#[serde(untagged)] +#[serde(try_from = "String", into = "String")] pub enum CityObjectType { Known(KnownCityObjectType), /// Not a core CityJSON type: a CityJSON Extension type, always spelled @@ -112,6 +103,35 @@ pub enum CityObjectType { /// but only when matching on a *value*. See /// [`CityObjectType`][CityObjectType#match-on-the-value-not-on-a-reference] /// for why that matters and how it fails silently otherwise. +impl TryFrom for CityObjectType { + type Error = crate::error::CjseqError; + /// A known name, or an Extension name: `+` followed by at least one + /// character. Anything else -- a misspelt core type, say -- is an error. + fn try_from(s: String) -> crate::error::Result { + if let Ok(k) = serde_json::from_value::(Value::String(s.clone())) { + return Ok(CityObjectType::Known(k)); + } + if s.len() > 1 && s.starts_with('+') { + return Ok(CityObjectType::Extension(s)); + } + Err(crate::error::CjseqError::Validation(format!( + "{s:?} is not a City Object type; an Extension type starts with '+'" + ))) + } +} + +impl From for String { + fn from(t: CityObjectType) -> String { + match t { + CityObjectType::Known(k) => match serde_json::to_value(k) { + Ok(Value::String(s)) => s, + _ => unreachable!("a unit variant serializes as its name"), + }, + CityObjectType::Extension(s) => s, + } + } +} + #[allow(non_upper_case_globals)] impl CityObjectType { pub const Bridge: CityObjectType = CityObjectType::Known(KnownCityObjectType::Bridge); @@ -177,7 +197,7 @@ pub struct CityObject { #[serde(skip_serializing_if = "Option::is_none")] pub geographical_extent: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub attributes: Option, + pub attributes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub geometry: Option>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/cityjson.rs b/src/cityjson.rs index f5da883..50f1162 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -73,7 +73,7 @@ pub enum SortingStrategy { pub struct CityJSON { #[serde(rename = "type")] pub thetype: CityJSONType, - pub version: String, + pub version: CityJSONVersion, pub transform: Transform, #[serde(rename = "CityObjects")] pub city_objects: IndexMap, @@ -86,7 +86,7 @@ pub struct CityJSON { #[serde(skip_serializing_if = "Option::is_none")] pub geometry_templates: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option, + pub extensions: Option>, /// Root members the schema does not name, kept verbatim. #[serde(flatten)] pub other: Map, @@ -135,7 +135,7 @@ impl CityJSON { pub fn new() -> Self { CityJSON { thetype: CityJSONType::CityJSON, - version: "2.0".to_string(), + version: CityJSONVersion::V2_0, transform: Transform::new(), city_objects: IndexMap::new(), vertices: Vec::new(), @@ -152,7 +152,7 @@ impl CityJSON { pub fn get_metadata(&self) -> Self { let mut cj0 = CityJSON { thetype: self.thetype.clone(), - version: self.version.clone(), + version: self.version, transform: self.transform.clone(), metadata: self.metadata.clone(), city_objects: IndexMap::new(), @@ -493,6 +493,26 @@ pub fn cjseq_to_cj(mut base_cj: CityJSON, features: Vec) -> Cit base_cj } +/// The CityJSON versions this crate reads and writes. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CityJSONVersion { + #[serde(rename = "1.1")] + V1_1, + #[serde(rename = "2.0")] + V2_0, +} + +/// One entry of the `extensions` member (§ 8): where the Extension's schema +/// lives and which version of it the document uses. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Extension { + pub url: String, + pub version: String, + /// Members the schema does not name, kept verbatim. + #[serde(flatten)] + pub other: Map, +} + /// The `type` member of a [`CityJSON`] object. The spec fixes its value to /// the single string `"CityJSON"` (§ 1 CityJSON Object); unlike City Object /// and semantic-surface types, CityJSON does not define an Extension @@ -540,7 +560,7 @@ mod tests { let b = CityJSON::new(); assert_eq!(a, b); let mut c = CityJSON::new(); - c.version = "1.1".to_string(); + c.version = CityJSONVersion::V1_1; assert_ne!(a, c); } diff --git a/src/geometry.rs b/src/geometry.rs index b47ab5f..cb6fbcd 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,4 +1,4 @@ -use crate::appearance::{MaterialReference, TextureReference}; +use crate::appearance::{MaterialReference, TextureReference, TexturedRing}; use crate::error::{CjseqError, Result}; use crate::semantics::Semantics; use indexmap::IndexMap; @@ -41,6 +41,78 @@ impl GeometryType { } } +/// A level of detail (§ 3.2): `"0"` to `"3"`, optionally refined with one more +/// digit, `"0.0"` to `"3.3"` -- the twenty values `geomprimitives.schema.json` +/// enumerates. `"2"` and `"2.0"` are distinct values and stay distinct. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(try_from = "String", into = "String")] +pub struct Lod { + level: u8, + refinement: Option, +} + +impl Lod { + /// `level` and `refinement` must each be 0 to 3. + pub fn new(level: u8, refinement: Option) -> Result { + if level > 3 || refinement.is_some_and(|r| r > 3) { + return Err(CjseqError::Validation(format!( + "LoD {level}.{refinement:?} is out of range: both parts are 0 to 3" + ))); + } + Ok(Lod { level, refinement }) + } + /// The LoD level, 0 to 3. + pub fn level(&self) -> u8 { + self.level + } + /// The refinement after the dot, if the value has one. + pub fn refinement(&self) -> Option { + self.refinement + } +} + +impl std::fmt::Display for Lod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.refinement { + Some(r) => write!(f, "{}.{r}", self.level), + None => write!(f, "{}", self.level), + } + } +} + +impl std::str::FromStr for Lod { + type Err = CjseqError; + fn from_str(s: &str) -> Result { + let digit = |c: &str| match c.as_bytes() { + [d @ b'0'..=b'3'] => Some(d - b'0'), + _ => None, + }; + let parsed = match s.split_once('.') { + None => digit(s).map(|l| (l, None)), + Some((l, r)) => digit(l).zip(digit(r)).map(|(l, r)| (l, Some(r))), + }; + let (level, refinement) = parsed.ok_or_else(|| { + CjseqError::Validation(format!( + "{s:?} is not a LoD: expected \"0\" to \"3\" or \"0.0\" to \"3.3\"" + )) + })?; + Lod::new(level, refinement) + } +} + +impl TryFrom for Lod { + type Error = CjseqError; + fn try_from(s: String) -> Result { + s.parse() + } +} + +impl From for String { + fn from(l: Lod) -> String { + l.to_string() + } +} + /// Per-theme appearance references of a geometry, keyed by theme name. pub type Themes = IndexMap; @@ -67,22 +139,19 @@ fn nesting_depth(v: &Value) -> usize { #[serde(tag = "type", deny_unknown_fields)] pub enum Geometry { MultiPoint { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Ring, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, }, MultiLineString { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, }, MultiSurface { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, @@ -92,8 +161,7 @@ pub enum Geometry { texture: Option>, }, CompositeSurface { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, @@ -103,8 +171,7 @@ pub enum Geometry { texture: Option>, }, Solid { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, @@ -114,8 +181,7 @@ pub enum Geometry { texture: Option>, }, MultiSolid { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Vec>, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, @@ -125,8 +191,7 @@ pub enum Geometry { texture: Option>, }, CompositeSolid { - #[serde(skip_serializing_if = "Option::is_none")] - lod: Option, + lod: Lod, boundaries: Vec>, #[serde(skip_serializing_if = "Option::is_none")] semantics: Option, @@ -194,7 +259,7 @@ impl Geometry { } /// The lod of this geometry; a [`Geometry::GeometryInstance`] has none. - pub fn lod(&self) -> Option<&str> { + pub fn lod(&self) -> Option { match self { Geometry::MultiPoint { lod, .. } | Geometry::MultiLineString { lod, .. } @@ -202,7 +267,7 @@ impl Geometry { | Geometry::CompositeSurface { lod, .. } | Geometry::Solid { lod, .. } | Geometry::MultiSolid { lod, .. } - | Geometry::CompositeSolid { lod, .. } => lod.as_deref(), + | Geometry::CompositeSolid { lod, .. } => Some(*lod), Geometry::GeometryInstance { .. } => None, } } @@ -367,9 +432,10 @@ impl Geometry { continue; }; for ring in values.rings_mut() { - for (k, z) in ring.iter_mut().enumerate() { - if let Some(thevalue) = *z { - *z = Some(remap(k == 0, thevalue)); + if let TexturedRing::Textured { texture, uvs } = ring { + *texture = remap(true, *texture); + for uv in uvs { + *uv = remap(false, *uv); } } } @@ -422,7 +488,7 @@ mod tests { })) .unwrap_or_else(|e| panic!("{name} must accept its own depth: {e}")); assert_eq!(g.geometry_type(), thetype); - assert_eq!(g.lod(), Some("1")); + assert_eq!(g.lod().map(|l| l.to_string()).as_deref(), Some("1")); //-- and it must serialize back to exactly the boundaries it was given assert_eq!( serde_json::to_value(&g).unwrap()["boundaries"], @@ -560,7 +626,7 @@ mod tests { #[test] fn update_texture_honours_a_prefilled_map() { let mut g: Geometry = serde_json::from_value(serde_json::json!({ - "type": "MultiSurface", + "type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]]], "texture": {"t": {"values": [[[0, 2, 0, 1]]]}} })) @@ -669,12 +735,12 @@ mod tests { #[test] fn absent_members_are_not_serialized_as_null() { let g: Geometry = serde_json::from_value(serde_json::json!({ - "type": "MultiSurface", "boundaries": [[[0, 1, 2]]] + "type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]] })) .unwrap(); assert_eq!( serde_json::to_value(&g).unwrap(), - serde_json::json!({"type": "MultiSurface", "boundaries": [[[0, 1, 2]]]}) + serde_json::json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}) ); } } diff --git a/src/main.rs b/src/main.rs index f76d506..747f0f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -359,11 +359,6 @@ fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { "Input file not CityJSON.".to_string(), )); } - if cjj.version != "1.1" && cjj.version != "2.0" { - return Err(CjseqError::Validation( - "Input file not CityJSON v1.1 nor v2.0.".to_string(), - )); - } cjj.sort_cjfeatures(order); //-- first line: the CityJSON "metadata" let cj1 = cjj.get_metadata(); @@ -376,24 +371,3 @@ fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - /// `cat()` used to report a bad version with a hand-rolled `MyError` - /// local to the binary. It now reuses the library's `CjseqError`, so a - /// bad version must come back as `CjseqError::Validation`, not a panic - /// or a generic string error. - #[test] - fn cat_rejects_a_version_the_crate_does_not_support() { - let mut cjj = CityJSON::new(); - cjj.version = "9.9".to_string(); - let err = cat(&mut cjj, cjseq::SortingStrategy::Random).unwrap_err(); - assert!(matches!(err, CjseqError::Validation(_))); - assert_eq!( - err.to_string(), - "invalid CityJSON: Input file not CityJSON v1.1 nor v2.0." - ); - } -} diff --git a/src/metadata.rs b/src/metadata.rs index 49b3605..beed8e2 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -54,10 +54,9 @@ pub struct PointOfContact { pub contact_name: String, #[serde(rename = "contactType")] #[serde(skip_serializing_if = "Option::is_none")] - pub contact_type: Option, - #[serde(rename = "role")] + pub contact_type: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub role: Option, + pub role: Option, #[serde(skip_serializing_if = "Option::is_none")] pub phone: Option, #[serde(rename = "emailAddress")] @@ -77,6 +76,41 @@ pub struct PointOfContact { pub other: Map, } +/// `pointOfContact.contactType` (§ 5.3). +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum ContactType { + Individual, + Organization, +} + +/// `pointOfContact.role`, from the ISO 19115 role codelist (§ 5.3). +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub enum ContactRole { + ResourceProvider, + Custodian, + Owner, + User, + Distributor, + Originator, + PointOfContact, + PrincipalInvestigator, + Processor, + Publisher, + Author, + Sponsor, + #[serde(rename = "co-author")] + CoAuthor, + Collaborator, + Editor, + Mediator, + RightsHolder, + Contributor, + Funder, + Stakeholder, +} + /// A reference system following the OGC Name Type Specification. /// /// The format follows: `http://www.opengis.net/def/crs/{authority}/{version}/{code}` diff --git a/src/semantics.rs b/src/semantics.rs index 5f75257..107100e 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -4,8 +4,7 @@ use serde_json::{Map, Value}; /// The known surface types the spec assigns to Building-, WaterBody-, and /// Transportation-family City Objects (§ 3.3 Semantics of geometric /// primitives). Every variant is a unit variant already spelled exactly as -/// the spec requires, so serde's default (non-untagged) derive collapses -/// each one to its bare name string with no `#[serde(rename)]` needed. +/// the spec requires, so each serializes as its bare name. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum KnownSemanticSurfaceType { RoofSurface, @@ -36,12 +35,9 @@ pub enum KnownSemanticSurfaceType { /// (eg `"+ThermalSurface"`, § 8.5); `Extension` carries that string verbatim /// so it round-trips byte for byte. /// -/// This nests the known names inside their own unit-only enum rather than -/// mixing unit variants with `Extension(String)` directly in one flat -/// `#[serde(untagged)]` enum -- see [`crate::CityObjectType`]'s doc comment -/// for why a flat mix cannot round-trip (a unit variant under `untagged` -/// (de)serializes as `null`, not its name; this was verified empirically -/// while building `CityObjectType`, which has the identical shape). +/// As with [`crate::CityObjectType`], it is a plain string in JSON, and a +/// string that is neither a known name nor `+` followed by a name is an +/// error. /// /// # Match on the value, not on a reference /// @@ -68,7 +64,7 @@ pub enum KnownSemanticSurfaceType { /// arm is still required: `Known` and `Extension` are the real variants, so /// the known names alone are not exhaustive. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] -#[serde(untagged)] +#[serde(try_from = "String", into = "String")] pub enum SemanticSurfaceType { Known(KnownSemanticSurfaceType), /// Not a core CityJSON semantic surface type: a CityJSON Extension @@ -82,6 +78,36 @@ pub enum SemanticSurfaceType { /// variants -- and, on /// [`SemanticSurfaceType`][SemanticSurfaceType#match-on-the-value-not-on-a-reference], /// for why they must be matched on a value rather than on a reference. +impl TryFrom for SemanticSurfaceType { + type Error = crate::error::CjseqError; + /// A known name, or an Extension name: `+` followed by at least one + /// character. Anything else -- a misspelt core type, say -- is an error. + fn try_from(s: String) -> crate::error::Result { + if let Ok(k) = serde_json::from_value::(Value::String(s.clone())) + { + return Ok(SemanticSurfaceType::Known(k)); + } + if s.len() > 1 && s.starts_with('+') { + return Ok(SemanticSurfaceType::Extension(s)); + } + Err(crate::error::CjseqError::Validation(format!( + "{s:?} is not a semantic surface type; an Extension type starts with '+'" + ))) + } +} + +impl From for String { + fn from(t: SemanticSurfaceType) -> String { + match t { + SemanticSurfaceType::Known(k) => match serde_json::to_value(k) { + Ok(Value::String(s)) => s, + _ => unreachable!("a unit variant serializes as its name"), + }, + SemanticSurfaceType::Extension(s) => s, + } + } +} + #[allow(non_upper_case_globals)] impl SemanticSurfaceType { pub const RoofSurface: SemanticSurfaceType = @@ -453,7 +479,7 @@ mod tests { #[test] fn extension_typed_semantics_surface_roundtrips_byte_for_byte_inside_a_geometry() { let input = concat!( - r#"{"type":"MultiSurface","boundaries":[[[0,1,2]]],"#, + r#"{"type":"MultiSurface","lod":"1","boundaries":[[[0,1,2]]],"#, r#""semantics":{"surfaces":[{"type":"+ThermalSurface","azimuth":30.5}],"values":[0]}}"# ); let parsed: crate::geometry::Geometry = serde_json::from_str(input).unwrap(); diff --git a/tests/robustness.rs b/tests/robustness.rs index 468710b..e1a2793 100644 --- a/tests/robustness.rs +++ b/tests/robustness.rs @@ -112,7 +112,7 @@ fn a_feature_carries_every_descendant_not_just_children() { "b": {"type": "Building", "children": ["p"]}, "p": {"type": "BuildingPart", "parents": ["b"], "children": ["i"]}, "i": {"type": "BuildingInstallation", "parents": ["p"], "geometry": [ - {"type": "MultiPoint", "boundaries": [0]} + {"type": "MultiPoint", "lod": "1", "boundaries": [0]} ]} }), json!([[1, 2, 3]]), @@ -156,7 +156,7 @@ fn a_dangling_child_is_an_error_not_a_panic() { #[test] fn an_out_of_range_vertex_index_is_an_error_not_a_panic() { let cj = doc( - json!({"b": {"type": "Building", "geometry": [{"type": "MultiPoint", "boundaries": [0, 7]}]}}), + json!({"b": {"type": "Building", "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [0, 7]}]}}), json!([[1, 2, 3]]), ); assert!(matches!( diff --git a/tests/typed_strings.rs b/tests/typed_strings.rs new file mode 100644 index 0000000..2750a3e --- /dev/null +++ b/tests/typed_strings.rs @@ -0,0 +1,157 @@ +//! Strings the schema constrains are typed, so a value the schema forbids +//! fails to parse. + +use cjseq::{ + CityJSON, CityJSONVersion, CityObject, CityObjectType, ContactRole, ContactType, Geometry, Lod, + PointOfContact, SemanticSurfaceType, TextureReference, TexturedRing, +}; +use serde_json::json; +use std::str::FromStr; + +fn doc(extra: serde_json::Value) -> serde_json::Value { + let mut d = json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [] + }); + d.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + d +} + +#[test] +fn lod_is_one_of_the_schemas_values() { + for ok in ["0", "1", "2", "3", "0.0", "1.2", "2.2", "3.3"] { + let l: Lod = serde_json::from_value(json!(ok)).unwrap(); + assert_eq!(serde_json::to_value(l).unwrap(), json!(ok), "{ok}"); + } + for bad in ["4", "2.4", "2.", "x", "", "1.2.3", "02"] { + assert!( + serde_json::from_value::(json!(bad)).is_err(), + "{bad:?} must be rejected" + ); + } + let l: Lod = serde_json::from_value(json!("2.2")).unwrap(); + assert_eq!((l.level(), l.refinement()), (2, Some(2))); + let l: Lod = serde_json::from_value(json!("1")).unwrap(); + assert_eq!((l.level(), l.refinement()), (1, None)); +} + +#[test] +fn a_geometry_requires_a_lod() { + assert!(serde_json::from_value::(json!({ + "type": "MultiSurface", "boundaries": [[[0, 1, 2]]] + })) + .is_err()); + let g: Geometry = serde_json::from_value(json!({ + "type": "MultiSurface", "lod": "2.2", "boundaries": [[[0, 1, 2]]] + })) + .unwrap(); + assert_eq!(g.lod().map(|l| l.to_string()), Some("2.2".to_string())); +} + +#[test] +fn version_is_one_the_crate_supports() { + let cj = CityJSON::from_str(&doc(json!({})).to_string()).unwrap(); + assert_eq!(cj.version, CityJSONVersion::V2_0); + let cj = CityJSON::from_str(&doc(json!({"version": "1.1"})).to_string()).unwrap(); + assert_eq!(cj.version, CityJSONVersion::V1_1); + assert!(CityJSON::from_str(&doc(json!({"version": "9.9"})).to_string()).is_err()); + assert!(CityJSON::from_str(&doc(json!({"version": 2.0})).to_string()).is_err()); +} + +#[test] +fn attributes_are_an_object() { + let co: CityObject = + serde_json::from_value(json!({"type": "Building", "attributes": {"h": 3}})).unwrap(); + assert_eq!(co.attributes.unwrap()["h"], json!(3)); + assert!( + serde_json::from_value::(json!({"type": "Building", "attributes": [1]})) + .is_err() + ); +} + +#[test] +fn extensions_name_a_url_and_a_version() { + let cj = CityJSON::from_str( + &doc(json!({"extensions": {"Noise": {"url": "https://x.org/noise.ext.json", "version": "2.0"}}})) + .to_string(), + ) + .unwrap(); + let ext = &cj.extensions.as_ref().unwrap()["Noise"]; + assert_eq!(ext.url, "https://x.org/noise.ext.json"); + assert_eq!(ext.version, "2.0"); + assert!(CityJSON::from_str( + &doc(json!({"extensions": {"Noise": {"url": "https://x.org/noise.ext.json"}}})).to_string() + ) + .is_err()); +} + +#[test] +fn an_unknown_type_without_a_plus_is_rejected() { + assert!(serde_json::from_value::(json!("Buildng")).is_err()); + assert!(serde_json::from_value::(json!("+")).is_err()); + assert_eq!( + serde_json::from_value::(json!("+Noise")).unwrap(), + CityObjectType::Extension("+Noise".into()) + ); + assert!(serde_json::from_value::(json!("Roof")).is_err()); + assert_eq!( + serde_json::from_value::(json!("+Thermal")).unwrap(), + SemanticSurfaceType::Extension("+Thermal".into()) + ); +} + +#[test] +fn contact_type_and_role_are_the_spec_enums() { + let p: PointOfContact = serde_json::from_value(json!({ + "contactName": "a", "emailAddress": "b", + "contactType": "organization", "role": "co-author" + })) + .unwrap(); + assert_eq!(p.contact_type, Some(ContactType::Organization)); + assert_eq!(p.role, Some(ContactRole::CoAuthor)); + assert_eq!( + serde_json::to_value(&p).unwrap()["role"], + json!("co-author") + ); + let p: PointOfContact = serde_json::from_value(json!({ + "contactName": "a", "emailAddress": "b", "role": "pointOfContact" + })) + .unwrap(); + assert_eq!(p.role, Some(ContactRole::PointOfContact)); + assert!(serde_json::from_value::(json!({ + "contactName": "a", "emailAddress": "b", "contactType": "robot" + })) + .is_err()); +} + +#[test] +fn a_texture_ring_is_untextured_or_a_texture_with_uvs() { + let t: TextureReference = + serde_json::from_value(json!({"values": [[[0, 10, 11, 12]], [[null]]]})).unwrap(); + let v = serde_json::to_value(&t).unwrap(); + assert_eq!(v, json!({"values": [[[0, 10, 11, 12]], [[null]]]})); + let ring: TexturedRing = serde_json::from_value(json!([0, 10, 11, 12])).unwrap(); + assert_eq!( + ring, + TexturedRing::Textured { + texture: 0, + uvs: vec![10, 11, 12] + } + ); + let ring: TexturedRing = serde_json::from_value(json!([null])).unwrap(); + assert_eq!(ring, TexturedRing::Untextured); + for bad in [ + json!([0, null, 1]), + json!([null, 1]), + json!([]), + json!([null, null]), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "{bad}" + ); + } +} From bd098b992e54af70a73ee371815b0f212b91a03e Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:19:31 +0200 Subject: [PATCH 42/65] feat!: one type per kind of index VertexIndex, UvIndex (texture vertex), MaterialIndex, TextureIndex, SurfaceIndex and TemplateIndex are serde-transparent newtypes over usize, all implementing an Index trait that knows which array it indexes. Boundaries, GeometryInstance template and boundary, semantic surface parent/children and values, material value/values and texture rings use them, and the renumbering maps in get_cjfeature, add_cjfeature and remove_duplicate_vertices are typed per kind, so mixing up two kinds of index no longer compiles. IndexOutOfRange reports the kind from the type. --- src/appearance.rs | 31 ++++++++++------- src/cityjson.rs | 56 ++++++++++++++++--------------- src/error.rs | 2 ++ src/geometry.rs | 69 +++++++++++++++++--------------------- src/index.rs | 75 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +++ src/semantics.rs | 13 ++++---- tests/index_types.rs | 65 ++++++++++++++++++++++++++++++++++++ tests/typed_strings.rs | 4 +-- 9 files changed, 234 insertions(+), 85 deletions(-) create mode 100644 src/index.rs create mode 100644 tests/index_types.rs diff --git a/src/appearance.rs b/src/appearance.rs index a94a134..41ecb7a 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,3 +1,4 @@ +use crate::index::{MaterialIndex, TextureIndex, UvIndex}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -5,7 +6,7 @@ use serde_json::{Map, Value}; /// shell with no material — is `null` in JSON, which /// `geomprimitives.schema.json` permits at every level of `material.values` /// (`"type": ["array", "null"]`). -pub type MaterialShell = Vec>; +pub type MaterialShell = Vec>; /// One solid's worth of material indices, one per surface, per shell. pub type MaterialSolid = Vec>; @@ -26,7 +27,7 @@ pub type MaterialSolid = Vec>; #[serde(untagged)] pub enum MaterialValues { /// `MultiSurface`, `CompositeSurface`: one index per surface. - Surfaces(Vec>), + Surfaces(Vec>), /// `Solid`: one index per surface, per shell. Shells(Vec>), /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per @@ -38,7 +39,7 @@ impl MaterialValues { /// Every material index, in document order, whatever the depth. The depth /// is known from the variant, so no runtime inspection is needed; the /// `flatten()`s skip `None` sub-arrays as well as `null` leaves. - pub(crate) fn indices_mut(&mut self) -> Box + '_> { + pub(crate) fn indices_mut(&mut self) -> Box + '_> { match self { MaterialValues::Surfaces(v) => Box::new(v.iter_mut().flatten()), MaterialValues::Shells(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), @@ -63,7 +64,10 @@ pub enum TexturedRing { /// `[null]` Untextured, /// `[texture, uvs...]` - Textured { texture: usize, uvs: Vec }, + Textured { + texture: TextureIndex, + uvs: Vec, + }, } impl TryFrom>> for TexturedRing { @@ -72,11 +76,11 @@ impl TryFrom>> for TexturedRing { match v.split_first() { Some((None, [])) => Ok(TexturedRing::Untextured), Some((Some(texture), rest)) if !rest.is_empty() => Ok(TexturedRing::Textured { - texture: *texture, + texture: TextureIndex(*texture), uvs: rest .iter() .map(|uv| { - uv.ok_or_else(|| { + uv.map(UvIndex).ok_or_else(|| { crate::error::CjseqError::Validation( "a textured ring has a null texture-vertex index".to_string(), ) @@ -95,8 +99,8 @@ impl From for Vec> { fn from(r: TexturedRing) -> Self { match r { TexturedRing::Untextured => vec![None], - TexturedRing::Textured { texture, uvs } => std::iter::once(Some(texture)) - .chain(uvs.into_iter().map(Some)) + TexturedRing::Textured { texture, uvs } => std::iter::once(Some(texture.0)) + .chain(uvs.into_iter().map(|uv| Some(uv.0))) .collect(), } } @@ -183,7 +187,7 @@ pub struct MaterialReference { )] pub values: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub value: Option, + pub value: Option, /// The per-theme material object names `value` and `values` and declares /// no `additionalProperties: false`, so anything further is legal /// CityJSON and is kept rather than dropped. @@ -458,7 +462,7 @@ mod tests { fn a_material_or_texture_theme_keeps_members_the_schema_does_not_name() { let m: MaterialReference = serde_json::from_value(serde_json::json!({"value": 0, "vendorData": true})).unwrap(); - assert_eq!(m.value, Some(0)); + assert_eq!(m.value, Some(MaterialIndex(0))); assert_eq!( serde_json::to_value(&m).unwrap(), serde_json::json!({"value": 0, "vendorData": true}) @@ -533,7 +537,7 @@ mod tests { #[test] fn material_value_is_a_bare_integer() { let m: MaterialReference = serde_json::from_value(serde_json::json!({"value": 3})).unwrap(); - assert_eq!(m.value, Some(3)); + assert_eq!(m.value, Some(MaterialIndex(3))); assert_eq!( serde_json::to_value(&m).unwrap(), serde_json::json!({"value": 3}) @@ -739,7 +743,10 @@ mod tests { serde_json::from_value(serde_json::json!({"values": [0, 1]})).unwrap(); assert_eq!( array.values, - Some(Some(MaterialValues::Surfaces(vec![Some(0), Some(1)]))) + Some(Some(MaterialValues::Surfaces(vec![ + Some(MaterialIndex(0)), + Some(MaterialIndex(1)) + ]))) ); assert_eq!( serde_json::to_value(&array).unwrap(), diff --git a/src/cityjson.rs b/src/cityjson.rs index 50f1162..df108b3 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,7 +1,8 @@ use crate::appearance::{Appearance, MaterialObject, TextureObject}; use crate::city_object::CityObject; -use crate::error::{CjseqError, IndexKind, Result}; +use crate::error::{CjseqError, Result}; use crate::geometry::GeometryTemplates; +use crate::index::{Index, MaterialIndex, TextureIndex, UvIndex, VertexIndex}; use crate::metadata::{Metadata, Transform}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -20,14 +21,14 @@ pub type Vertex = [i64; 3]; /// new palette; `update_material`/`update_texture` build it by handing out /// consecutive new indices, so its values are exactly `0..oldnew.len()` and /// sorting by them reproduces the palette. -fn reindex(all: &[T], oldnew: &HashMap, kind: IndexKind) -> Result> { - let mut pairs: Vec<(usize, usize)> = oldnew.iter().map(|(o, n)| (*n, *o)).collect(); +fn reindex(all: &[T], oldnew: &HashMap) -> Result> { + let mut pairs: Vec<(usize, usize)> = oldnew.iter().map(|(o, n)| (n.get(), o.get())).collect(); pairs.sort_unstable(); pairs .into_iter() .map(|(_, old)| { all.get(old).cloned().ok_or(CjseqError::IndexOutOfRange { - kind, + kind: I::KIND, index: old, len: all.len(), }) @@ -39,21 +40,21 @@ fn reindex(all: &[T], oldnew: &HashMap, kind: IndexKind) /// `t_v_oldnew` refer to, with the default themes carried over. fn slice_appearance( all: &Appearance, - m_oldnew: &HashMap, - t_oldnew: &HashMap, - t_v_oldnew: &HashMap, + m_oldnew: &HashMap, + t_oldnew: &HashMap, + t_v_oldnew: &HashMap, ) -> Result { let mut a = Appearance::new(); a.default_theme_material = all.default_theme_material.clone(); a.default_theme_texture = all.default_theme_texture.clone(); if let Some(m) = &all.materials { - a.materials = Some(reindex(m, m_oldnew, IndexKind::Material)?); + a.materials = Some(reindex(m, m_oldnew)?); } if let Some(t) = &all.textures { - a.textures = Some(reindex(t, t_oldnew, IndexKind::Texture)?); + a.textures = Some(reindex(t, t_oldnew)?); } if let Some(tv) = &all.vertices_texture { - a.vertices_texture = Some(reindex(tv, t_v_oldnew, IndexKind::TextureVertex)?); + a.vertices_texture = Some(reindex(tv, t_v_oldnew)?); } Ok(a) } @@ -168,9 +169,9 @@ impl CityJSON { //-- added to 1st line (metadata) if let Some(x) = &self.geometry_templates { let mut gts2: GeometryTemplates = x.clone(); - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); for g in &mut gts2.templates { g.update_material(&mut m_oldnew); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); @@ -220,10 +221,10 @@ impl CityJSON { let mut cjf = CityJSONFeature::new(); cjf.id = id.clone(); - let mut g_vi_oldnew: HashMap = HashMap::new(); - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); + let mut g_vi_oldnew: HashMap = HashMap::new(); + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); for (key, co) in members { let mut co2 = co.clone(); for g in co2.geometry.iter_mut().flatten() { @@ -233,7 +234,7 @@ impl CityJSON { } cjf.add_co(key.clone(), co2); } - cjf.vertices = reindex(&self.vertices, &g_vi_oldnew, IndexKind::Vertex)?; + cjf.vertices = reindex(&self.vertices, &g_vi_oldnew)?; if let Some(a) = &self.appearance { cjf.appearance = Some(slice_appearance(a, &m_oldnew, &t_oldnew, &t_v_oldnew)?); } @@ -245,19 +246,22 @@ impl CityJSON { self.transform_correction = Some(t); } pub fn add_cjfeature(&mut self, cjf: &mut CityJSONFeature) { - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); let g_offset = self.vertices.len(); if let Some(cjf_app) = &cjf.appearance { if let Some(cjf_mat) = &cjf_app.materials { for (i, m) in cjf_mat.iter().enumerate() { - m_oldnew.insert(i, self.add_material(m.clone())); + m_oldnew.insert( + MaterialIndex(i), + MaterialIndex(self.add_material(m.clone())), + ); } } if let Some(cjf_tex) = &cjf_app.textures { for (i, m) in cjf_tex.iter().enumerate() { - t_oldnew.insert(i, self.add_texture(m.clone())); + t_oldnew.insert(TextureIndex(i), TextureIndex(self.add_texture(m.clone()))); } } if let Some(cjf_v_tex) = &cjf_app.vertices_texture { @@ -270,7 +274,7 @@ impl CityJSON { .and_then(|a| a.vertices_texture.as_ref()) .map_or(0, Vec::len); for i in 0..cjf_v_tex.len() { - t_v_oldnew.insert(i, t_v_offset + i); + t_v_oldnew.insert(UvIndex(i), UvIndex(t_v_offset + i)); } self.add_vertices_texture(cjf_v_tex.clone()); } @@ -289,14 +293,14 @@ impl CityJSON { } pub fn remove_duplicate_vertices(&mut self) { let mut h: HashMap = HashMap::new(); - let mut newids: HashMap = HashMap::new(); + let mut newids: HashMap = HashMap::new(); let mut newvertices: Vec = Vec::new(); for (i, v) in self.vertices.iter().enumerate() { let new = *h.entry(*v).or_insert_with(|| { newvertices.push(*v); newvertices.len() - 1 }); - newids.insert(i, new); + newids.insert(VertexIndex(i), VertexIndex(new)); } for co in self.city_objects.values_mut() { for g in co.geometry.iter_mut().flatten() { diff --git a/src/error.rs b/src/error.rs index d7cef7b..7629d27 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,6 +52,8 @@ pub enum IndexKind { TextureVertex, Material, Texture, + Surface, + Template, } impl fmt::Display for CjseqError { diff --git a/src/geometry.rs b/src/geometry.rs index cb6fbcd..504be80 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,5 +1,6 @@ use crate::appearance::{MaterialReference, TextureReference, TexturedRing}; use crate::error::{CjseqError, Result}; +use crate::index::{remap, MaterialIndex, TemplateIndex, TextureIndex, UvIndex, VertexIndex}; use crate::semantics::Semantics; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -7,7 +8,7 @@ use serde_json::Value; use std::collections::HashMap; /// A closed sequence of vertex indices bounding a face. -pub type Ring = Vec; +pub type Ring = Vec; /// A face: the exterior ring first, then any interior rings. pub type Surface = Vec; /// A closed collection of surfaces bounding a volume. @@ -202,8 +203,8 @@ pub enum Geometry { }, GeometryInstance { /// The one vertex the template is placed at. - boundaries: [usize; 1], - template: usize, + boundaries: [VertexIndex; 1], + template: TemplateIndex, #[serde(rename = "transformationMatrix")] transformation_matrix: [f64; 16], }, @@ -336,7 +337,7 @@ impl Geometry { /// Every vertex index in `boundaries`, in document order, whatever the /// nesting depth of this variant. The depth is known statically per /// variant, so no runtime inspection of the boundaries is needed. - fn boundary_indices_mut(&mut self) -> Box + '_> { + fn boundary_indices_mut(&mut self) -> Box + '_> { match self { Geometry::MultiPoint { boundaries, .. } => Box::new(boundaries.iter_mut()), Geometry::GeometryInstance { boundaries, .. } => Box::new(boundaries.iter_mut()), @@ -362,31 +363,32 @@ impl Geometry { } } - pub(crate) fn update_geometry_boundaries(&mut self, violdnew: &mut HashMap) { + pub(crate) fn update_geometry_boundaries( + &mut self, + violdnew: &mut HashMap, + ) { for vi in self.boundary_indices_mut() { - let next = violdnew.len(); - *vi = *violdnew.entry(*vi).or_insert(next); + *vi = remap(violdnew, *vi); } } pub(crate) fn offset_geometry_boundaries(&mut self, offset: usize) { for vi in self.boundary_indices_mut() { - *vi += offset; + vi.0 += offset; } } /// Renumber every material index against `m_oldnew`, first-encounter /// order. The depth of `values` comes from its variant, so the geometry /// type is never consulted. - pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { + pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { let Some(materials) = self.material_mut() else { return; }; for mat in materials.values_mut() { //-- material.value colours the whole object if let Some(thevalue) = mat.value { - let l = m_oldnew.len(); - mat.value = Some(*m_oldnew.entry(thevalue).or_insert(l)); + mat.value = Some(remap(m_oldnew, thevalue)); continue; } //-- else it's material.values, one index per surface. The outer @@ -396,8 +398,7 @@ impl Geometry { continue; }; for x in values.indices_mut() { - let l = m_oldnew.len(); - *x = *m_oldnew.entry(*x).or_insert(l); + *x = remap(m_oldnew, *x); } } } @@ -409,23 +410,12 @@ impl Geometry { /// does) fills the map first. pub(crate) fn update_texture( &mut self, - t_oldnew: &mut HashMap, - t_v_oldnew: &mut HashMap, + t_oldnew: &mut HashMap, + t_v_oldnew: &mut HashMap, ) { let Some(textures) = self.texture_mut() else { return; }; - //-- the first index of the innermost array is a texture, the rest are - //-- texture vertices; they are renumbered against different maps. - let mut remap = |is_texture: bool, thevalue: usize| -> usize { - let map = if is_texture { - &mut *t_oldnew - } else { - &mut *t_v_oldnew - }; - let l = map.len(); - *map.entry(thevalue).or_insert(l) - }; for tex in textures.values_mut() { //-- a theme may legally carry no `values` at all let Some(values) = tex.values.as_mut() else { @@ -433,9 +423,9 @@ impl Geometry { }; for ring in values.rings_mut() { if let TexturedRing::Textured { texture, uvs } = ring { - *texture = remap(true, *texture); + *texture = remap(t_oldnew, *texture); for uv in uvs { - *uv = remap(false, *uv); + *uv = remap(t_v_oldnew, *uv); } } } @@ -558,7 +548,7 @@ mod tests { "boundaries": [[[7, 9, 7]], [[9, 4, 4]]] })) .unwrap(); - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); g.update_geometry_boundaries(&mut map); assert_eq!( serde_json::to_value(&g).unwrap()["boundaries"], @@ -577,13 +567,13 @@ mod tests { "boundaries": [[[[[7, 9, 7]]]], [[[[9, 4, 4]]]]] })) .unwrap(); - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); g.update_geometry_boundaries(&mut map); assert_eq!( serde_json::to_value(&g).unwrap()["boundaries"], serde_json::json!([[[[[0, 1, 0]]]], [[[[1, 2, 2]]]]]) ); - let mut entries: Vec<(usize, usize)> = map.into_iter().collect(); + let mut entries: Vec<(usize, usize)> = map.into_iter().map(|(a, b)| (a.0, b.0)).collect(); entries.sort(); assert_eq!(entries, vec![(4, 2), (7, 0), (9, 1)]); } @@ -604,8 +594,8 @@ mod tests { ]}} })) .unwrap(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); let values = serde_json::to_value(&g.texture().unwrap()["winter"].values).unwrap(); @@ -613,10 +603,10 @@ mod tests { values, serde_json::json!([[[0, 0, 1, 2, 0]], [[0, 2, 1, 3, 2]], [[null]]]) ); - let mut tv: Vec<(usize, usize)> = t_v_oldnew.into_iter().collect(); + let mut tv: Vec<(usize, usize)> = t_v_oldnew.into_iter().map(|(a, b)| (a.0, b.0)).collect(); tv.sort(); assert_eq!(tv, vec![(20, 0), (21, 1), (22, 2), (30, 3)]); - let t: Vec<(usize, usize)> = t_oldnew.into_iter().collect(); + let t: Vec<(usize, usize)> = t_oldnew.into_iter().map(|(a, b)| (a.0, b.0)).collect(); assert_eq!(t, vec![(5, 0)]); } @@ -631,8 +621,9 @@ mod tests { "texture": {"t": {"values": [[[0, 2, 0, 1]]]}} })) .unwrap(); - let mut t_oldnew: HashMap = HashMap::from([(0, 3)]); - let mut t_v_oldnew: HashMap = (0..3).map(|i| (i, 10 + i)).collect(); + let mut t_oldnew = HashMap::from([(TextureIndex(0), TextureIndex(3))]); + let mut t_v_oldnew: HashMap = + (0..3).map(|i| (UvIndex(i), UvIndex(10 + i))).collect(); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); assert_eq!( serde_json::to_value(&g).unwrap()["texture"]["t"]["values"], @@ -652,7 +643,7 @@ mod tests { "material": {"irradiation": {"values": [[0, null, 2], [2, null, 0]]}} })) .unwrap(); - let mut m_oldnew: HashMap = HashMap::new(); + let mut m_oldnew: HashMap = HashMap::new(); g.update_material(&mut m_oldnew); //-- as above: typed `values`, oracle expectations unchanged. @@ -669,7 +660,7 @@ mod tests { "null holes must survive and indices must compact in first-encounter order" ); - let mut m: Vec<(usize, usize)> = m_oldnew.into_iter().collect(); + let mut m: Vec<(usize, usize)> = m_oldnew.into_iter().map(|(a, b)| (a.0, b.0)).collect(); m.sort(); assert_eq!(m, vec![(0, 0), (2, 1)]); } diff --git a/src/index.rs b/src/index.rs new file mode 100644 index 0000000..db26b30 --- /dev/null +++ b/src/index.rs @@ -0,0 +1,75 @@ +//! One type per kind of index, so an index into one array cannot be used as +//! an index into another. In JSON each is a plain non-negative integer. + +use crate::error::IndexKind; +use serde::{Deserialize, Serialize}; +use std::hash::Hash; + +/// What every index type can do: be built from and turned back into a +/// position, and say which array it indexes. +pub trait Index: Copy + Eq + Hash + std::fmt::Debug { + /// The array this kind of index points into. + const KIND: IndexKind; + fn new(i: usize) -> Self; + fn get(self) -> usize; +} + +macro_rules! index_type { + ($(#[$doc:meta])* $name:ident, $kind:ident) => { + $(#[$doc])* + #[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, + )] + #[serde(transparent)] + pub struct $name(pub usize); + + impl Index for $name { + const KIND: IndexKind = IndexKind::$kind; + fn new(i: usize) -> Self { + $name(i) + } + fn get(self) -> usize { + self.0 + } + } + }; +} + +index_type!( + /// A position in `vertices` (or, inside a geometry template, in + /// `vertices-templates`). + VertexIndex, + Vertex +); +index_type!( + /// A position in `appearance.vertices-texture`. + UvIndex, + TextureVertex +); +index_type!( + /// A position in `appearance.materials`. + MaterialIndex, + Material +); +index_type!( + /// A position in `appearance.textures`. + TextureIndex, + Texture +); +index_type!( + /// A position in a geometry's `semantics.surfaces`. + SurfaceIndex, + Surface +); +index_type!( + /// A position in `geometry-templates.templates`. + TemplateIndex, + Template +); + +/// Renumber `i` in first-encounter order: an index already in `map` keeps +/// its mapping, a new one gets the next free position. +pub(crate) fn remap(map: &mut std::collections::HashMap, i: I) -> I { + let next = I::new(map.len()); + *map.entry(i).or_insert(next) +} diff --git a/src/lib.rs b/src/lib.rs index 9e9f3eb..5ff47ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ mod city_object; mod cityjson; pub mod error; mod geometry; +mod index; mod metadata; mod semantics; @@ -34,6 +35,9 @@ pub use cityjson::*; //-- name; the alias stays reachable as `cjseq::error::Result`. pub use error::{CjseqError, IndexKind}; pub use geometry::*; +pub use index::{ + Index, MaterialIndex, SurfaceIndex, TemplateIndex, TextureIndex, UvIndex, VertexIndex, +}; pub use metadata::*; pub use semantics::*; diff --git a/src/semantics.rs b/src/semantics.rs index 107100e..f9f8927 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -1,3 +1,4 @@ +use crate::index::SurfaceIndex; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -155,9 +156,9 @@ pub struct SemanticsSurface { #[serde(rename = "type")] pub thetype: SemanticSurfaceType, #[serde(skip_serializing_if = "Option::is_none")] - pub parent: Option, + pub parent: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub children: Option>, + pub children: Option>, /// CityJSON lets a semantic surface carry arbitrary extra members /// (`slope`, `direction`, ...); they are kept verbatim so that a file /// round-trips without losing them. @@ -169,7 +170,7 @@ pub struct SemanticsSurface { /// whole shell with no semantics — is `null` in JSON, which /// `geomprimitives.schema.json` permits at every level of `semantics.values` /// (`"type": ["array", "null"]`), exactly as it does for `material.values`. -pub type SemanticsShell = Vec>; +pub type SemanticsShell = Vec>; /// One solid's worth of semantic-surface indices, per surface, per shell. pub type SemanticsSolid = Vec>; @@ -188,7 +189,7 @@ pub type SemanticsSolid = Vec>; #[serde(untagged)] pub enum SemanticsValues { /// `MultiSurface`, `CompositeSurface`: one index per surface. - Surfaces(Vec>), + Surfaces(Vec>), /// `Solid`: one index per surface, per shell. Shells(Vec>), /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per @@ -303,8 +304,8 @@ mod tests { "values": [0, 1] }); let s: Semantics = serde_json::from_value(input.clone()).unwrap(); - assert_eq!(s.surfaces[0].children, Some(vec![1])); - assert_eq!(s.surfaces[1].parent, Some(0)); + assert_eq!(s.surfaces[0].children, Some(vec![SurfaceIndex(1)])); + assert_eq!(s.surfaces[1].parent, Some(SurfaceIndex(0))); assert_eq!( s.surfaces[1].other["direction"], serde_json::json!("north"), diff --git a/tests/index_types.rs b/tests/index_types.rs new file mode 100644 index 0000000..8d20ccb --- /dev/null +++ b/tests/index_types.rs @@ -0,0 +1,65 @@ +//! Each kind of index has its own type, so a vertex index cannot be used where +//! a texture-vertex index is meant. In JSON they are plain integers. + +use cjseq::{ + Geometry, MaterialIndex, MaterialReference, SemanticsSurface, SurfaceIndex, TemplateIndex, + TextureIndex, TexturedRing, UvIndex, VertexIndex, +}; +use serde_json::json; + +#[test] +fn an_index_is_a_plain_integer_in_json() { + assert_eq!(serde_json::to_value(VertexIndex(5)).unwrap(), json!(5)); + let i: UvIndex = serde_json::from_value(json!(7)).unwrap(); + assert_eq!(i, UvIndex(7)); + assert!(serde_json::from_value::(json!(-1)).is_err()); +} + +#[test] +fn boundaries_hold_vertex_indices() { + let g: Geometry = serde_json::from_value(json!({ + "type": "MultiPoint", "lod": "1", "boundaries": [3, 4] + })) + .unwrap(); + let Geometry::MultiPoint { boundaries, .. } = g else { + panic!() + }; + assert_eq!(boundaries, vec![VertexIndex(3), VertexIndex(4)]); +} + +#[test] +fn a_geometry_instance_holds_a_template_index() { + let g: Geometry = serde_json::from_value(json!({ + "type": "GeometryInstance", "template": 2, "boundaries": [9], + "transformationMatrix": [1.0,0.0,0.0,0.0, 0.0,1.0,0.0,0.0, 0.0,0.0,1.0,0.0, 0.0,0.0,0.0,1.0] + })) + .unwrap(); + let Geometry::GeometryInstance { + template, + boundaries, + .. + } = g + else { + panic!() + }; + assert_eq!(template, TemplateIndex(2)); + assert_eq!(boundaries, [VertexIndex(9)]); +} + +#[test] +fn appearance_and_semantics_indices_are_typed() { + let ring: TexturedRing = serde_json::from_value(json!([0, 1, 2])).unwrap(); + assert_eq!( + ring, + TexturedRing::Textured { + texture: TextureIndex(0), + uvs: vec![UvIndex(1), UvIndex(2)] + } + ); + let s: SemanticsSurface = + serde_json::from_value(json!({"type": "Window", "parent": 1, "children": [2]})).unwrap(); + assert_eq!(s.parent, Some(SurfaceIndex(1))); + assert_eq!(s.children, Some(vec![SurfaceIndex(2)])); + let m: MaterialReference = serde_json::from_value(json!({"value": 4})).unwrap(); + assert_eq!(m.value, Some(MaterialIndex(4))); +} diff --git a/tests/typed_strings.rs b/tests/typed_strings.rs index 2750a3e..a0bd2a8 100644 --- a/tests/typed_strings.rs +++ b/tests/typed_strings.rs @@ -137,8 +137,8 @@ fn a_texture_ring_is_untextured_or_a_texture_with_uvs() { assert_eq!( ring, TexturedRing::Textured { - texture: 0, - uvs: vec![10, 11, 12] + texture: cjseq::TextureIndex(0), + uvs: vec![cjseq::UvIndex(10), cjseq::UvIndex(11), cjseq::UvIndex(12)] } ); let ring: TexturedRing = serde_json::from_value(json!([null])).unwrap(); From a29c680ef59a6adca688f4d9cad88b9bab5b3787 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:23:17 +0200 Subject: [PATCH 43/65] feat!: values nested exactly as deep as their geometry needs Semantics, MaterialReference and TextureReference are generic over the shape of their values, and each Geometry variant names its own: PrimitiveValues for MultiPoint/MultiLineString/MultiSurface/ CompositeSurface, SolidValues for Solid, MultiSolidValues for MultiSolid/CompositeSolid (of SurfaceIndex or MaterialIndex), and texture rings nested as deeply as the boundaries. Values nested to the wrong depth for their geometry no longer parse, an empty values array is no longer ambiguous, and the untagged SemanticsValues, MaterialValues and TextureValues ladders are gone. Indices are walked through one Indices trait over the index types and the containers that nest them. Geometry::semantics/material/texture are replaced by surfaces(); the depth-specific values are on each variant. On a 65 MB, 15,000-feature CityJSONSeq (release build, median of 3) collect goes from 0.78 s to 0.68 s and cat from 0.47 s to 0.43 s, with byte-identical output. --- src/appearance.rs | 324 ++++++------------------------------------- src/geometry.rs | 214 ++++++++++------------------ src/index.rs | 56 ++++++++ src/lib.rs | 8 +- src/semantics.rs | 177 ++++------------------- tests/value_depth.rs | 124 +++++++++++++++++ 6 files changed, 330 insertions(+), 573 deletions(-) create mode 100644 tests/value_depth.rs diff --git a/src/appearance.rs b/src/appearance.rs index 41ecb7a..0c293c3 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -1,60 +1,7 @@ -use crate::index::{MaterialIndex, TextureIndex, UvIndex}; +use crate::index::{Indices, MaterialIndex, PrimitiveValues, TextureIndex, UvIndex}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -/// One shell's worth of material indices, one per surface. `None` — a whole -/// shell with no material — is `null` in JSON, which -/// `geomprimitives.schema.json` permits at every level of `material.values` -/// (`"type": ["array", "null"]`). -pub type MaterialShell = Vec>; -/// One solid's worth of material indices, one per surface, per shell. -pub type MaterialSolid = Vec>; - -/// The `values` array of a [`MaterialReference`]: one index into the -/// document's `materials` palette per *surface*, so it is nested exactly two -/// levels less deeply than that geometry's `boundaries` (CityJSON 2.0, -/// section 6.1). -/// -/// `null` is permitted at *every* level, not only at the leaf: a surface, a -/// whole shell, or a whole solid may have no material. Every one of those -/// `None`s must serialize back as `null` and never as `[]` — that is -/// finding #7, and it is pinned by test at each level. -/// -/// The variants are ordered shallowest-first; serde tries them in declaration -/// order, so a value only reaches a deeper variant when the shallower ones -/// cannot describe it. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(untagged)] -pub enum MaterialValues { - /// `MultiSurface`, `CompositeSurface`: one index per surface. - Surfaces(Vec>), - /// `Solid`: one index per surface, per shell. - Shells(Vec>), - /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per - /// solid. - Solids(Vec>), -} - -impl MaterialValues { - /// Every material index, in document order, whatever the depth. The depth - /// is known from the variant, so no runtime inspection is needed; the - /// `flatten()`s skip `None` sub-arrays as well as `null` leaves. - pub(crate) fn indices_mut(&mut self) -> Box + '_> { - match self { - MaterialValues::Surfaces(v) => Box::new(v.iter_mut().flatten()), - MaterialValues::Shells(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), - MaterialValues::Solids(v) => Box::new( - v.iter_mut() - .flatten() - .flatten() - .flatten() - .flatten() - .flatten(), - ), - } - } -} - /// One ring's texture entry. In JSON it is `[texture, uv, uv, ...]` -- the /// texture index first, then one texture-vertex index per vertex of the ring /// -- or `[null]` for a ring with no texture. @@ -110,43 +57,18 @@ pub type TexturedSurface = Vec; /// One volume's bounding faces, mirroring [`crate::Shell`]. pub type TexturedShell = Vec; -/// The `values` array of a [`TextureReference`]: nested exactly as deeply as -/// the geometry's `boundaries` (CityJSON 2.0, section 6.2), with each ring -/// becoming `[texture_index, uv_index, ...]` — one more entry than the ring -/// has vertices. An untextured ring is `[null]`. -/// -/// Only surface-bearing geometries can carry a texture, so the ladder starts -/// at `MultiSurface` depth: a `MultiPoint` or `MultiLineString` has no rings -/// to texture, and both declare `additionalProperties: false` without a -/// `texture` member. -/// -/// Unlike [`MaterialValues`], the intermediate levels are *not* nullable. -/// `geomprimitives.schema.json` types `texture.values` and every one of its -/// intermediate `items` as a plain `"array"` — only the innermost items are -/// `["integer", "null"]` — so `[null, [[0, 10, 11]]]` is not valid CityJSON -/// and is rejected here. -/// -/// The variants are ordered shallowest-first, as in [`MaterialValues`]. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(untagged)] -pub enum TextureValues { - /// `MultiSurface`, `CompositeSurface`: per surface, per ring. - Surface(Vec), - /// `Solid`: ... per shell. - Shell(Vec), - /// `MultiSolid`, `CompositeSolid`: ... per solid. - Solid(Vec>), +impl Indices for TexturedRing { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut TextureIndex)) { + if let TexturedRing::Textured { texture, .. } = self { + f(texture) + } + } } -impl TextureValues { - /// Every ring, in document order, whatever the depth. A ring is - /// `[texture_index, uv_index, ...]`, so the caller renumbers its first - /// entry against a different map than the rest. - pub(crate) fn rings_mut(&mut self) -> Box + '_> { - match self { - TextureValues::Surface(v) => Box::new(v.iter_mut().flatten()), - TextureValues::Shell(v) => Box::new(v.iter_mut().flatten().flatten()), - TextureValues::Solid(v) => Box::new(v.iter_mut().flatten().flatten().flatten()), +impl Indices for TexturedRing { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut UvIndex)) { + if let TexturedRing::Textured { uvs, .. } = self { + uvs.iter_mut().for_each(f) } } } @@ -178,14 +100,19 @@ where /// is present-vs-absent, the inner is `null`-vs-array. Without that /// distinction a schema-valid `{"values": null}` re-emits as `{}`, which /// satisfies neither branch of the `oneOf` and is rejected by a validator. +/// +/// `V` is the shape of `values`, fixed by the geometry type: +/// [`PrimitiveValues`], [`crate::SolidValues`] or [`crate::MultiSolidValues`] +/// of [`MaterialIndex`]. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct MaterialReference { +#[serde(bound(deserialize = "V: Deserialize<'de>"))] +pub struct MaterialReference> { #[serde( default, deserialize_with = "present_even_if_null", skip_serializing_if = "Option::is_none" )] - pub values: Option>, + pub values: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, /// The per-theme material object names `value` and `values` and declares @@ -195,6 +122,13 @@ pub struct MaterialReference { pub other: Map, } +impl> Indices for MaterialReference { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut MaterialIndex)) { + self.value.for_each_index(f); + self.values.for_each_index(f); + } +} + /// One theme's texture assignment for a geometry. /// /// `values` is optional: the per-theme texture object in @@ -202,15 +136,21 @@ pub struct MaterialReference { /// contrast to `material`, which requires exactly one of `value`/`values`), so /// a bare `{"theme": {}}` is valid CityJSON. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct TextureReference { +pub struct TextureReference> { #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, + pub values: Option, /// As with [`MaterialReference`]: the per-theme texture object declares no /// `additionalProperties: false`, so extra members are legal and kept. #[serde(flatten)] pub other: Map, } +impl> Indices for TextureReference { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + self.values.for_each_index(f); + } +} + /// A material or border colour: three numbers, per /// `appearance.schema.json`'s `"minItems": 3, "maxItems": 3` on /// `diffuseColor`, `emissiveColor` and `specularColor`. @@ -342,7 +282,7 @@ pub struct MaterialObject { } /// One entry of the document-level `appearance.textures` palette, referred to -/// by the first entry of each ring in a [`TextureValues`]. +/// by the `texture` of each [`TexturedRing`]. /// /// `appearance.schema.json#/Texture`, in full: /// @@ -509,11 +449,11 @@ mod tests { #[test] fn solid_material_values_keep_their_shell_level() { - // Finding #8: this shape came back as [[[0, 1]]] through FCB. - let m: MaterialReference = serde_json::from_value(serde_json::json!({ - "values": [[0, 1]] - })) - .unwrap(); + let m: MaterialReference> = + serde_json::from_value(serde_json::json!({ + "values": [[0, 1]] + })) + .unwrap(); assert_eq!( serde_json::to_value(&m).unwrap(), serde_json::json!({"values": [[0, 1]]}) @@ -544,149 +484,6 @@ mod tests { ); } - /// The untagged variant order is load-bearing: asserting only on the - /// re-serialized JSON would pass even if a value landed in the wrong - /// variant, so pin the variant identity itself. - /// - /// Material values are nested two levels less deeply than the boundaries - /// of the geometry that carries them (CityJSON 2.0, section 6.1). - #[test] - fn material_values_land_in_the_variant_their_depth_implies() { - let parse = |v: serde_json::Value| -> MaterialValues { serde_json::from_value(v).unwrap() }; - - //-- MultiSurface/CompositeSurface: one index per surface - assert!(matches!( - parse(serde_json::json!([0, null, 2])), - MaterialValues::Surfaces(_) - )); - //-- Solid: one index per surface, per shell - assert!(matches!( - parse(serde_json::json!([[0, null, 2]])), - MaterialValues::Shells(_) - )); - //-- MultiSolid/CompositeSolid: ... per solid - assert!(matches!( - parse(serde_json::json!([[[0, null, 2]]])), - MaterialValues::Solids(_) - )); - - //-- the empty array is ambiguous; it must resolve to the shallowest - //-- variant, and stay `[]` on the way out - let empty = parse(serde_json::json!([])); - assert!(matches!(empty, MaterialValues::Surfaces(_))); - assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([])); - - //-- deeper than the ladder is not silently accepted - assert!(serde_json::from_value::(serde_json::json!([[[[0]]]])).is_err()); - //-- and neither is a bare scalar - assert!(serde_json::from_value::(serde_json::json!(0)).is_err()); - //-- the untyped Option this replaced accepted six levels - assert!( - serde_json::from_value::(serde_json::json!([[[[[[0]]]]]])).is_err() - ); - } - - /// `geomprimitives.schema.json` types `material.values` and every one of - /// its intermediate `items` as `["array", "null"]`, so a whole null shell - /// or a whole null solid is valid CityJSON. Each of those `None`s must - /// come back as `null` — never as `[]`, which is finding #7, the bug this - /// whole rewrite exists to prevent. - #[test] - fn a_null_material_sub_array_round_trips_as_null_at_every_level() { - for input in [ - //-- leaf: a surface with no material - serde_json::json!([0, null, 2]), - //-- intermediate: a whole shell with no material - serde_json::json!([[0, 1], null]), - serde_json::json!([null, [0, 1]]), - //-- intermediate: a whole solid, and a shell inside a solid - serde_json::json!([[[0, 1]], null]), - serde_json::json!([[[0, 1], null], null]), - //-- nothing but nulls, at each depth - serde_json::json!([null, null]), - serde_json::json!([[null], null]), - ] { - let v: MaterialValues = serde_json::from_value(input.clone()) - .unwrap_or_else(|e| panic!("{input} is schema-valid CityJSON: {e}")); - assert_eq!( - serde_json::to_value(&v).unwrap(), - input, - "{input} must round-trip with its nulls intact, never as []" - ); - } - } - - /// The variant ladder must still resolve by depth now that the - /// intermediate levels are `Option`. - #[test] - fn null_sub_arrays_do_not_disturb_the_material_ladder() { - let parse = |v: serde_json::Value| -> MaterialValues { serde_json::from_value(v).unwrap() }; - - assert!(matches!( - parse(serde_json::json!([null, 1])), - MaterialValues::Surfaces(_) - )); - assert!(matches!( - parse(serde_json::json!([[0, 1], null])), - MaterialValues::Shells(_) - )); - assert!(matches!( - parse(serde_json::json!([[[0, 1]], null])), - MaterialValues::Solids(_) - )); - - //-- the empty-shape edges: each is ambiguous and must settle on the - //-- shallowest variant that can hold it, and stay byte-identical - for (input, is_surfaces) in [ - (serde_json::json!([]), true), - (serde_json::json!([[]]), false), - (serde_json::json!([[[]]]), false), - ] { - let v = parse(input.clone()); - assert_eq!(serde_json::to_value(&v).unwrap(), input, "{input}"); - assert_eq!( - matches!(v, MaterialValues::Surfaces(_)), - is_surfaces, - "{input} landed in {v:?}" - ); - } - assert!(matches!( - parse(serde_json::json!([[]])), - MaterialValues::Shells(_) - )); - assert!(matches!( - parse(serde_json::json!([[[]]])), - MaterialValues::Solids(_) - )); - } - - /// The texture ladder is deliberately *not* null-tolerant at its - /// intermediate levels: `geomprimitives.schema.json` types - /// `texture.values` and each intermediate `items` as a plain `"array"`, - /// with only the innermost items `["integer", "null"]`. An untextured ring - /// is spelled `[null]`, not `null`. - #[test] - fn texture_nulls_are_leaf_only() { - //-- what the schema allows: a null texture index inside a ring - let t: TextureValues = serde_json::from_value(serde_json::json!([[[null]]])).unwrap(); - assert_eq!( - serde_json::to_value(&t).unwrap(), - serde_json::json!([[[null]]]) - ); - - //-- what it does not: a null in place of a surface, ring, or shell - for bad in [ - serde_json::json!([null, [[0, 10, 11]]]), - serde_json::json!([[null]]), - serde_json::json!([[[[0, 1]]], null]), - ] { - assert!( - serde_json::from_value::(bad.clone()).is_err(), - "{bad} is not valid CityJSON and must be rejected" - ); - } - } - /// The per-theme texture object has no `required` keyword in the schema, /// so both of these are valid CityJSON and must parse. #[test] @@ -743,10 +540,7 @@ mod tests { serde_json::from_value(serde_json::json!({"values": [0, 1]})).unwrap(); assert_eq!( array.values, - Some(Some(MaterialValues::Surfaces(vec![ - Some(MaterialIndex(0)), - Some(MaterialIndex(1)) - ]))) + Some(Some(vec![Some(MaterialIndex(0)), Some(MaterialIndex(1))])) ); assert_eq!( serde_json::to_value(&array).unwrap(), @@ -1066,42 +860,4 @@ mod tests { let a: Appearance = serde_json::from_value(input.clone()).unwrap(); assert_eq!(serde_json::to_value(&a).unwrap(), input); } - - /// Texture values are nested exactly as deeply as the boundaries of the - /// geometry that carries them (CityJSON 2.0, section 6.2), each ring - /// becoming `[texture_index, uv_index, ...]`. - #[test] - fn texture_values_land_in_the_variant_their_depth_implies() { - let parse = |v: serde_json::Value| -> TextureValues { serde_json::from_value(v).unwrap() }; - - //-- MultiSurface/CompositeSurface: per surface, per ring - assert!(matches!( - parse(serde_json::json!([[[0, 10, 11, 12]]])), - TextureValues::Surface(_) - )); - //-- Solid: ... per shell - assert!(matches!( - parse(serde_json::json!([[[[0, 10, 11, 12]]]])), - TextureValues::Shell(_) - )); - //-- MultiSolid/CompositeSolid: ... per solid - assert!(matches!( - parse(serde_json::json!([[[[[0, 10, 11, 12]]]]])), - TextureValues::Solid(_) - )); - //-- an untextured ring is `[null]`, at every depth - assert!(matches!( - parse(serde_json::json!([[[null]]])), - TextureValues::Surface(_) - )); - - let empty = parse(serde_json::json!([])); - assert!(matches!(empty, TextureValues::Surface(_))); - assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([])); - - //-- too deep, and too shallow, are both rejected: the untyped - //-- `Option` this replaces accepted either without a murmur - assert!(serde_json::from_value::(serde_json::json!([[[[[[0]]]]]])).is_err()); - assert!(serde_json::from_value::(serde_json::json!([0, 1])).is_err()); - } } diff --git a/src/geometry.rs b/src/geometry.rs index 504be80..3deb94e 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1,7 +1,12 @@ -use crate::appearance::{MaterialReference, TextureReference, TexturedRing}; +use crate::appearance::{ + MaterialReference, TextureReference, TexturedRing, TexturedShell, TexturedSurface, +}; use crate::error::{CjseqError, Result}; -use crate::index::{remap, MaterialIndex, TemplateIndex, TextureIndex, UvIndex, VertexIndex}; -use crate::semantics::Semantics; +use crate::index::{ + remap, Indices, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, + TemplateIndex, TextureIndex, UvIndex, VertexIndex, +}; +use crate::semantics::{Semantics, SemanticsSurface}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -133,6 +138,10 @@ fn nesting_depth(v: &Value) -> usize { /// previously it decoded happily into an untyped `serde_json::Value` and left /// every consumer to guess the depth. /// +/// So is the nesting depth of semantics, material and texture `values`: each +/// variant names its own value types, so values nested to the wrong depth for +/// their geometry do not deserialize either. +/// /// Each variant has exactly the members the schema names for it, and the /// schema closes every geometry object, so any other member is rejected. A /// MultiPoint or MultiLineString carries no material or texture. @@ -155,51 +164,51 @@ pub enum Geometry { lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] - semantics: Option, + semantics: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - material: Option>, + material: Option>>>, #[serde(skip_serializing_if = "Option::is_none")] - texture: Option>, + texture: Option>>>, }, CompositeSurface { lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] - semantics: Option, + semantics: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - material: Option>, + material: Option>>>, #[serde(skip_serializing_if = "Option::is_none")] - texture: Option>, + texture: Option>>>, }, Solid { lod: Lod, boundaries: Vec, #[serde(skip_serializing_if = "Option::is_none")] - semantics: Option, + semantics: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - material: Option>, + material: Option>>>, #[serde(skip_serializing_if = "Option::is_none")] - texture: Option>, + texture: Option>>>, }, MultiSolid { lod: Lod, boundaries: Vec>, #[serde(skip_serializing_if = "Option::is_none")] - semantics: Option, + semantics: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - material: Option>, + material: Option>>>, #[serde(skip_serializing_if = "Option::is_none")] - texture: Option>, + texture: Option>>>>, }, CompositeSolid { lod: Lod, boundaries: Vec>, #[serde(skip_serializing_if = "Option::is_none")] - semantics: Option, + semantics: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - material: Option>, + material: Option>>>, #[serde(skip_serializing_if = "Option::is_none")] - texture: Option>, + texture: Option>>>>, }, GeometryInstance { /// The one vertex the template is placed at. @@ -273,93 +282,66 @@ impl Geometry { } } - pub fn semantics(&self) -> Option<&Semantics> { + /// The semantic surfaces, whatever the geometry's depth; the `values` + /// that refer to them are on each variant. + pub fn surfaces(&self) -> Option<&[SemanticsSurface]> { match self { Geometry::MultiPoint { semantics, .. } | Geometry::MultiLineString { semantics, .. } | Geometry::MultiSurface { semantics, .. } - | Geometry::CompositeSurface { semantics, .. } - | Geometry::Solid { semantics, .. } - | Geometry::MultiSolid { semantics, .. } - | Geometry::CompositeSolid { semantics, .. } => semantics.as_ref(), + | Geometry::CompositeSurface { semantics, .. } => { + semantics.as_ref().map(|s| &s.surfaces[..]) + } + Geometry::Solid { semantics, .. } => semantics.as_ref().map(|s| &s.surfaces[..]), + Geometry::MultiSolid { semantics, .. } | Geometry::CompositeSolid { semantics, .. } => { + semantics.as_ref().map(|s| &s.surfaces[..]) + } Geometry::GeometryInstance { .. } => None, } } - /// The material themes. Only surface and solid geometries can carry - /// them; a [`Geometry::GeometryInstance`]'s appearance lives on its - /// template. - pub fn material(&self) -> Option<&Themes> { + /// Visit every vertex index in `boundaries`, in document order. + pub(crate) fn for_each_vertex(&mut self, f: &mut dyn FnMut(&mut VertexIndex)) { match self { - Geometry::MultiSurface { material, .. } - | Geometry::CompositeSurface { material, .. } - | Geometry::Solid { material, .. } - | Geometry::MultiSolid { material, .. } - | Geometry::CompositeSolid { material, .. } => material.as_ref(), - _ => None, + Geometry::MultiPoint { boundaries, .. } => boundaries.for_each_index(f), + Geometry::MultiLineString { boundaries, .. } => boundaries.for_each_index(f), + Geometry::MultiSurface { boundaries, .. } + | Geometry::CompositeSurface { boundaries, .. } => boundaries.for_each_index(f), + Geometry::Solid { boundaries, .. } => boundaries.for_each_index(f), + Geometry::MultiSolid { boundaries, .. } + | Geometry::CompositeSolid { boundaries, .. } => boundaries.for_each_index(f), + Geometry::GeometryInstance { boundaries, .. } => boundaries.for_each_index(f), } } - fn material_mut(&mut self) -> Option<&mut Themes> { + /// Visit every material index (`value` and `values`, every theme). + pub(crate) fn for_each_material(&mut self, f: &mut dyn FnMut(&mut MaterialIndex)) { match self { Geometry::MultiSurface { material, .. } - | Geometry::CompositeSurface { material, .. } - | Geometry::Solid { material, .. } - | Geometry::MultiSolid { material, .. } - | Geometry::CompositeSolid { material, .. } => material.as_mut(), - _ => None, - } - } - - /// The texture themes; as for [`Geometry::material`]. - pub fn texture(&self) -> Option<&Themes> { - match self { - Geometry::MultiSurface { texture, .. } - | Geometry::CompositeSurface { texture, .. } - | Geometry::Solid { texture, .. } - | Geometry::MultiSolid { texture, .. } - | Geometry::CompositeSolid { texture, .. } => texture.as_ref(), - _ => None, - } - } - - fn texture_mut(&mut self) -> Option<&mut Themes> { - match self { - Geometry::MultiSurface { texture, .. } - | Geometry::CompositeSurface { texture, .. } - | Geometry::Solid { texture, .. } - | Geometry::MultiSolid { texture, .. } - | Geometry::CompositeSolid { texture, .. } => texture.as_mut(), - _ => None, + | Geometry::CompositeSurface { material, .. } => material.for_each_index(f), + Geometry::Solid { material, .. } => material.for_each_index(f), + Geometry::MultiSolid { material, .. } | Geometry::CompositeSolid { material, .. } => { + material.for_each_index(f) + } + _ => {} } } - /// Every vertex index in `boundaries`, in document order, whatever the - /// nesting depth of this variant. The depth is known statically per - /// variant, so no runtime inspection of the boundaries is needed. - fn boundary_indices_mut(&mut self) -> Box + '_> { + /// Visit every texture index, or every texture-vertex index, in the + /// texture rings of every theme. + pub(crate) fn for_each_texture(&mut self, f: &mut dyn FnMut(&mut I)) + where + TexturedRing: Indices, + { match self { - Geometry::MultiPoint { boundaries, .. } => Box::new(boundaries.iter_mut()), - Geometry::GeometryInstance { boundaries, .. } => Box::new(boundaries.iter_mut()), - Geometry::MultiLineString { boundaries, .. } => { - Box::new(boundaries.iter_mut().flatten()) + Geometry::MultiSurface { texture, .. } | Geometry::CompositeSurface { texture, .. } => { + texture.for_each_index(f) } - Geometry::MultiSurface { boundaries, .. } - | Geometry::CompositeSurface { boundaries, .. } => { - Box::new(boundaries.iter_mut().flatten().flatten()) - } - Geometry::Solid { boundaries, .. } => { - Box::new(boundaries.iter_mut().flatten().flatten().flatten()) + Geometry::Solid { texture, .. } => texture.for_each_index(f), + Geometry::MultiSolid { texture, .. } | Geometry::CompositeSolid { texture, .. } => { + texture.for_each_index(f) } - Geometry::MultiSolid { boundaries, .. } - | Geometry::CompositeSolid { boundaries, .. } => Box::new( - boundaries - .iter_mut() - .flatten() - .flatten() - .flatten() - .flatten(), - ), + _ => {} } } @@ -367,40 +349,17 @@ impl Geometry { &mut self, violdnew: &mut HashMap, ) { - for vi in self.boundary_indices_mut() { - *vi = remap(violdnew, *vi); - } + self.for_each_vertex(&mut |vi| *vi = remap(violdnew, *vi)); } pub(crate) fn offset_geometry_boundaries(&mut self, offset: usize) { - for vi in self.boundary_indices_mut() { - vi.0 += offset; - } + self.for_each_vertex(&mut |vi| vi.0 += offset); } /// Renumber every material index against `m_oldnew`, first-encounter - /// order. The depth of `values` comes from its variant, so the geometry - /// type is never consulted. + /// order. pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { - let Some(materials) = self.material_mut() else { - return; - }; - for mat in materials.values_mut() { - //-- material.value colours the whole object - if let Some(thevalue) = mat.value { - mat.value = Some(remap(m_oldnew, thevalue)); - continue; - } - //-- else it's material.values, one index per surface. The outer - //-- Option is present-vs-absent, the inner null-vs-array; there is - //-- nothing to renumber unless both are Some. - let Some(values) = mat.values.as_mut().and_then(|v| v.as_mut()) else { - continue; - }; - for x in values.indices_mut() { - *x = remap(m_oldnew, *x); - } - } + self.for_each_material(&mut |m| *m = remap(m_oldnew, *m)); } /// Renumber every texture index against `t_oldnew` and every texture @@ -413,23 +372,8 @@ impl Geometry { t_oldnew: &mut HashMap, t_v_oldnew: &mut HashMap, ) { - let Some(textures) = self.texture_mut() else { - return; - }; - for tex in textures.values_mut() { - //-- a theme may legally carry no `values` at all - let Some(values) = tex.values.as_mut() else { - continue; - }; - for ring in values.rings_mut() { - if let TexturedRing::Textured { texture, uvs } = ring { - *texture = remap(t_oldnew, *texture); - for uv in uvs { - *uv = remap(t_v_oldnew, *uv); - } - } - } - } + self.for_each_texture(&mut |t: &mut TextureIndex| *t = remap(t_oldnew, *t)); + self.for_each_texture(&mut |uv: &mut UvIndex| *uv = remap(t_v_oldnew, *uv)); } } @@ -523,7 +467,7 @@ mod tests { }); let g: Geometry = serde_json::from_value(input.clone()).unwrap(); assert_eq!(g.geometry_type(), GeometryType::GeometryInstance); - assert!(g.semantics().is_none() && g.material().is_none()); + assert!(g.surfaces().is_none()); assert_eq!(serde_json::to_value(&g).unwrap(), input); } @@ -598,7 +542,7 @@ mod tests { let mut t_v_oldnew: HashMap = HashMap::new(); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); - let values = serde_json::to_value(&g.texture().unwrap()["winter"].values).unwrap(); + let values = serde_json::to_value(&g).unwrap()["texture"]["winter"]["values"].clone(); assert_eq!( values, serde_json::json!([[[0, 0, 1, 2, 0]], [[0, 2, 1, 3, 2]], [[null]]]) @@ -647,13 +591,7 @@ mod tests { g.update_material(&mut m_oldnew); //-- as above: typed `values`, oracle expectations unchanged. - let values = serde_json::to_value( - g.material().unwrap()["irradiation"] - .values - .as_ref() - .unwrap(), - ) - .unwrap(); + let values = serde_json::to_value(&g).unwrap()["material"]["irradiation"]["values"].clone(); assert_eq!( values, serde_json::json!([[0, null, 1], [1, null, 0]]), diff --git a/src/index.rs b/src/index.rs index db26b30..8a9bea5 100644 --- a/src/index.rs +++ b/src/index.rs @@ -23,6 +23,12 @@ macro_rules! index_type { #[serde(transparent)] pub struct $name(pub usize); + impl Indices<$name> for $name { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut $name)) { + f(self) + } + } + impl Index for $name { const KIND: IndexKind = IndexKind::$kind; fn new(i: usize) -> Self { @@ -67,6 +73,56 @@ index_type!( Template ); +/// Semantics or material values of a MultiPoint, MultiLineString, +/// MultiSurface or CompositeSurface: one index per point, line or surface. +/// `None` is `null`: that primitive has none. +pub type PrimitiveValues = Vec>; +/// Semantics or material values of a Solid: per shell, per surface. A whole +/// shell may be `null`. +pub type SolidValues = Vec>>; +/// Semantics or material values of a MultiSolid or CompositeSolid: per +/// solid, per shell, per surface. A whole solid may be `null`. +pub type MultiSolidValues = Vec>>; + +/// Visit every index of kind `I` held somewhere inside a value, in document +/// order. Implemented for each index type itself and for the containers +/// that nest them, so a value of any depth is walked the same way. +pub(crate) trait Indices { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)); +} + +impl> Indices for Vec { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + for t in self { + t.for_each_index(f); + } + } +} + +impl, const N: usize> Indices for [T; N] { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + for t in self { + t.for_each_index(f); + } + } +} + +impl> Indices for Option { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + if let Some(t) = self { + t.for_each_index(f); + } + } +} + +impl> Indices for indexmap::IndexMap { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + for t in self.values_mut() { + t.for_each_index(f); + } + } +} + /// Renumber `i` in first-encounter order: an index already in `map` keeps /// its mapping, a new one gets the next free position. pub(crate) fn remap(map: &mut std::collections::HashMap, i: I) -> I { diff --git a/src/lib.rs b/src/lib.rs index 5ff47ea..d1ca05a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,9 @@ //! //! Internally the crate is organised into one module per concern: //! - `geometry` — `Geometry`, `GeometryType`, `GeometryTemplates` -//! - `semantics` — `Semantics`, `SemanticsSurface`, `SemanticsValues` -//! - `appearance` — `Appearance`, `MaterialReference`, `MaterialValues`, `TextureReference`, `TextureValues` +//! - `semantics` — `Semantics`, `SemanticsSurface`, `SemanticSurfaceType` +//! - `appearance` — `Appearance`, `MaterialReference`, `TextureReference`, `TexturedRing` +//! - `index` — one newtype per kind of index (`VertexIndex`, `UvIndex`, ...) and the value shapes `PrimitiveValues`, `SolidValues`, `MultiSolidValues` //! - `city_object` — `CityObject` //! - `metadata` — `Metadata`, `PointOfContact`, `Address`, `ReferenceSystem`, `Transform`, `GeographicalExtent` //! - `error` — `CjseqError`, `Result` @@ -36,7 +37,8 @@ pub use cityjson::*; pub use error::{CjseqError, IndexKind}; pub use geometry::*; pub use index::{ - Index, MaterialIndex, SurfaceIndex, TemplateIndex, TextureIndex, UvIndex, VertexIndex, + Index, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, + TemplateIndex, TextureIndex, UvIndex, VertexIndex, }; pub use metadata::*; pub use semantics::*; diff --git a/src/semantics.rs b/src/semantics.rs index f9f8927..ff2c6ce 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -1,4 +1,6 @@ -use crate::index::SurfaceIndex; +use crate::index::{Indices, PrimitiveValues, SurfaceIndex}; +#[cfg(doc)] +use crate::index::{MultiSolidValues, SolidValues}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -166,58 +168,33 @@ pub struct SemanticsSurface { pub other: Map, } -/// One shell's worth of semantic-surface indices, one per surface. `None` — a -/// whole shell with no semantics — is `null` in JSON, which -/// `geomprimitives.schema.json` permits at every level of `semantics.values` -/// (`"type": ["array", "null"]`), exactly as it does for `material.values`. -pub type SemanticsShell = Vec>; -/// One solid's worth of semantic-surface indices, per surface, per shell. -pub type SemanticsSolid = Vec>; - -/// The `values` array of a [`Semantics`] object: one index into `surfaces` per -/// *surface* of the geometry, so it is nested exactly one level less deeply -/// than that geometry's `boundaries`. -/// -/// As with [`crate::MaterialValues`], `null` is permitted at every level, not -/// only at the leaf, and every `None` must serialize back as `null` rather -/// than `[]` (finding #7). -/// -/// The variants are ordered shallowest-first; serde tries them in declaration -/// order, so a value only reaches a deeper variant when the shallower ones -/// cannot describe it. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(untagged)] -pub enum SemanticsValues { - /// `MultiSurface`, `CompositeSurface`: one index per surface. - Surfaces(Vec>), - /// `Solid`: one index per surface, per shell. - Shells(Vec>), - /// `MultiSolid`, `CompositeSolid`: one index per surface, per shell, per - /// solid. - Solids(Vec>), -} - -/// The `semantics` member of a geometry. +/// The `semantics` member of a geometry: the semantic surfaces, and `values` +/// assigning one of them to each primitive. `V` is the shape of `values`, +/// fixed by the geometry type -- [`PrimitiveValues`], [`SolidValues`] or +/// [`MultiSolidValues`] of [`SurfaceIndex`] -- so values nested to the wrong +/// depth for their geometry fail to parse. /// -/// `values` is a *required* member whose value may be `null` -/// (`"type": ["array", "null"]`, and `"required": ["surfaces", "values"]`), so -/// it is an `Option` that is serialized even when `None` — writing it out as -/// `null` rather than dropping the member, which would produce a document the -/// schema rejects. -/// -/// As with [`SemanticsSurface`], the `semantics` object in -/// `geomprimitives.schema.json` names `surfaces` and `values` and declares no -/// `additionalProperties: false` -- unlike its enclosing geometry object, -/// which does -- so any further member is legal and is kept verbatim in -/// `other` rather than dropped. +/// `values` is required but may be `null`, so it is written out even when +/// `None`. The `semantics` object is open: members the schema does not name +/// are kept in `other`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct Semantics { +pub struct Semantics> { pub surfaces: Vec, - pub values: Option, + pub values: Option, #[serde(flatten)] pub other: Map, } +impl> Indices for Semantics { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut SurfaceIndex)) { + for s in &mut self.surfaces { + s.parent.for_each_index(f); + s.children.for_each_index(f); + } + self.values.for_each_index(f); + } +} + #[cfg(test)] mod tests { use super::*; @@ -253,11 +230,12 @@ mod tests { #[test] fn solid_semantics_values_are_one_per_surface_per_shell() { - let s: Semantics = serde_json::from_value(serde_json::json!({ - "surfaces": [{"type": "RoofSurface"}, {"type": "WallSurface"}], - "values": [[0, 1, null]] - })) - .unwrap(); + let s: Semantics> = + serde_json::from_value(serde_json::json!({ + "surfaces": [{"type": "RoofSurface"}, {"type": "WallSurface"}], + "values": [[0, 1, null]] + })) + .unwrap(); assert_eq!( serde_json::to_value(&s).unwrap()["values"], serde_json::json!([[0, 1, null]]) @@ -288,7 +266,6 @@ mod tests { fn semantics_surface_and_semantics_implement_eq() { fn assert_impls_eq() {} assert_impls_eq::(); - assert_impls_eq::(); assert_impls_eq::(); } @@ -332,60 +309,6 @@ mod tests { assert_eq!(serde_json::to_value(&s).unwrap(), input); } - /// The untagged variant order is load-bearing: each shape must land in the - /// variant its depth implies, not merely re-serialize to the same JSON. - #[test] - fn each_shape_lands_in_the_variant_its_depth_implies() { - let parse = - |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() }; - - assert!(matches!( - parse(serde_json::json!([0, 1, null])), - SemanticsValues::Surfaces(_) - )); - assert!(matches!( - parse(serde_json::json!([[0, 1, null]])), - SemanticsValues::Shells(_) - )); - assert!(matches!( - parse(serde_json::json!([[[0, 1, null]]])), - SemanticsValues::Solids(_) - )); - //-- the empty array is ambiguous; it must resolve to the shallowest - //-- variant, and stay `[]` on the way out - let empty = parse(serde_json::json!([])); - assert!(matches!(empty, SemanticsValues::Surfaces(_))); - assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!([])); - - //-- a shape deeper than the ladder is not silently accepted - assert!(serde_json::from_value::(serde_json::json!([[[[0]]]])).is_err()); - } - - /// `geomprimitives.schema.json` types `semantics.values` and every one of - /// its intermediate `items` as `["array", "null"]`, exactly as it does for - /// `material.values` — so a whole null shell is valid CityJSON here too, - /// and each `None` must come back as `null`, never as `[]` (finding #7). - #[test] - fn a_null_semantics_sub_array_round_trips_as_null_at_every_level() { - for input in [ - serde_json::json!([0, null, 2]), - serde_json::json!([[0, 1], null]), - serde_json::json!([null, [0, 1]]), - serde_json::json!([[[0, 1]], null]), - serde_json::json!([[[0, 1], null], null]), - serde_json::json!([null, null]), - serde_json::json!([[null], null]), - ] { - let v: SemanticsValues = serde_json::from_value(input.clone()) - .unwrap_or_else(|e| panic!("{input} is schema-valid CityJSON: {e}")); - assert_eq!( - serde_json::to_value(&v).unwrap(), - input, - "{input} must round-trip with its nulls intact, never as []" - ); - } - } - /// `values` is required by the schema but may be `null`. It must parse, /// and it must be written back out as `null` — dropping the member /// entirely would violate `"required": ["surfaces", "values"]`. @@ -400,48 +323,6 @@ mod tests { assert_eq!(serde_json::to_value(&s).unwrap(), input); } - /// The ladder must still resolve by depth now that the intermediate levels - /// are `Option`, including the ambiguous empty shapes. - #[test] - fn null_sub_arrays_do_not_disturb_the_semantics_ladder() { - let parse = - |v: serde_json::Value| -> SemanticsValues { serde_json::from_value(v).unwrap() }; - - assert!(matches!( - parse(serde_json::json!([null, 1])), - SemanticsValues::Surfaces(_) - )); - assert!(matches!( - parse(serde_json::json!([[0, 1], null])), - SemanticsValues::Shells(_) - )); - assert!(matches!( - parse(serde_json::json!([[[0, 1]], null])), - SemanticsValues::Solids(_) - )); - - for input in [ - serde_json::json!([]), - serde_json::json!([[]]), - serde_json::json!([[[]]]), - ] { - let v = parse(input.clone()); - assert_eq!(serde_json::to_value(&v).unwrap(), input, "{input}"); - } - assert!(matches!( - parse(serde_json::json!([])), - SemanticsValues::Surfaces(_) - )); - assert!(matches!( - parse(serde_json::json!([[]])), - SemanticsValues::Shells(_) - )); - assert!(matches!( - parse(serde_json::json!([[[]]])), - SemanticsValues::Solids(_) - )); - } - /// Extension semantic surfaces (`"+ThermalSurface"`, `"+PatioDoor"`, ...) /// must survive a round trip with their leading `+` intact -- mirrors the /// `CityObjectType` extension-round-trip requirement, but for surfaces. diff --git a/tests/value_depth.rs b/tests/value_depth.rs new file mode 100644 index 0000000..60ef25b --- /dev/null +++ b/tests/value_depth.rs @@ -0,0 +1,124 @@ +//! Semantics, material and texture values are nested exactly one level less +//! deeply than the geometry's boundaries (texture values: exactly as deeply), +//! and the geometry type decides which depth that is. + +use cjseq::{ + Geometry, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, + TexturedRing, +}; +use serde_json::{json, Value}; + +fn parse(v: Value) -> Result { + serde_json::from_value(v) +} + +fn round_trips(v: Value) { + let g = parse(v.clone()).unwrap_or_else(|e| panic!("{v} must parse: {e}")); + assert_eq!(serde_json::to_value(&g).unwrap(), v); +} + +const SURFACES: [[[usize; 3]; 1]; 2] = [[[0, 1, 2]], [[2, 1, 3]]]; + +#[test] +fn semantics_values_must_have_the_depth_the_geometry_type_implies() { + let sem = |values: Value| json!({"surfaces": [{"type": "RoofSurface"}], "values": values}); + //-- MultiSurface: one per surface + assert!(parse(json!({"type": "MultiSurface", "lod": "2", "boundaries": SURFACES, "semantics": sem(json!([0, null]))})).is_ok()); + assert!(parse(json!({"type": "MultiSurface", "lod": "2", "boundaries": SURFACES, "semantics": sem(json!([[0, null]]))})).is_err()); + //-- Solid: per shell, per surface + assert!(parse(json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], "semantics": sem(json!([[0, null]]))})).is_ok()); + assert!(parse(json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], "semantics": sem(json!([0, null]))})).is_err()); + assert!(parse(json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], "semantics": sem(json!([[[0, null]]]))})).is_err()); + //-- MultiSolid: per solid, per shell, per surface + assert!(parse(json!({"type": "MultiSolid", "lod": "2", "boundaries": [[SURFACES]], "semantics": sem(json!([[[0, null]]]))})).is_ok()); + assert!(parse(json!({"type": "MultiSolid", "lod": "2", "boundaries": [[SURFACES]], "semantics": sem(json!([[0, null]]))})).is_err()); +} + +#[test] +fn material_values_must_have_the_depth_the_geometry_type_implies() { + let mat = |values: Value| json!({"m": {"values": values}}); + assert!(parse(json!({"type": "CompositeSurface", "lod": "2", "boundaries": SURFACES, "material": mat(json!([0, 1]))})).is_ok()); + assert!(parse(json!({"type": "CompositeSurface", "lod": "2", "boundaries": SURFACES, "material": mat(json!([[0, 1]]))})).is_err()); + assert!(parse(json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], "material": mat(json!([0, 1]))})).is_err()); + assert!(parse(json!({"type": "CompositeSolid", "lod": "2", "boundaries": [[SURFACES]], "material": mat(json!([[0, 1]]))})).is_err()); +} + +#[test] +fn texture_values_must_have_the_depth_the_geometry_type_implies() { + let tex = |values: Value| json!({"t": {"values": values}}); + let surface = json!([[0, 0, 1, 2]]); + assert!(parse(json!({"type": "MultiSurface", "lod": "2", "boundaries": SURFACES, "texture": tex(json!([surface, surface]))})).is_ok()); + assert!(parse(json!({"type": "MultiSurface", "lod": "2", "boundaries": SURFACES, "texture": tex(json!([[surface, surface]]))})).is_err()); + assert!(parse(json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], "texture": tex(json!([[surface, surface]]))})).is_ok()); + assert!(parse(json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], "texture": tex(json!([surface, surface]))})).is_err()); +} + +#[test] +fn nulls_round_trip_at_every_level_the_schema_allows() { + let sem = |values: Value| json!({"surfaces": [{"type": "RoofSurface"}], "values": values}); + round_trips( + json!({"type": "MultiSolid", "lod": "2", "boundaries": [[SURFACES], [SURFACES]], + "semantics": sem(json!([[[0, null]], null]))}), + ); + round_trips( + json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES, SURFACES], + "semantics": sem(json!([null, [null, 0]]))}), + ); + round_trips( + json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], + "semantics": sem(Value::Null)}), + ); + round_trips( + json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], + "material": {"m": {"values": [[null, 1]]}, "n": {"values": null}, "o": {"value": 2}}}), + ); +} + +#[test] +fn empty_values_are_unambiguous_now() { + let sem = |values: Value| json!({"surfaces": [], "values": values}); + round_trips( + json!({"type": "MultiSurface", "lod": "2", "boundaries": [], "semantics": sem(json!([]))}), + ); + round_trips( + json!({"type": "Solid", "lod": "2", "boundaries": [], "semantics": sem(json!([]))}), + ); + round_trips( + json!({"type": "Solid", "lod": "2", "boundaries": [[]], "semantics": sem(json!([[]]))}), + ); +} + +#[test] +fn each_variant_exposes_its_own_value_types() { + let g = parse( + json!({"type": "Solid", "lod": "2", "boundaries": [SURFACES], + "semantics": {"surfaces": [{"type": "RoofSurface"}], "values": [[0, null]]}, + "material": {"m": {"values": [[1, null]]}}, + "texture": {"t": {"values": [[[[0, 0, 1, 2]], [[null]]]]}}}), + ) + .unwrap(); + let Geometry::Solid { + semantics: Some(sem), + material: Some(mat), + texture: Some(tex), + .. + } = g + else { + panic!() + }; + let v: SolidValues = sem.values.unwrap(); + assert_eq!(v, vec![Some(vec![Some(SurfaceIndex(0)), None])]); + let m: &Option>> = &mat["m"].values; + assert_eq!( + m, + &Some(Some(vec![Some(vec![Some(MaterialIndex(1)), None])])) + ); + assert_eq!( + tex["t"].values.as_ref().unwrap()[0][1][0], + TexturedRing::Untextured + ); + + //-- and the shallower and deeper aliases exist for the other variants + let _: PrimitiveValues = vec![]; + let _: MultiSolidValues = vec![]; +} From 091365633cf4eb805c6adafad982e68dc0a6f808 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:28:05 +0200 Subject: [PATCH 44/65] feat: validate() checks every reference serde cannot CityJSON::errors()/validate() and CityJSONFeature::errors()/validate() check: - every vertex index against vertices (and, in a geometry template, against vertices-templates); GeometryInstance templates against the templates; - semantic surface values, parents and children against the surfaces; - material, texture and texture-vertex indices against the appearance palettes (no appearance means empty palettes); - that each material theme has exactly one of value/values; - that semantics, material and texture values have one entry per primitive, shell or solid, and a textured ring one UV per vertex; - that every child and parent exists and names the other back; - for a feature, that its id is one of its City Objects and that it carries none of the document-only root members. Errors are structured: IndexOutOfRange, DanglingReference, HierarchyMismatch, ValueCount, MisplacedMember, wrapped in InCityObject/InTemplate to say where. errors() returns them all, validate() the first. Every fixture validates, as does everything collect and cat make from it, except two fixture problems the validator found, recorded in the test with the exact error each must produce: a material index with no materials palette in empty_appearance.city.jsonl, and a header in geom_temp.city.jsonl written by a cat with the bug fixed here. fix: get_metadata wrote the geometry templates unrenumbered after slicing the appearance down to what they use, so template material and texture indices pointed at the wrong entries or past the end. The header now carries the renumbered templates, and get_metadata returns a Result instead of silently dropping the appearance when slicing fails. --- src/appearance.rs | 17 ++ src/cityjson.rs | 13 +- src/error.rs | 45 ++++ src/geometry.rs | 14 ++ src/index.rs | 24 +++ src/lib.rs | 1 + src/main.rs | 2 +- src/semantics.rs | 7 + src/validate.rs | 380 +++++++++++++++++++++++++++++++++ tests/cj_input.rs | 2 +- tests/texture_merge.rs | 4 +- tests/validate.rs | 470 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 972 insertions(+), 7 deletions(-) create mode 100644 src/validate.rs create mode 100644 tests/validate.rs diff --git a/src/appearance.rs b/src/appearance.rs index 0c293c3..6165acf 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -63,6 +63,11 @@ impl Indices for TexturedRing { f(texture) } } + fn visit_index(&self, f: &mut dyn FnMut(TextureIndex)) { + if let TexturedRing::Textured { texture, .. } = self { + f(*texture) + } + } } impl Indices for TexturedRing { @@ -71,6 +76,11 @@ impl Indices for TexturedRing { uvs.iter_mut().for_each(f) } } + fn visit_index(&self, f: &mut dyn FnMut(UvIndex)) { + if let TexturedRing::Textured { uvs, .. } = self { + uvs.iter().for_each(|uv| f(*uv)) + } + } } /// Keep an explicit `null` distinct from an absent member. @@ -127,6 +137,10 @@ impl> Indices for MaterialReference self.value.for_each_index(f); self.values.for_each_index(f); } + fn visit_index(&self, f: &mut dyn FnMut(MaterialIndex)) { + self.value.visit_index(f); + self.values.visit_index(f); + } } /// One theme's texture assignment for a geometry. @@ -149,6 +163,9 @@ impl> Indices for TextureReference { fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { self.values.for_each_index(f); } + fn visit_index(&self, f: &mut dyn FnMut(I)) { + self.values.visit_index(f); + } } /// A material or border colour: three numbers, per diff --git a/src/cityjson.rs b/src/cityjson.rs index df108b3..3d95243 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -149,8 +149,11 @@ impl CityJSON { transform_correction: None, } } - /// Get the "first line" (aka metadata or header) of a CityJSONSeq - pub fn get_metadata(&self) -> Self { + /// Get the "first line" (aka metadata or header) of a CityJSONSeq: the + /// document without its City Objects and vertices, its appearance cut + /// down to what the geometry templates use. An appearance index in a + /// template that is out of range is an error. + pub fn get_metadata(&self) -> Result { let mut cj0 = CityJSON { thetype: self.thetype.clone(), version: self.version, @@ -177,10 +180,12 @@ impl CityJSON { g.update_texture(&mut t_oldnew, &mut t_v_oldnew); } if let Some(a) = &self.appearance { - cj0.appearance = slice_appearance(a, &m_oldnew, &t_oldnew, &t_v_oldnew).ok(); + cj0.appearance = Some(slice_appearance(a, &m_oldnew, &t_oldnew, &t_v_oldnew)?); } + //-- the templates now index the sliced appearance + cj0.geometry_templates = Some(gts2); } - cj0 + Ok(cj0) } /// The `i`th feature, in the order set by [`CityJSON::sort_cjfeatures`]: diff --git a/src/error.rs b/src/error.rs index 7629d27..1d55ce8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -36,6 +36,28 @@ pub enum CjseqError { /// The id it refers to. to: String, }, + /// A parent and a child disagree: one names the other but is not named + /// back (`children` vs `parents`). + HierarchyMismatch { parent: String, child: String }, + /// An array of values has a different number of entries than the part + /// of the geometry it describes. + ValueCount { + /// What the values are, eg `"semantics values"`. + what: &'static str, + /// The number the boundaries call for. + expected: usize, + found: usize, + }, + /// A CityJSONFeature carries a root member only a CityJSON document may + /// carry, eg `transform`. + MisplacedMember { member: String }, + /// An error inside one City Object. + InCityObject { id: String, source: Box }, + /// An error inside one geometry template. + InTemplate { + index: usize, + source: Box, + }, /// An index points past the end of the array it indexes. IndexOutOfRange { kind: IndexKind, @@ -74,6 +96,23 @@ impl fmt::Display for CjseqError { f, "City Object {from:?} refers to {to:?}, which is not in the document" ), + CjseqError::HierarchyMismatch { parent, child } => write!( + f, + "City Objects {parent:?} and {child:?} disagree on whether one is the other's parent" + ), + CjseqError::ValueCount { + what, + expected, + found, + } => write!(f, "{found} {what} where the boundaries call for {expected}"), + CjseqError::MisplacedMember { member } => write!( + f, + "a CityJSONFeature cannot carry {member:?}; only the CityJSON document can" + ), + CjseqError::InCityObject { id, source } => write!(f, "in City Object {id:?}: {source}"), + CjseqError::InTemplate { index, source } => { + write!(f, "in geometry template {index}: {source}") + } CjseqError::IndexOutOfRange { kind, index, len } => { write!(f, "{kind:?} index {index} is out of range (there are {len})") } @@ -87,8 +126,14 @@ impl std::error::Error for CjseqError { CjseqError::Json(e) => Some(e), CjseqError::GeometryDepth { .. } => None, CjseqError::Io(e) => Some(e), + CjseqError::InCityObject { source, .. } | CjseqError::InTemplate { source, .. } => { + Some(source.as_ref()) + } CjseqError::Validation(_) | CjseqError::DanglingReference { .. } + | CjseqError::HierarchyMismatch { .. } + | CjseqError::ValueCount { .. } + | CjseqError::MisplacedMember { .. } | CjseqError::IndexOutOfRange { .. } => None, } } diff --git a/src/geometry.rs b/src/geometry.rs index 3deb94e..4198ae6 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -314,6 +314,20 @@ impl Geometry { } } + /// As [`Geometry::for_each_vertex`], read-only. + pub(crate) fn visit_vertices(&self, f: &mut dyn FnMut(VertexIndex)) { + match self { + Geometry::MultiPoint { boundaries, .. } => boundaries.visit_index(f), + Geometry::MultiLineString { boundaries, .. } => boundaries.visit_index(f), + Geometry::MultiSurface { boundaries, .. } + | Geometry::CompositeSurface { boundaries, .. } => boundaries.visit_index(f), + Geometry::Solid { boundaries, .. } => boundaries.visit_index(f), + Geometry::MultiSolid { boundaries, .. } + | Geometry::CompositeSolid { boundaries, .. } => boundaries.visit_index(f), + Geometry::GeometryInstance { boundaries, .. } => boundaries.visit_index(f), + } + } + /// Visit every material index (`value` and `values`, every theme). pub(crate) fn for_each_material(&mut self, f: &mut dyn FnMut(&mut MaterialIndex)) { match self { diff --git a/src/index.rs b/src/index.rs index 8a9bea5..771abac 100644 --- a/src/index.rs +++ b/src/index.rs @@ -27,6 +27,9 @@ macro_rules! index_type { fn for_each_index(&mut self, f: &mut dyn FnMut(&mut $name)) { f(self) } + fn visit_index(&self, f: &mut dyn FnMut($name)) { + f(*self) + } } impl Index for $name { @@ -89,6 +92,7 @@ pub type MultiSolidValues = Vec>>; /// that nest them, so a value of any depth is walked the same way. pub(crate) trait Indices { fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)); + fn visit_index(&self, f: &mut dyn FnMut(I)); } impl> Indices for Vec { @@ -97,6 +101,11 @@ impl> Indices for Vec { t.for_each_index(f); } } + fn visit_index(&self, f: &mut dyn FnMut(I)) { + for t in self { + t.visit_index(f); + } + } } impl, const N: usize> Indices for [T; N] { @@ -105,6 +114,11 @@ impl, const N: usize> Indices for [T; N] { t.for_each_index(f); } } + fn visit_index(&self, f: &mut dyn FnMut(I)) { + for t in self { + t.visit_index(f); + } + } } impl> Indices for Option { @@ -113,6 +127,11 @@ impl> Indices for Option { t.for_each_index(f); } } + fn visit_index(&self, f: &mut dyn FnMut(I)) { + if let Some(t) = self { + t.visit_index(f); + } + } } impl> Indices for indexmap::IndexMap { @@ -121,6 +140,11 @@ impl> Indices for indexmap::IndexMap { t.for_each_index(f); } } + fn visit_index(&self, f: &mut dyn FnMut(I)) { + for t in self.values() { + t.visit_index(f); + } + } } /// Renumber `i` in first-encounter order: an index already in `map` keeps diff --git a/src/lib.rs b/src/lib.rs index d1ca05a..852dcbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ mod geometry; mod index; mod metadata; mod semantics; +mod validate; pub use appearance::*; pub use city_object::*; diff --git a/src/main.rs b/src/main.rs index 747f0f8..9656e96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -361,7 +361,7 @@ fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { } cjj.sort_cjfeatures(order); //-- first line: the CityJSON "metadata" - let cj1 = cjj.get_metadata(); + let cj1 = cjj.get_metadata()?; io::stdout().write_all(format!("{}\n", serde_json::to_string(&cj1).unwrap()).as_bytes())?; //-- the other lines for each CityJSONSeq let mut i: usize = 0; diff --git a/src/semantics.rs b/src/semantics.rs index ff2c6ce..b831de7 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -193,6 +193,13 @@ impl> Indices for Semantics { } self.values.for_each_index(f); } + fn visit_index(&self, f: &mut dyn FnMut(SurfaceIndex)) { + for s in &self.surfaces { + s.parent.visit_index(f); + s.children.visit_index(f); + } + self.values.visit_index(f); + } } #[cfg(test)] diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..0a736f8 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,380 @@ +//! The checks serde cannot express: every index is in range, every value +//! array has as many entries as the geometry it describes, and the City +//! Object hierarchy agrees with itself. + +use crate::appearance::{Appearance, MaterialReference, TextureReference, TexturedRing}; +use crate::city_object::CityObject; +use crate::cityjson::{CityJSON, CityJSONFeature}; +use crate::error::{CjseqError, Result}; +use crate::geometry::{Geometry, Shell, Surface, Themes}; +use crate::index::{ + Index, Indices, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, + TextureIndex, UvIndex, +}; +use crate::semantics::Semantics; +use indexmap::IndexMap; + +/// The lengths that the indices of one geometry are checked against. +struct Lengths { + vertices: usize, + /// `None` where templates are not known, as in a lone CityJSONFeature. + templates: Option, + materials: usize, + textures: usize, + uvs: usize, +} + +impl Lengths { + fn new(vertices: usize, templates: Option, appearance: Option<&Appearance>) -> Self { + let len = |v: Option| v.unwrap_or(0); + Lengths { + vertices, + templates, + materials: len(appearance.and_then(|a| a.materials.as_ref().map(Vec::len))), + textures: len(appearance.and_then(|a| a.textures.as_ref().map(Vec::len))), + uvs: len(appearance.and_then(|a| a.vertices_texture.as_ref().map(Vec::len))), + } + } +} + +fn in_range(i: I, len: usize, errs: &mut Vec) { + if i.get() >= len { + errs.push(CjseqError::IndexOutOfRange { + kind: I::KIND, + index: i.get(), + len, + }); + } +} + +fn count(what: &'static str, expected: usize, found: usize, errs: &mut Vec) { + if expected != found { + errs.push(CjseqError::ValueCount { + what, + expected, + found, + }); + } +} + +/// How a value array lines up with the boundaries it describes. +trait Shape { + fn check_shape(&self, what: &'static str, boundaries: &B, errs: &mut Vec); +} + +//-- one impl per index type rather than generic over it: generic impls for +//-- `Vec>` and `Vec>>>` would overlap +macro_rules! value_shapes { + ($($i:ty),*) => {$( + impl Shape<[B]> for PrimitiveValues<$i> { + fn check_shape(&self, what: &'static str, b: &[B], errs: &mut Vec) { + count(what, b.len(), self.len(), errs); + } + } + + impl Shape<[Shell]> for SolidValues<$i> { + fn check_shape(&self, what: &'static str, b: &[Shell], errs: &mut Vec) { + count(what, b.len(), self.len(), errs); + for (v, shell) in self.iter().zip(b) { + if let Some(v) = v { + count(what, shell.len(), v.len(), errs); + } + } + } + } + + impl Shape<[Vec]> for MultiSolidValues<$i> { + fn check_shape(&self, what: &'static str, b: &[Vec], errs: &mut Vec) { + count(what, b.len(), self.len(), errs); + for (v, solid) in self.iter().zip(b) { + if let Some(v) = v { + v.check_shape(what, &solid[..], errs); + } + } + } + } + )*}; +} +value_shapes!(SurfaceIndex, MaterialIndex); + +const TEXTURE: &str = "texture values"; + +impl Shape<[Surface]> for Vec> { + fn check_shape(&self, what: &'static str, b: &[Surface], errs: &mut Vec) { + count(what, b.len(), self.len(), errs); + for (rings, surface) in self.iter().zip(b) { + count(what, surface.len(), rings.len(), errs); + for (ring, vertices) in rings.iter().zip(surface) { + if let TexturedRing::Textured { uvs, .. } = ring { + count("texture vertices", vertices.len(), uvs.len(), errs); + } + } + } + } +} + +impl Shape<[Shell]> for Vec>> { + fn check_shape(&self, what: &'static str, b: &[Shell], errs: &mut Vec) { + count(what, b.len(), self.len(), errs); + for (t, shell) in self.iter().zip(b) { + t.check_shape(what, &shell[..], errs); + } + } +} + +impl Shape<[Vec]> for Vec>>> { + fn check_shape(&self, what: &'static str, b: &[Vec], errs: &mut Vec) { + count(what, b.len(), self.len(), errs); + for (t, solid) in self.iter().zip(b) { + t.check_shape(what, &solid[..], errs); + } + } +} + +fn semantics(s: &Option>, b: &B, errs: &mut Vec) +where + V: Indices + Shape, +{ + let Some(s) = s else { return }; + let n = s.surfaces.len(); + s.visit_index(&mut |i| in_range(i, n, errs)); + if let Some(v) = &s.values { + v.check_shape("semantics values", b, errs); + } +} + +fn material( + m: &Option>>, + b: &B, + l: &Lengths, + errs: &mut Vec, +) where + V: Indices + Shape, +{ + let Some(m) = m else { return }; + for (theme, r) in m { + if r.value.is_some() == r.values.is_some() { + errs.push(CjseqError::Validation(format!( + "material theme {theme:?} must have exactly one of \"value\" and \"values\"" + ))); + } + r.visit_index(&mut |i| in_range(i, l.materials, errs)); + if let Some(Some(v)) = &r.values { + v.check_shape("material values", b, errs); + } + } +} + +fn texture( + t: &Option>>, + b: &B, + l: &Lengths, + errs: &mut Vec, +) where + V: Indices + Indices + Shape, +{ + let Some(t) = t else { return }; + for r in t.values() { + Indices::::visit_index(r, &mut |i| in_range(i, l.textures, errs)); + Indices::::visit_index(r, &mut |i| in_range(i, l.uvs, errs)); + if let Some(v) = &r.values { + v.check_shape(TEXTURE, b, errs); + } + } +} + +fn geometry(g: &Geometry, l: &Lengths, errs: &mut Vec) { + g.visit_vertices(&mut |v| in_range(v, l.vertices, errs)); + match g { + Geometry::MultiPoint { + boundaries, + semantics: s, + .. + } => semantics(s, &boundaries[..], errs), + Geometry::MultiLineString { + boundaries, + semantics: s, + .. + } => semantics(s, &boundaries[..], errs), + Geometry::MultiSurface { + boundaries, + semantics: s, + material: m, + texture: t, + .. + } + | Geometry::CompositeSurface { + boundaries, + semantics: s, + material: m, + texture: t, + .. + } => { + semantics(s, &boundaries[..], errs); + material(m, &boundaries[..], l, errs); + texture(t, &boundaries[..], l, errs); + } + Geometry::Solid { + boundaries, + semantics: s, + material: m, + texture: t, + .. + } => { + semantics(s, &boundaries[..], errs); + material(m, &boundaries[..], l, errs); + texture(t, &boundaries[..], l, errs); + } + Geometry::MultiSolid { + boundaries, + semantics: s, + material: m, + texture: t, + .. + } + | Geometry::CompositeSolid { + boundaries, + semantics: s, + material: m, + texture: t, + .. + } => { + semantics(s, &boundaries[..], errs); + material(m, &boundaries[..], l, errs); + texture(t, &boundaries[..], l, errs); + } + Geometry::GeometryInstance { template, .. } => { + if let Some(n) = l.templates { + in_range(*template, n, errs); + } + } + } +} + +/// Every child and parent named exists, and names the other back. +fn hierarchy(cos: &IndexMap, errs: &mut Vec) { + let names = |list: &Option>, id: &str| { + list.as_ref().is_some_and(|l| l.iter().any(|x| x == id)) + }; + for (id, co) in cos { + for child in co.children.iter().flatten() { + match cos.get(child) { + None => errs.push(CjseqError::DanglingReference { + from: id.clone(), + to: child.clone(), + }), + Some(c) if !names(&c.parents, id) => errs.push(CjseqError::HierarchyMismatch { + parent: id.clone(), + child: child.clone(), + }), + _ => {} + } + } + for parent in co.parents.iter().flatten() { + match cos.get(parent) { + None => errs.push(CjseqError::DanglingReference { + from: id.clone(), + to: parent.clone(), + }), + Some(p) if !names(&p.children, id) => errs.push(CjseqError::HierarchyMismatch { + parent: parent.clone(), + child: id.clone(), + }), + _ => {} + } + } + } +} + +fn city_objects(cos: &IndexMap, l: &Lengths, errs: &mut Vec) { + hierarchy(cos, errs); + for (id, co) in cos { + let mut inner = Vec::new(); + for g in co.geometry.iter().flatten() { + geometry(g, l, &mut inner); + } + errs.extend(inner.into_iter().map(|e| CjseqError::InCityObject { + id: id.clone(), + source: Box::new(e), + })); + } +} + +fn first(errs: Vec) -> Result<()> { + match errs.into_iter().next() { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// The root members a CityJSONFeature must not carry: they belong to the +/// document (`cityjsonfeature.schema.json`'s `not`). +const DOCUMENT_ONLY: [&str; 5] = [ + "transform", + "version", + "metadata", + "geometry-templates", + "extensions", +]; + +impl CityJSON { + /// Every reference the document makes that serde cannot check, with the + /// City Object or template each problem is in. Empty if the document is + /// valid. + pub fn errors(&self) -> Vec { + let mut errs = Vec::new(); + let app = self.appearance.as_ref(); + if let Some(gt) = &self.geometry_templates { + let l = Lengths::new(gt.vertices_templates.len(), None, app); + for (index, g) in gt.templates.iter().enumerate() { + let mut inner = Vec::new(); + geometry(g, &l, &mut inner); + errs.extend(inner.into_iter().map(|e| CjseqError::InTemplate { + index, + source: Box::new(e), + })); + } + } + let templates = self + .geometry_templates + .as_ref() + .map_or(0, |t| t.templates.len()); + let l = Lengths::new(self.vertices.len(), Some(templates), app); + city_objects(&self.city_objects, &l, &mut errs); + errs + } + + /// The first of [`CityJSON::errors`], if any. + pub fn validate(&self) -> Result<()> { + first(self.errors()) + } +} + +impl CityJSONFeature { + /// As [`CityJSON::errors`], for what a feature can check on its own: a + /// GeometryInstance's template lives in the document's header, so it is + /// not checked here. + pub fn errors(&self) -> Vec { + let mut errs: Vec = DOCUMENT_ONLY + .iter() + .filter(|m| self.other.contains_key(**m)) + .map(|m| CjseqError::MisplacedMember { + member: m.to_string(), + }) + .collect(); + if !self.city_objects.contains_key(&self.id) { + errs.push(CjseqError::DanglingReference { + from: "CityJSONFeature.id".to_string(), + to: self.id.clone(), + }); + } + let l = Lengths::new(self.vertices.len(), None, self.appearance.as_ref()); + city_objects(&self.city_objects, &l, &mut errs); + errs + } + + /// The first of [`CityJSONFeature::errors`], if any. + pub fn validate(&self) -> Result<()> { + first(self.errors()) + } +} diff --git a/tests/cj_input.rs b/tests/cj_input.rs index 9c529f2..1bab61d 100755 --- a/tests/cj_input.rs +++ b/tests/cj_input.rs @@ -58,7 +58,7 @@ fn basic_reading_of_cj() { let mut cjj: CityJSON = CityJSON::from_str(&json_content).unwrap(); assert!(cjj.number_of_city_objects() == 2); - let cj1 = cjj.get_metadata(); + let cj1 = cjj.get_metadata().unwrap(); assert!(cj1.number_of_city_objects() == 0); assert!(cj1.vertices.is_empty()); diff --git a/tests/texture_merge.rs b/tests/texture_merge.rs index 5519091..0dcd69b 100644 --- a/tests/texture_merge.rs +++ b/tests/texture_merge.rs @@ -76,6 +76,7 @@ fn collecting_several_textured_features_keeps_every_uv() { let c = feature("c", &a_uvs, &[[2, 1, 0]]); let cj = cjseq_to_cj(header(), vec![a, b, c]); + cj.validate().unwrap(); assert_eq!( resolved_uvs(&cj, "a"), @@ -129,7 +130,8 @@ fn a_cat_then_collect_round_trip_keeps_every_uv() { while let Some(f) = doc.get_cjfeature(features.len()).unwrap() { features.push(f); } - let back = cjseq_to_cj(doc.get_metadata(), features); + let back = cjseq_to_cj(doc.get_metadata().unwrap(), features); + back.validate().unwrap(); assert_eq!(resolved_uvs(&back, "a"), expected_a); assert_eq!(resolved_uvs(&back, "b"), expected_b); diff --git a/tests/validate.rs b/tests/validate.rs new file mode 100644 index 0000000..93d7749 --- /dev/null +++ b/tests/validate.rs @@ -0,0 +1,470 @@ +//! `validate()` checks every reference a document makes: what serde alone +//! cannot express. + +use cjseq::{CityJSON, CityJSONFeature, CjseqError, IndexKind}; +use serde_json::{json, Value}; +use std::str::FromStr; + +fn doc(city_objects: Value, vertices: Value, extra: Value) -> CityJSON { + let mut d = json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": city_objects, "vertices": vertices + }); + d.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + CityJSON::from_str(&d.to_string()).unwrap() +} + +const V4: [[i64; 3]; 4] = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]; + +fn building(geometry: Value) -> Value { + json!({"b": {"type": "Building", "geometry": [geometry]}}) +} + +/// The error inside the `InCityObject` wrapper, checking the id. +fn inner(e: CjseqError, id: &str) -> CjseqError { + match e { + CjseqError::InCityObject { id: got, source } => { + assert_eq!(got, id); + *source + } + other => panic!("expected an error in {id}, got {other:?}"), + } +} + +fn first_error(cj: &CityJSON) -> CjseqError { + cj.validate().expect_err("must not validate") +} + +#[test] +fn a_well_formed_document_validates() { + let cj = doc( + json!({ + "b": {"type": "Building", "children": ["p"], "geometry": [{ + "type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]], [[0, 2, 3]]], + "semantics": {"surfaces": [{"type": "RoofSurface", "children": [1]}, {"type": "Window", "parent": 0}], "values": [0, null]}, + "material": {"m": {"values": [0, null]}, "n": {"value": 0}}, + "texture": {"t": {"values": [[[0, 0, 1, 2]], [[null]]]}} + }]}, + "p": {"type": "BuildingPart", "parents": ["b"]} + }), + json!(V4), + json!({"appearance": { + "materials": [{"name": "m"}], + "textures": [{"type": "PNG", "image": "a.png"}], + "vertices-texture": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]] + }}), + ); + cj.validate().unwrap(); + assert!(cj.errors().is_empty()); +} + +#[test] +fn a_vertex_index_past_the_vertices_is_reported_with_its_object() { + let cj = doc( + building(json!({"type": "MultiPoint", "lod": "1", "boundaries": [0, 9]})), + json!(V4), + json!({}), + ); + assert!(matches!( + inner(first_error(&cj), "b"), + CjseqError::IndexOutOfRange { + kind: IndexKind::Vertex, + index: 9, + len: 4 + } + )); +} + +#[test] +fn appearance_indices_are_checked_against_the_palettes() { + let tex = |ring: Value| { + building( + json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], + "texture": {"t": {"values": [[ring]]}}}), + ) + }; + let app = json!({"appearance": {"textures": [{"type": "PNG", "image": "a.png"}], "vertices-texture": [[0.0, 0.0]]}}); + let e = inner( + first_error(&doc(tex(json!([1, 0, 0, 0])), json!(V4), app.clone())), + "b", + ); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::Texture, + index: 1, + len: 1 + } + ), + "{e:?}" + ); + let e = inner( + first_error(&doc(tex(json!([0, 0, 5, 0])), json!(V4), app)), + "b", + ); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::TextureVertex, + index: 5, + len: 1 + } + ), + "{e:?}" + ); + //-- a material reference with no appearance at all is out of range of an empty palette + let e = inner( + first_error(&doc( + building( + json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], "material": {"m": {"value": 0}}}), + ), + json!(V4), + json!({}), + )), + "b", + ); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::Material, + index: 0, + len: 0 + } + ), + "{e:?}" + ); +} + +#[test] +fn semantic_indices_are_checked_against_the_surfaces() { + let sem = |s: Value| { + building( + json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], "semantics": s}), + ) + }; + for s in [ + json!({"surfaces": [{"type": "RoofSurface"}], "values": [1]}), + json!({"surfaces": [{"type": "RoofSurface", "parent": 3}], "values": [0]}), + json!({"surfaces": [{"type": "RoofSurface", "children": [2]}], "values": [0]}), + ] { + let e = inner(first_error(&doc(sem(s.clone()), json!(V4), json!({}))), "b"); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::Surface, + .. + } + ), + "{s}: {e:?}" + ); + } +} + +#[test] +fn value_counts_must_match_the_boundaries() { + let g = |extra: Value| { + let mut g = + json!({"type": "Solid", "lod": "1", "boundaries": [[[[0, 1, 2]], [[0, 2, 3]]]]}); + g.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + building(g) + }; + let app = json!({"appearance": {"materials": [{"name": "m"}], "textures": [{"image": "a.png"}], + "vertices-texture": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]}}); + for extra in [ + //-- one value for two surfaces + json!({"semantics": {"surfaces": [{"type": "RoofSurface"}], "values": [[0]]}}), + //-- two shells of values for one shell + json!({"material": {"m": {"values": [[0, 0], [0, 0]]}}}), + //-- a textured ring with two UVs for three vertices + json!({"texture": {"t": {"values": [[[[0, 0, 1]], [[null]]]]}}}), + //-- one surface of texture values for two + json!({"texture": {"t": {"values": [[[[0, 0, 1, 2]]]]}}}), + ] { + let e = inner( + first_error(&doc(g(extra.clone()), json!(V4), app.clone())), + "b", + ); + assert!(matches!(e, CjseqError::ValueCount { .. }), "{extra}: {e:?}"); + } + //-- a null shell or surface is not a count mismatch + doc( + g( + json!({"semantics": {"surfaces": [{"type": "RoofSurface"}], "values": [null]}, + "material": {"m": {"values": [[0, null]]}}}), + ), + json!(V4), + app, + ) + .validate() + .unwrap(); +} + +#[test] +fn a_material_theme_has_exactly_one_of_value_and_values() { + for m in [json!({}), json!({"value": 0, "values": [0]})] { + let cj = doc( + building( + json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], "material": {"m": m}}), + ), + json!(V4), + json!({"appearance": {"materials": [{"name": "m"}]}}), + ); + let e = inner(first_error(&cj), "b"); + assert!(matches!(e, CjseqError::Validation(_)), "{e:?}"); + } +} + +#[test] +fn the_hierarchy_must_be_consistent() { + let e = first_error(&doc( + json!({"b": {"type": "Building", "children": ["x"]}}), + json!([]), + json!({}), + )); + assert!( + matches!(e, CjseqError::DanglingReference { ref from, ref to } if from == "b" && to == "x"), + "{e:?}" + ); + let e = first_error(&doc( + json!({"p": {"type": "BuildingPart", "parents": ["x"]}}), + json!([]), + json!({}), + )); + assert!(matches!(e, CjseqError::DanglingReference { .. }), "{e:?}"); + //-- b lists p as a child, but p does not name b as a parent + let e = first_error(&doc( + json!({"b": {"type": "Building", "children": ["p"]}, "p": {"type": "BuildingPart"}}), + json!([]), + json!({}), + )); + assert!( + matches!(e, CjseqError::HierarchyMismatch { ref parent, ref child } if parent == "b" && child == "p"), + "{e:?}" + ); +} + +#[test] +fn geometry_instances_and_templates_are_checked() { + let tm = + json!([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]); + let templates = json!({"geometry-templates": { + "templates": [{"type": "MultiPoint", "lod": "1", "boundaries": [0, 1]}], + "vertices-templates": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]] + }}); + let gi = |t: usize, v: usize| { + building( + json!({"type": "GeometryInstance", "template": t, "boundaries": [v], "transformationMatrix": tm}), + ) + }; + doc(gi(0, 3), json!(V4), templates.clone()) + .validate() + .unwrap(); + let e = inner( + first_error(&doc(gi(1, 0), json!(V4), templates.clone())), + "b", + ); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::Template, + index: 1, + len: 1 + } + ), + "{e:?}" + ); + let e = inner(first_error(&doc(gi(0, 4), json!(V4), templates)), "b"); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::Vertex, + index: 4, + len: 4 + } + ), + "{e:?}" + ); + //-- a template's own boundaries index vertices-templates + let bad = json!({"geometry-templates": { + "templates": [{"type": "MultiPoint", "lod": "1", "boundaries": [0, 2]}], + "vertices-templates": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]] + }}); + let e = first_error(&doc(json!({}), json!([]), bad)); + assert!( + matches!(e, CjseqError::InTemplate { index: 0, .. }), + "{e:?}" + ); +} + +#[test] +fn every_error_is_reported_not_only_the_first() { + let cj = doc( + json!({ + "a": {"type": "Building", "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [7]}]}, + "b": {"type": "Building", "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [8]}]} + }), + json!([]), + json!({}), + ); + assert_eq!(cj.errors().len(), 2); +} + +#[test] +fn a_feature_is_checked_on_its_own() { + let f = |v: Value| CityJSONFeature::from_str(&v.to_string()).unwrap(); + f(json!({"type": "CityJSONFeature", "id": "b", "CityObjects": {"b": {"type": "Building"}}, "vertices": []})) + .validate() + .unwrap(); + //-- the id must name one of its City Objects + let e = f(json!({"type": "CityJSONFeature", "id": "x", "CityObjects": {"b": {"type": "Building"}}, "vertices": []})) + .validate() + .unwrap_err(); + assert!(matches!(e, CjseqError::DanglingReference { .. }), "{e:?}"); + //-- vertex indices against its own vertices + let e = f( + json!({"type": "CityJSONFeature", "id": "b", "CityObjects": {"b": {"type": "Building", + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [0]}]}}, "vertices": []}), + ) + .validate() + .unwrap_err(); + assert!(matches!( + inner(e, "b"), + CjseqError::IndexOutOfRange { + kind: IndexKind::Vertex, + .. + } + )); + //-- and the root members only the document may carry + for m in [ + "transform", + "version", + "metadata", + "geometry-templates", + "extensions", + ] { + let mut v = json!({"type": "CityJSONFeature", "id": "b", "CityObjects": {"b": {"type": "Building"}}, "vertices": []}); + v[m] = json!({}); + let e = f(v).validate().unwrap_err(); + assert!( + matches!(e, CjseqError::MisplacedMember { ref member } if member == m), + "{m}: {e:?}" + ); + } +} + +/// Every fixture is valid, and so is everything `collect` and `cat` make +/// from it. +#[test] +fn every_fixture_and_everything_made_from_it_validates() { + let mut seqs: Vec = std::fs::read_dir("tests/data") + .unwrap() + .chain(std::fs::read_dir("data").unwrap()) + .map(|e| e.unwrap().path()) + .collect(); + seqs.sort(); + let mut checked = 0; + for path in seqs { + let name = path.display().to_string(); + let text = std::fs::read_to_string(&path).unwrap(); + let docs: Vec = if name.ends_with(".jsonl") { + let mut lines = text.lines(); + let header = CityJSON::from_str(lines.next().unwrap()).unwrap(); + if name.ends_with("geom_temp.city.jsonl") { + //-- this header was written by a `cat` that sliced the + //-- appearance to the templates' materials but left the + //-- templates' indices unrenumbered (fixed since): template + //-- 1 points past the two materials left. It is reported, + //-- and nothing built on it can validate. + let e = header.validate().unwrap_err(); + assert!( + e.to_string() + .contains("Material index 2 is out of range (there are 2)"), + "{e}" + ); + for l in lines { + let f = CityJSONFeature::from_str(l).unwrap(); + f.validate() + .unwrap_or_else(|e| panic!("{name} feature {}: {e}", f.id)); + } + continue; + } + header + .validate() + .unwrap_or_else(|e| panic!("{name} header: {e}")); + let features: Vec = lines + .map(|l| CityJSONFeature::from_str(l).unwrap()) + .collect(); + let (bad, features): (Vec<_>, Vec<_>) = features.into_iter().partition(|f| { + name.ends_with("empty_appearance.city.jsonl") && f.id == "null_indices" + }); + //-- this fixture exercises null material indices, and its one + //-- non-null index points into a materials palette nobody + //-- defined: it really is invalid, and must be reported so + for f in &bad { + let e = f.validate().unwrap_err(); + assert!( + e.to_string() + .contains("Material index 0 is out of range (there are 0)"), + "{e}" + ); + } + for f in &features { + f.validate() + .unwrap_or_else(|e| panic!("{name} feature {}: {e}", f.id)); + } + vec![cjseq::cjseq_to_cj(header, features)] + } else if name.ends_with(".json") { + vec![CityJSON::from_str(&text).unwrap()] + } else { + continue; + }; + for cj in docs { + cj.validate().unwrap_or_else(|e| panic!("{name}: {e}")); + let mut i = 0; + while let Some(f) = cj.get_cjfeature(i).unwrap() { + f.validate() + .unwrap_or_else(|e| panic!("{name} sliced feature {}: {e}", f.id)); + i += 1; + } + checked += 1; + } + } + assert!(checked >= 10, "only {checked} fixtures found"); +} + +/// The header `cat` writes slices the appearance down to what the geometry +/// templates use, so the templates it writes must be renumbered to match. +#[test] +fn the_header_renumbers_its_templates_to_the_sliced_appearance() { + let cj = doc( + json!({}), + json!([]), + json!({ + "appearance": {"materials": [{"name": "a"}, {"name": "b"}, {"name": "c"}]}, + "geometry-templates": { + "templates": [{"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], + "material": {"visual": {"value": 2}}}], + "vertices-templates": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]] + } + }), + ); + let header = cj.get_metadata().unwrap(); + header.validate().unwrap(); + let v = serde_json::to_value(&header).unwrap(); + assert_eq!(v["appearance"]["materials"], json!([{"name": "c"}])); + assert_eq!( + v["geometry-templates"]["templates"][0]["material"]["visual"]["value"], + json!(0) + ); +} From 97edfe2d0b9af120eea902b071694c51bbf99dac Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:34:45 +0200 Subject: [PATCH 45/65] feat!: split the document model from CityJSONSeq sequencing CityJSON holds the document and nothing else: the hidden sorted_ids and transform_correction state is gone, and so is the hand-written PartialEq. In its place: - CityJSON::header() (was get_metadata) and CityJSON::features(order), an iterator of Result; CityJSON::feature(id) slices out one. sort_cjfeatures and get_cjfeature(i) are gone. - Collector: new(header), add(feature), add_header(header) for a further sequence whose features are in another transform, and finish(). It replaces add_cjfeature/add_transform_correction, and refuses a feature whose id is not one of its City Objects. cjseq_to_cj wraps it and returns a Result. - SeqReader yields Line::Header / Line::Feature, skips blank lines and wraps errors in AtLine { line }; SeqWriter writes them back. Line::from_str parses a feature line once, and looks at the type only when that fails. - Transform::new is public, with Default. The CLI uses them: every line is parsed once, output is buffered, filter --cotype no longer panics on a feature whose id is missing, collect refuses features before any header, and a new 'cjseq validate' prints every problem in a CityJSON or CityJSONSeq. The wasm binding reports collect errors instead of panicking. On the 65 MB / 15,000-feature sequence (release, median of 3): collect 0.68 s -> 0.41 s, filter --radius 0.49 s -> 0.25 s, cat unchanged at 0.44 s; collect output byte-identical. --- src/cityjson.rs | 383 ++++++++++++++++++------------------- src/error.rs | 12 +- src/geometry.rs | 2 +- src/lib.rs | 5 +- src/main.rs | 418 ++++++++++++++++------------------------- src/metadata.rs | 9 +- src/seq.rs | 116 ++++++++++++ src/wasm.rs | 11 +- tests/cj_input.rs | 20 +- tests/cjf_input.rs | 42 ++--- tests/robustness.rs | 13 +- tests/seq.rs | 177 +++++++++++++++++ tests/texture_merge.rs | 22 +-- tests/validate.rs | 9 +- 14 files changed, 708 insertions(+), 531 deletions(-) create mode 100644 src/seq.rs create mode 100644 tests/seq.rs diff --git a/src/cityjson.rs b/src/cityjson.rs index 3d95243..cff0c13 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,4 +1,4 @@ -use crate::appearance::{Appearance, MaterialObject, TextureObject}; +use crate::appearance::Appearance; use crate::city_object::CityObject; use crate::error::{CjseqError, Result}; use crate::geometry::GeometryTemplates; @@ -59,7 +59,7 @@ fn slice_appearance( Ok(a) } -/// The order in which [`CityJSON::get_cjfeature`] hands out features. +/// The order in which [`CityJSON::features`] hands out features. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SortingStrategy { /// The order the top-level City Objects appear in the document. @@ -70,7 +70,10 @@ pub enum SortingStrategy { Lexicographical, } -#[derive(Serialize, Deserialize, Debug, Clone)] +/// A CityJSON document. It holds the document and nothing else; splitting it +/// into a CityJSONSeq is [`CityJSON::header`] and [`CityJSON::features`], and +/// the reverse is [`crate::Collector`]. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CityJSON { #[serde(rename = "type")] pub thetype: CityJSONType, @@ -91,27 +94,6 @@ pub struct CityJSON { /// Root members the schema does not name, kept verbatim. #[serde(flatten)] pub other: Map, - #[serde(skip)] - sorted_ids: Vec, - #[serde(skip)] - transform_correction: Option, -} - -/// Equality is over the document only: `sorted_ids` and -/// `transform_correction` are sequencing state, not CityJSON members. -impl PartialEq for CityJSON { - fn eq(&self, other: &Self) -> bool { - self.thetype == other.thetype - && self.version == other.version - && self.transform == other.transform - && self.city_objects == other.city_objects - && self.vertices == other.vertices - && self.metadata == other.metadata - && self.appearance == other.appearance - && self.geometry_templates == other.geometry_templates - && self.extensions == other.extensions - && self.other == other.other - } } impl Default for CityJSON { @@ -122,17 +104,14 @@ impl Default for CityJSON { impl FromStr for CityJSON { type Err = CjseqError; - /// Parse a CityJSON document. Its features are handed out in document - /// order until [`CityJSON::sort_cjfeatures`] says otherwise. + /// Parse a CityJSON document. fn from_str(s: &str) -> Result { - let mut cjj: CityJSON = serde_json::from_str(s)?; - cjj.sort_cjfeatures(SortingStrategy::AsIs); - Ok(cjj) + Ok(serde_json::from_str(s)?) } } impl CityJSON { - /// Create a new CityJSON instance with default values. + /// An empty CityJSON 2.0 document with the identity transform. pub fn new() -> Self { CityJSON { thetype: CityJSONType::CityJSON, @@ -145,31 +124,20 @@ impl CityJSON { geometry_templates: None, extensions: None, other: Map::new(), - sorted_ids: vec![], - transform_correction: None, } } - /// Get the "first line" (aka metadata or header) of a CityJSONSeq: the - /// document without its City Objects and vertices, its appearance cut - /// down to what the geometry templates use. An appearance index in a - /// template that is out of range is an error. - pub fn get_metadata(&self) -> Result { + + /// The first line of a CityJSONSeq: the document without its City + /// Objects and vertices, its appearance cut down to what the geometry + /// templates use. An appearance index in a template that is out of range + /// is an error. + pub fn header(&self) -> Result { let mut cj0 = CityJSON { - thetype: self.thetype.clone(), - version: self.version, - transform: self.transform.clone(), - metadata: self.metadata.clone(), city_objects: IndexMap::new(), vertices: Vec::new(), appearance: None, - geometry_templates: self.geometry_templates.clone(), - other: self.other.clone(), - extensions: self.extensions.clone(), - sorted_ids: vec![], - transform_correction: None, + ..self.clone_without_objects() }; - //-- if geometry-templates have material/textures then these need to be - //-- added to 1st line (metadata) if let Some(x) = &self.geometry_templates { let mut gts2: GeometryTemplates = x.clone(); let mut m_oldnew: HashMap = HashMap::new(); @@ -188,21 +156,68 @@ impl CityJSON { Ok(cj0) } - /// The `i`th feature, in the order set by [`CityJSON::sort_cjfeatures`]: - /// a top-level City Object with all of its descendants, and only the - /// vertices and appearance entries they use. + /// Every member but the City Objects, vertices and appearance, which + /// `header` rebuilds. + fn clone_without_objects(&self) -> Self { + CityJSON { + thetype: self.thetype.clone(), + version: self.version, + transform: self.transform.clone(), + city_objects: IndexMap::new(), + vertices: Vec::new(), + metadata: self.metadata.clone(), + appearance: None, + geometry_templates: self.geometry_templates.clone(), + extensions: self.extensions.clone(), + other: self.other.clone(), + } + } + + /// The ids of the top-level City Objects, in `order`. + fn feature_ids(&self, order: SortingStrategy) -> Vec<&String> { + let mut ids: Vec<&String> = self + .city_objects + .iter() + .filter(|(_, co)| co.is_toplevel()) + .map(|(id, _)| id) + .collect(); + match order { + SortingStrategy::AsIs => {} + SortingStrategy::Random => { + use rand::seq::SliceRandom; + ids.shuffle(&mut rand::thread_rng()); + } + SortingStrategy::Lexicographical => ids.sort(), + } + ids + } + + /// The document's features, one per top-level City Object, in `order`; + /// see [`CityJSON::feature`]. + pub fn features( + &self, + order: SortingStrategy, + ) -> impl Iterator> + '_ { + self.feature_ids(order) + .into_iter() + .map(move |id| self.slice(id, &self.city_objects[id])) + } + + /// The feature rooted at City Object `id`: it with all of its + /// descendants, and only the vertices and appearance entries they use, + /// renumbered. `Ok(None)` if there is no such City Object. /// - /// `Ok(None)` once `i` is past the last feature. A child id that names no - /// City Object, or an index past the end of `vertices` or of an appearance - /// palette, is an error. - pub fn get_cjfeature(&self, i: usize) -> Result> { - let Some(id) = self.sorted_ids.get(i) else { - return Ok(None); - }; - let Some(root) = self.city_objects.get(id) else { - return Ok(None); - }; - //-- the root, then every descendant depth-first; `seen` stops a + /// A child id that names no City Object, or an index past the end of + /// `vertices` or of an appearance palette, is an error. + pub fn feature(&self, id: &str) -> Result> { + match self.city_objects.get_key_value(id) { + Some((id, root)) => self.slice(id, root).map(Some), + None => Ok(None), + } + } + + fn slice(&self, id: &String, root: &CityObject) -> Result { + //-- the root, then every descendant breadth-first; `seen` stops a //-- parent/child cycle from looping let mut members: Vec<(&String, &CityObject)> = vec![(id, root)]; let mut seen: HashSet<&str> = HashSet::from([id.as_str()]); @@ -243,59 +258,10 @@ impl CityJSON { if let Some(a) = &self.appearance { cjf.appearance = Some(slice_appearance(a, &m_oldnew, &t_oldnew, &t_v_oldnew)?); } - Ok(Some(cjf)) - } - /// Used when many CityJSONSeq are used, the "transform" can - /// be modified (the new value is a "correction"). - pub fn add_transform_correction(&mut self, t: Transform) { - self.transform_correction = Some(t); + Ok(cjf) } - pub fn add_cjfeature(&mut self, cjf: &mut CityJSONFeature) { - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); - let g_offset = self.vertices.len(); - if let Some(cjf_app) = &cjf.appearance { - if let Some(cjf_mat) = &cjf_app.materials { - for (i, m) in cjf_mat.iter().enumerate() { - m_oldnew.insert( - MaterialIndex(i), - MaterialIndex(self.add_material(m.clone())), - ); - } - } - if let Some(cjf_tex) = &cjf_app.textures { - for (i, m) in cjf_tex.iter().enumerate() { - t_oldnew.insert(TextureIndex(i), TextureIndex(self.add_texture(m.clone()))); - } - } - if let Some(cjf_v_tex) = &cjf_app.vertices_texture { - //-- the feature's texture vertices are appended verbatim, so - //-- each one's new index is its old one shifted by however - //-- many were already collected - let t_v_offset = self - .appearance - .as_ref() - .and_then(|a| a.vertices_texture.as_ref()) - .map_or(0, Vec::len); - for i in 0..cjf_v_tex.len() { - t_v_oldnew.insert(UvIndex(i), UvIndex(t_v_offset + i)); - } - self.add_vertices_texture(cjf_v_tex.clone()); - } - } - for (key, co) in &mut cjf.city_objects { - for g in co.geometry.iter_mut().flatten() { - g.offset_geometry_boundaries(g_offset); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew); - } - self.add_co(key.to_string(), co.clone()); - } - self.add_vertices(&mut cjf.vertices); - self.sorted_ids.push(cjf.id.clone()); - } + /// Merge identical vertices, renumbering every geometry to match. pub fn remove_duplicate_vertices(&mut self) { let mut h: HashMap = HashMap::new(); let mut newids: HashMap = HashMap::new(); @@ -369,63 +335,111 @@ impl CityJSON { *tr += m as f64 * sc; } } + /// The number of top-level City Objects, ie of features. pub fn number_of_city_objects(&self) -> usize { self.city_objects .values() .filter(|co| co.is_toplevel()) .count() } - /// Set the order in which [`CityJSON::get_cjfeature`] hands out features. - pub fn sort_cjfeatures(&mut self, ss: SortingStrategy) { - self.sorted_ids = self - .city_objects - .iter() - .filter(|(_, co)| co.is_toplevel()) - .map(|(key, _)| key.clone()) - .collect(); - match ss { - SortingStrategy::AsIs => {} - SortingStrategy::Random => { - use rand::seq::SliceRandom; - self.sorted_ids.shuffle(&mut rand::thread_rng()); - } - SortingStrategy::Lexicographical => self.sorted_ids.sort(), +} + +/// Collects a CityJSONSeq back into one CityJSON document: start from the +/// header, [`Collector::add`] each feature, then [`Collector::finish`]. +/// +/// Features from several sequences can be collected into one document: hand +/// each further sequence's header to [`Collector::add_header`], and the +/// features that follow are re-expressed in the first header's transform. +#[derive(Debug, Clone)] +pub struct Collector { + doc: CityJSON, + /// The transform the features being added are expressed in, when it is + /// not the document's. + correction: Option, +} + +impl Collector { + pub fn new(header: CityJSON) -> Self { + Collector { + doc: header, + correction: None, } } - fn add_co(&mut self, id: String, co: CityObject) { - self.city_objects.insert(id, co); + + /// The header of a further sequence: the features added after it are in + /// its transform. + pub fn add_header(&mut self, header: CityJSON) -> Result<()> { + self.correction = (header.transform != self.doc.transform).then_some(header.transform); + Ok(()) } - fn add_vertices(&mut self, vs: &mut Vec) { - if let Some(c) = &self.transform_correction { - //-- re-express each vertex in this document's transform - let t = &self.transform; - for v in vs.iter() { - let mut out = [0; 3]; - for k in 0..3 { - out[k] = (((v[k] as f64 * c.scale[k]) + c.translate[k] - t.translate[k]) - / t.scale[k]) - .round() as i64; + + /// Add one feature: its City Objects, its vertices (re-expressed if a + /// later header changed the transform) and its appearance, merged into + /// the document's palettes. + pub fn add(&mut self, mut cjf: CityJSONFeature) -> Result<()> { + if !cjf.city_objects.contains_key(&cjf.id) { + return Err(CjseqError::DanglingReference { + from: "CityJSONFeature.id".to_string(), + to: cjf.id, + }); + } + let doc = &mut self.doc; + let mut m_oldnew: HashMap = HashMap::new(); + let mut t_oldnew: HashMap = HashMap::new(); + let mut t_v_oldnew: HashMap = HashMap::new(); + let g_offset = doc.vertices.len(); + if let Some(cjf_app) = cjf.appearance.take() { + let app = doc.appearance.get_or_insert_with(Appearance::new); + for (i, m) in cjf_app.materials.into_iter().flatten().enumerate() { + m_oldnew.insert(MaterialIndex(i), MaterialIndex(app.add_material(m))); + } + for (i, t) in cjf_app.textures.into_iter().flatten().enumerate() { + t_oldnew.insert(TextureIndex(i), TextureIndex(app.add_texture(t))); + } + if let Some(uvs) = cjf_app.vertices_texture { + //-- the feature's texture vertices are appended verbatim, so + //-- each one's new index is its old one shifted by however + //-- many were already collected + let t_v_offset = app.vertices_texture.as_ref().map_or(0, Vec::len); + for i in 0..uvs.len() { + t_v_oldnew.insert(UvIndex(i), UvIndex(t_v_offset + i)); } - self.vertices.push(out); + app.add_vertices_texture(uvs); } - } else { - self.vertices.append(vs); } + for (key, mut co) in cjf.city_objects { + for g in co.geometry.iter_mut().flatten() { + g.offset_geometry_boundaries(g_offset); + g.update_material(&mut m_oldnew); + g.update_texture(&mut t_oldnew, &mut t_v_oldnew); + } + doc.city_objects.insert(key, co); + } + match &self.correction { + None => doc.vertices.extend(cjf.vertices), + Some(c) => { + //-- re-express each vertex in the document's transform + let t = &doc.transform; + doc.vertices.extend(cjf.vertices.iter().map(|v| { + [0, 1, 2].map(|k| { + (((v[k] as f64 * c.scale[k]) + c.translate[k] - t.translate[k]) + / t.scale[k]) + .round() as i64 + }) + })); + } + } + Ok(()) } - fn add_vertices_texture(&mut self, vs: Vec<[f64; 2]>) { - self.appearance - .get_or_insert_with(Appearance::new) - .add_vertices_texture(vs); - } - fn add_material(&mut self, jm: MaterialObject) -> usize { - self.appearance - .get_or_insert_with(Appearance::new) - .add_material(jm) - } - fn add_texture(&mut self, jm: TextureObject) -> usize { - self.appearance - .get_or_insert_with(Appearance::new) - .add_texture(jm) + + /// The collected document, with duplicate vertices merged, the transform + /// moved to the vertices' minimum corner and the geographical extent + /// recomputed. + pub fn finish(mut self) -> CityJSON { + self.doc.remove_duplicate_vertices(); + self.doc.update_transform(); + self.doc.update_geographicalextent(); + self.doc } } @@ -488,18 +502,14 @@ impl CityJSONFeature { } } -/// Collects a base CityJSON metadata and a vector of CityJSONFeatures -/// into a complete CityJSON object -pub fn cjseq_to_cj(mut base_cj: CityJSON, features: Vec) -> CityJSON { - for mut feature in features { - base_cj.add_cjfeature(&mut feature); - } - - base_cj.remove_duplicate_vertices(); - base_cj.update_transform(); - base_cj.update_geographicalextent(); - - base_cj +/// Collect a header and its features into one CityJSON document; see +/// [`Collector`]. +pub fn cjseq_to_cj(header: CityJSON, features: Vec) -> Result { + let mut c = Collector::new(header); + for f in features { + c.add(f)?; + } + Ok(c.finish()) } /// The CityJSON versions this crate reads and writes. @@ -573,41 +583,6 @@ mod tests { assert_ne!(a, c); } - /// `sorted_ids` and `transform_correction` are `#[serde(skip)]` internal - /// bookkeeping, not part of the document: `sorted_ids` is populated by - /// `from_str` iterating `city_objects` (a `HashMap`, whose iteration - /// order is not guaranteed stable across independently-constructed - /// maps), and `sort_cjfeatures(Random)` shuffles it further. Two - /// `CityJSON`s parsed from the identical string represent the identical - /// document and must compare equal regardless of what order that - /// bookkeeping happened to land in. A fixture with a single top-level - /// CityObject cannot exercise this -- `sorted_ids` would trivially have - /// one, unambiguous order either way -- so this uses five. - #[test] - fn cityjson_parsed_twice_from_the_same_string_is_equal_regardless_of_hashmap_order() { - let input = serde_json::json!({ - "type": "CityJSON", - "version": "2.0", - "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, - "CityObjects": { - "id-a": {"type": "Building"}, - "id-b": {"type": "Building"}, - "id-c": {"type": "Building"}, - "id-d": {"type": "Building"}, - "id-e": {"type": "Building"} - }, - "vertices": [] - }) - .to_string(); - let a = CityJSON::from_str(&input).unwrap(); - let b = CityJSON::from_str(&input).unwrap(); - assert_eq!( - a, b, - "the same document parsed twice must be equal, independent of internal \ - bookkeeping order" - ); - } - #[test] fn cityjson_type_round_trips() { let t: CityJSONType = serde_json::from_value(serde_json::json!("CityJSON")).unwrap(); diff --git a/src/error.rs b/src/error.rs index 1d55ce8..0916f87 100644 --- a/src/error.rs +++ b/src/error.rs @@ -53,6 +53,11 @@ pub enum CjseqError { MisplacedMember { member: String }, /// An error inside one City Object. InCityObject { id: String, source: Box }, + /// An error on one line of a CityJSONSeq (counting from 1). + AtLine { + line: usize, + source: Box, + }, /// An error inside one geometry template. InTemplate { index: usize, @@ -110,6 +115,7 @@ impl fmt::Display for CjseqError { "a CityJSONFeature cannot carry {member:?}; only the CityJSON document can" ), CjseqError::InCityObject { id, source } => write!(f, "in City Object {id:?}: {source}"), + CjseqError::AtLine { line, source } => write!(f, "line {line}: {source}"), CjseqError::InTemplate { index, source } => { write!(f, "in geometry template {index}: {source}") } @@ -126,9 +132,9 @@ impl std::error::Error for CjseqError { CjseqError::Json(e) => Some(e), CjseqError::GeometryDepth { .. } => None, CjseqError::Io(e) => Some(e), - CjseqError::InCityObject { source, .. } | CjseqError::InTemplate { source, .. } => { - Some(source.as_ref()) - } + CjseqError::InCityObject { source, .. } + | CjseqError::InTemplate { source, .. } + | CjseqError::AtLine { source, .. } => Some(source.as_ref()), CjseqError::Validation(_) | CjseqError::DanglingReference { .. } | CjseqError::HierarchyMismatch { .. } diff --git a/src/geometry.rs b/src/geometry.rs index 4198ae6..bbda5ad 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -379,7 +379,7 @@ impl Geometry { /// Renumber every texture index against `t_oldnew` and every texture /// vertex against `t_v_oldnew`, both in first-encounter order. An index /// already in a map keeps the value the map gives it, so a caller that - /// knows the target numbering up front (as `CityJSON::add_cjfeature` + /// knows the target numbering up front (as `Collector::add` /// does) fills the map first. pub(crate) fn update_texture( &mut self, diff --git a/src/lib.rs b/src/lib.rs index 852dcbf..abecd35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,8 @@ //! - `city_object` — `CityObject` //! - `metadata` — `Metadata`, `PointOfContact`, `Address`, `ReferenceSystem`, `Transform`, `GeographicalExtent` //! - `error` — `CjseqError`, `Result` -//! - `cityjson` — `CityJSON`, `CityJSONFeature`, `SortingStrategy`, and the sequencing/collect/filter logic +//! - `cityjson` — `CityJSON`, `CityJSONFeature`, `SortingStrategy`, `Collector` +//! - `seq` — `SeqReader`, `SeqWriter`, `Line`: CityJSONSeq in and out //! //! Every type is re-exported at the crate root, so the module boundaries //! above are an internal organisational detail, not part of the public @@ -25,6 +26,7 @@ mod geometry; mod index; mod metadata; mod semantics; +mod seq; mod validate; pub use appearance::*; @@ -43,6 +45,7 @@ pub use index::{ }; pub use metadata::*; pub use semantics::*; +pub use seq::{Line, SeqReader, SeqWriter}; // WASM bindings module #[cfg(all(target_arch = "wasm32", feature = "wasm"))] diff --git a/src/main.rs b/src/main.rs index 9656e96..0bb1bb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,11 @@ use cjseq::error::Result; -use cjseq::CityJSON; -use cjseq::CityJSONFeature; -use cjseq::CityJSONType; -use cjseq::CityObjectType; -use cjseq::CjseqError; -use cjseq::Transform; -use std::str::FromStr; - -extern crate clap; +use cjseq::{ + CityJSON, CityObjectType, CjseqError, Collector, Line, SeqReader, SeqWriter, Transform, +}; use clap::{Parser, Subcommand, ValueEnum}; -use serde_json::Value; - use rand::Rng; use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; -use std::io::{self, Read, Write}; +use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; #[derive(Parser)] @@ -72,50 +62,26 @@ enum Commands { #[arg(long, value_name = "X", value_parser = clap::value_parser!(u32).range(1..), group = "exclusive")] random: Option, }, + /// Check a CityJSON or CityJSONSeq file for references that do not + /// resolve; prints every problem found + Validate { + /// CityJSON (.json) or CityJSONSeq (.jsonl) input file; stdin if absent + file: Option, + }, } fn main() { let cli = Cli::parse(); - - match &cli.command { - //-- cat + let result = match &cli.command { Commands::Cat { file, order } => { - let o2 = match order.clone().unwrap_or(SortingStrategy::AsIs) { + let order = match order.clone().unwrap_or(SortingStrategy::AsIs) { SortingStrategy::AsIs => cjseq::SortingStrategy::AsIs, SortingStrategy::Random => cjseq::SortingStrategy::Random, SortingStrategy::Lexicographical => cjseq::SortingStrategy::Lexicographical, }; - match file { - Some(x) => { - if let Err(e) = cat_from_file(x, o2) { - eprintln!("{e}"); - std::process::exit(1); - } - } - None => { - if let Err(e) = cat_from_stdin(o2) { - eprintln!("{e}"); - std::process::exit(1); - } - } - } + read_all(file.as_deref()).and_then(|s| cat(&s, order)) } - //-- collect - Commands::Collect { files } => match files { - Some(x) => { - if let Err(e) = collect_from_files(x) { - eprintln!("{e}"); - std::process::exit(1); - } - } - None => { - if let Err(e) = collect_from_stdin() { - eprintln!("{e}"); - std::process::exit(1); - } - } - }, - //-- filter + Commands::Collect { files } => collect(files.as_deref().unwrap_or_default()), Commands::Filter { bbox, cotype, @@ -123,251 +89,187 @@ fn main() { radius, random, } => { - if bbox.is_none() && cotype.is_none() && radius.is_none() && random.is_none() { - if let Err(e) = filter_random(*exclude, 1) { - eprintln!("{e}"); - std::process::exit(1); - } - } - if bbox.is_some() { - if let Err(e) = filter_bbox(*exclude, &bbox.clone().unwrap()) { - eprintln!("{e}"); - std::process::exit(1); - } - } - if cotype.is_some() { - if let Err(e) = filter_cotype(*exclude, cotype.clone().unwrap()) { - eprintln!("{e}"); - std::process::exit(1); + let keep = if let Some(b) = bbox { + Filter::Bbox([b[0], b[1], b[2], b[3]]) + } else if let Some(c) = cotype { + //-- parsed through the same type the City Objects carry, so + //-- the comparison is enum to enum + match serde_json::from_value::(serde_json::json!(c)) { + Ok(t) => Filter::CoType(t), + Err(e) => exit_with(e.into()), } - } - if radius.is_some() { - let p: Vec = radius.clone().unwrap(); - if let Err(e) = filter_radius(*exclude, p[0], p[1], p[2]) { - eprintln!("{e}"); - std::process::exit(1); - } - } - if random.is_some() { - if let Err(e) = filter_random(*exclude, random.unwrap()) { - eprintln!("{e}"); - std::process::exit(1); - } - } + } else if let Some(r) = radius { + Filter::Radius(r[0], r[1], r[2]) + } else { + Filter::Random(random.unwrap_or(1)) + }; + filter(&keep, *exclude) } + Commands::Validate { file } => read_all(file.as_deref()).and_then(|s| validate(&s)), + }; + if let Err(e) = result { + exit_with(e); } } -fn filter_random(exclude: bool, rand_factor: u32) -> Result<()> { - let stdin = std::io::stdin(); - let mut rng = rand::thread_rng(); - for line in stdin.lock().lines() { - let mut w: bool = false; - if let Ok(l) = line { - let j: Value = serde_json::from_str(&l)?; - if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - } else { - let r: u32 = rng.gen_range(1..=rand_factor); - if r == 1 { - w = true; - } - if (w && !exclude) || (!w && exclude) { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - } - } - } - } - Ok(()) +fn exit_with(e: CjseqError) -> ! { + eprintln!("{e}"); + std::process::exit(1); } -fn filter_cotype(exclude: bool, cotype: String) -> Result<()> { - // The CLI accepts any type name the user types (a core type such as - // "Building", or an Extension type such as "+NoiseBuilding"); parse it - // through the same enum used for the CityObjects being filtered so the - // comparison below is enum-to-enum, not string-to-string. - let cotype: CityObjectType = serde_json::from_value(serde_json::Value::String(cotype))?; - let stdin = std::io::stdin(); - for line in stdin.lock().lines() { - let mut w: bool = false; - if let Ok(l) = line { - let j: Value = serde_json::from_str(&l)?; - if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - } else { - let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; - if cjf.city_objects[&cjf.id].get_type() == cotype { - w = true; - } - if (w && !exclude) || (!w && exclude) { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - } - } +fn read_all(file: Option<&Path>) -> Result { + let mut s = String::new(); + match file { + Some(p) => { + File::open(p)?.read_to_string(&mut s)?; + } + None => { + io::stdin().read_to_string(&mut s)?; } } - - Ok(()) + Ok(s) } -fn filter_bbox(exclude: bool, bbox: &[f64]) -> Result<()> { - let stdin = std::io::stdin(); - let mut cj: CityJSON = CityJSON::new(); - for line in stdin.lock().lines() { - let mut w: bool = false; - if let Ok(l) = line { - let j: Value = serde_json::from_str(&l)?; - if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - cj = CityJSON::from_str(&l)?; - } else { - let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; - //-- a feature with no vertices is nowhere, so never inside - if let Some(ci) = cjf.centroid() { - let cx = (ci[0] * cj.transform.scale[0]) + cj.transform.translate[0]; - let cy = (ci[1] * cj.transform.scale[1]) + cj.transform.translate[1]; - if (cx > bbox[0]) && (cx < bbox[2]) && (cy > bbox[1]) && (cy < bbox[3]) { - w = true; - } - } - if (w && !exclude) || (!w && exclude) { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - } - } - } +fn cat(input: &str, order: cjseq::SortingStrategy) -> Result<()> { + let cj: CityJSON = input.parse()?; + let mut w = SeqWriter::new(BufWriter::new(io::stdout().lock())); + w.write_header(&cj.header()?)?; + for f in cj.features(order) { + w.write_feature(&f?)?; } + w.into_inner().flush()?; Ok(()) } -fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<()> { - let stdin = std::io::stdin(); - let mut cj: CityJSON = CityJSON::new(); - for line in stdin.lock().lines() { - let mut w: bool = false; - if let Ok(l) = line { - let j: Value = serde_json::from_str(&l)?; - if j.get("type").unwrap() == "CityJSON" { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; - cj = CityJSON::from_str(&l)?; - } else { - let cjf: CityJSONFeature = CityJSONFeature::from_str(&l)?; - //-- a feature with no vertices is nowhere, so never inside - if let Some(ci) = cjf.centroid() { - let cx = (ci[0] * cj.transform.scale[0]) + cj.transform.translate[0]; - let cy = (ci[1] * cj.transform.scale[1]) + cj.transform.translate[1]; - let d2 = (cx - x).powf(2.0) + (cy - y).powf(2.0); - if d2 <= (r * r) { - w = true; - } - } - if (w && !exclude) || (!w && exclude) { - io::stdout().write_all(format!("{}\n", l).as_bytes())?; +/// Collect every line of every sequence into one document: the first header +/// starts it, later headers set the transform of the features after them. +fn collect(files: &[String]) -> Result<()> { + let mut collector: Option = None; + let mut read = |r: &mut dyn BufRead| -> Result<()> { + for line in SeqReader::new(r) { + match (line?, collector.as_mut()) { + (Line::Header(h), None) => collector = Some(Collector::new(h)), + (Line::Header(h), Some(c)) => c.add_header(h)?, + (Line::Feature(f), Some(c)) => c.add(f)?, + (Line::Feature(_), None) => { + return Err(CjseqError::Validation( + "a CityJSONFeature came before any CityJSON header line".to_string(), + )) } } } + Ok(()) + }; + if files.is_empty() { + read(&mut io::stdin().lock())?; + } else { + for file in files { + read(&mut BufReader::new(File::open(file)?))?; + } } + let cj = collector.map(Collector::finish).unwrap_or_default(); + let mut out = BufWriter::new(io::stdout().lock()); + serde_json::to_writer(&mut out, &cj)?; + out.write_all(b"\n")?; + out.flush()?; Ok(()) } -fn collect_from_stdin() -> Result<()> { - let stdin = std::io::stdin(); - let mut cjj = CityJSON::new(); - let mut cjj_init: bool = false; - for json_line in stdin.lock().lines().map_while(std::result::Result::ok) { - let j: Value = serde_json::from_str(&json_line)?; - if j.get("type").unwrap() == "CityJSON" { - if !cjj_init { - cjj = CityJSON::from_str(&json_line)?; - cjj_init = true; - } else { - let t: Transform = serde_json::from_value(j.get("transform").unwrap().clone())?; - cjj.add_transform_correction(t); - } - } else { - let mut cjf = CityJSONFeature::from_str(&json_line)?; - cjj.add_cjfeature(&mut cjf); - } - } - cjj.remove_duplicate_vertices(); - cjj.update_transform(); - cjj.update_geographicalextent(); - io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjj).unwrap()).as_bytes())?; - Ok(()) +enum Filter { + Bbox([f64; 4]), + CoType(CityObjectType), + Radius(f64, f64, f64), + /// Keep 1 in X + Random(u32), } -fn collect_from_files(files: &[String]) -> Result<()> { - let mut cjj = CityJSON::new(); - let mut cjj_init: bool = false; - for file in files { - let path = PathBuf::from(file); - let f = File::open(path.canonicalize()?)?; - let br = BufReader::new(f); - for (i, line) in br.lines().enumerate() { - match &line { - Ok(l) => { - if i == 0 { - if !cjj_init { - cjj = CityJSON::from_str(l)?; - cjj_init = true; - } else { - let cjj2 = CityJSON::from_str(l)?; - let t: Transform = cjj2.transform; - cjj.add_transform_correction(t); - } - } else { - let mut cjf: CityJSONFeature = CityJSONFeature::from_str(l)?; - cjj.add_cjfeature(&mut cjf); +/// Copy the sequence on stdin to stdout, keeping the header lines and the +/// features `keep` selects (or, with `exclude`, the others). Lines are copied +/// as they are, not re-serialized. +fn filter(keep: &Filter, exclude: bool) -> Result<()> { + let mut rng = rand::thread_rng(); + let mut transform = Transform::new(); + let mut out = BufWriter::new(io::stdout().lock()); + for (i, text) in io::stdin().lock().lines().enumerate() { + let text = text?; + if text.trim().is_empty() { + continue; + } + let at = |e: CjseqError| CjseqError::AtLine { + line: i + 1, + source: Box::new(e), + }; + let selected = match text.parse::().map_err(at)? { + Line::Header(h) => { + transform = h.transform; + writeln!(out, "{text}")?; + continue; + } + Line::Feature(f) => { + //-- the real-world centre, or None for a feature with no + //-- vertices, which is nowhere and so never inside + let centre = f + .centroid() + .map(|c| [0, 1].map(|k| c[k] * transform.scale[k] + transform.translate[k])); + match keep { + Filter::Bbox([minx, miny, maxx, maxy]) => centre + .is_some_and(|[x, y]| x > *minx && x < *maxx && y > *miny && y < *maxy), + Filter::Radius(cx, cy, r) => { + centre.is_some_and(|[x, y]| (x - cx).powi(2) + (y - cy).powi(2) <= r * r) } + Filter::CoType(t) => match f.city_objects.get(&f.id) { + Some(co) => co.thetype == *t, + None => { + return Err(at(CjseqError::DanglingReference { + from: "CityJSONFeature.id".to_string(), + to: f.id, + })) + } + }, + Filter::Random(x) => rng.gen_range(1..=*x) == 1, } - Err(error) => eprintln!("Error reading line: {}", error), } + }; + if selected != exclude { + writeln!(out, "{text}")?; } } - cjj.remove_duplicate_vertices(); - cjj.update_transform(); - cjj.update_geographicalextent(); - io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjj).unwrap()).as_bytes())?; + out.flush()?; Ok(()) } -fn cat_from_stdin(order: cjseq::SortingStrategy) -> Result<()> { - let mut input = String::new(); - match std::io::stdin().read_to_string(&mut input) { - Ok(_) => { - let mut cjj: CityJSON = CityJSON::from_str(&input)?; - cat(&mut cjj, order)?; +/// Print every problem in a CityJSON document or a CityJSONSeq; fail if +/// there is one. +fn validate(input: &str) -> Result<()> { + let mut problems: Vec = Vec::new(); + match input.parse::() { + Ok(cj) => { + problems.extend(cj.errors().iter().map(ToString::to_string)); } - Err(error) => { - eprintln!("Error: {}", error); + _ => { + for line in SeqReader::new(input.as_bytes()) { + match line { + Err(e) => problems.push(e.to_string()), + Ok(Line::Header(h)) => { + problems.extend(h.errors().iter().map(|e| format!("header: {e}"))) + } + Ok(Line::Feature(f)) => problems.extend( + f.errors() + .iter() + .map(|e| format!("feature {:?}: {e}", f.id)), + ), + } + } } } - Ok(()) -} - -fn cat_from_file(file: &Path, order: cjseq::SortingStrategy) -> Result<()> { - let f = File::open(file.canonicalize()?)?; - let mut br = BufReader::new(f); - let mut json_content = String::new(); - br.read_to_string(&mut json_content)?; - let mut cjj: CityJSON = CityJSON::from_str(&json_content)?; - cat(&mut cjj, order)?; - Ok(()) -} - -fn cat(cjj: &mut CityJSON, order: cjseq::SortingStrategy) -> Result<()> { - if cjj.thetype != CityJSONType::CityJSON { - return Err(CjseqError::Validation( - "Input file not CityJSON.".to_string(), - )); + for p in &problems { + println!("{p}"); } - cjj.sort_cjfeatures(order); - //-- first line: the CityJSON "metadata" - let cj1 = cjj.get_metadata()?; - io::stdout().write_all(format!("{}\n", serde_json::to_string(&cj1).unwrap()).as_bytes())?; - //-- the other lines for each CityJSONSeq - let mut i: usize = 0; - while let Some(cjf) = cjj.get_cjfeature(i)? { - i += 1; - io::stdout().write_all(format!("{}\n", serde_json::to_string(&cjf).unwrap()).as_bytes())?; + match problems.len() { + 0 => { + println!("valid"); + Ok(()) + } + n => Err(CjseqError::Validation(format!("{n} problem(s) found"))), } - Ok(()) } diff --git a/src/metadata.rs b/src/metadata.rs index beed8e2..aff0be1 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -10,8 +10,15 @@ pub struct Transform { pub scale: [f64; 3], pub translate: [f64; 3], } +impl Default for Transform { + fn default() -> Self { + Self::new() + } +} + impl Transform { - pub(crate) fn new() -> Self { + /// The identity transform: scale 1, translate 0. + pub fn new() -> Self { Transform { scale: [1.0, 1.0, 1.0], translate: [0.0, 0.0, 0.0], diff --git a/src/seq.rs b/src/seq.rs new file mode 100644 index 0000000..3648553 --- /dev/null +++ b/src/seq.rs @@ -0,0 +1,116 @@ +//! Reading and writing CityJSONSeq (CityJSON Text Sequences, § 7.2): one +//! CityJSON header line, then one CityJSONFeature per line. + +use crate::cityjson::{CityJSON, CityJSONFeature}; +use crate::error::{CjseqError, Result}; +use serde::Deserialize; +use std::io::{BufRead, Write}; +use std::str::FromStr; + +/// One line of a CityJSONSeq. +//-- a Line is matched on and taken apart as soon as it is read, so the size +//-- difference between the two never sits in a collection +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq)] +pub enum Line { + /// A `"type": "CityJSON"` line: the first line of a sequence, or of each + /// sequence when several are concatenated. + Header(CityJSON), + /// A `"type": "CityJSONFeature"` line. + Feature(CityJSONFeature), +} + +/// Just the `type` member of a line, borrowed. +#[derive(Deserialize)] +struct TypeOnly<'a> { + #[serde(rename = "type", borrow)] + thetype: std::borrow::Cow<'a, str>, +} + +impl FromStr for Line { + type Err = CjseqError; + /// Parse one line. Nearly every line is a feature, so it is parsed as one + /// straight away; only when that fails is the line's `type` looked at, to + /// parse a header or report the feature's own error. + fn from_str(s: &str) -> Result { + let feature_err = match serde_json::from_str::(s) { + Ok(f) => return Ok(Line::Feature(f)), + Err(e) => e, + }; + match serde_json::from_str::(s) { + Ok(t) if t.thetype == "CityJSON" => Ok(Line::Header(serde_json::from_str(s)?)), + Ok(t) if t.thetype == "CityJSONFeature" => Err(feature_err.into()), + Ok(t) => Err(CjseqError::Validation(format!( + "a CityJSONSeq line is a \"CityJSON\" or a \"CityJSONFeature\", not {:?}", + t.thetype + ))), + Err(_) => Err(feature_err.into()), + } + } +} + +/// Reads a CityJSONSeq one line at a time. Blank lines are skipped; an error +/// says which line it is on. +pub struct SeqReader { + lines: std::io::Lines, + line: usize, +} + +impl SeqReader { + pub fn new(reader: R) -> Self { + SeqReader { + lines: reader.lines(), + line: 0, + } + } +} + +impl Iterator for SeqReader { + type Item = Result; + fn next(&mut self) -> Option> { + loop { + let text = self.lines.next()?; + self.line += 1; + let line = self.line; + let at = |e: CjseqError| CjseqError::AtLine { + line, + source: Box::new(e), + }; + match text { + Err(e) => return Some(Err(at(e.into()))), + Ok(t) if t.trim().is_empty() => continue, + Ok(t) => return Some(t.parse().map_err(at)), + } + } + } +} + +/// Writes a CityJSONSeq: each header or feature as one line of compact JSON. +pub struct SeqWriter { + out: W, +} + +impl SeqWriter { + pub fn new(out: W) -> Self { + SeqWriter { out } + } + + pub fn write_header(&mut self, header: &CityJSON) -> Result<()> { + self.write(header) + } + + pub fn write_feature(&mut self, feature: &CityJSONFeature) -> Result<()> { + self.write(feature) + } + + fn write(&mut self, value: &T) -> Result<()> { + serde_json::to_writer(&mut self.out, value)?; + self.out.write_all(b"\n")?; + Ok(()) + } + + /// The underlying writer, eg to flush it. + pub fn into_inner(self) -> W { + self.out + } +} diff --git a/src/wasm.rs b/src/wasm.rs index ddbea96..af05770 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -54,7 +54,16 @@ pub fn cjseq_to_cj_wasm(base_cj: JsValue, features: JsValue) -> Result cj, + Err(e) => { + web_sys::console::error_1(&format!("failed to collect features: {}", e).into()); + return Err(JsValue::from_str(&format!( + "failed to collect features: {}", + e + ))); + } + }; match to_value(&cj) { Ok(js_val) => Ok(js_val), diff --git a/tests/cj_input.rs b/tests/cj_input.rs index 1bab61d..0ff61c0 100755 --- a/tests/cj_input.rs +++ b/tests/cj_input.rs @@ -46,7 +46,7 @@ fn empty() { let j = get_empty_file(); let cjj = CityJSON::from_str(&j.to_string()).unwrap(); assert!(cjj.number_of_city_objects() == 0); - assert!(cjj.get_cjfeature(0).unwrap().is_none()); + assert_eq!(cjj.features(cjseq::SortingStrategy::AsIs).count(), 0); } #[test] @@ -55,18 +55,18 @@ fn basic_reading_of_cj() { let mut br = BufReader::new(f); let mut json_content = String::new(); br.read_to_string(&mut json_content).unwrap(); - let mut cjj: CityJSON = CityJSON::from_str(&json_content).unwrap(); + let cjj: CityJSON = CityJSON::from_str(&json_content).unwrap(); assert!(cjj.number_of_city_objects() == 2); - let cj1 = cjj.get_metadata().unwrap(); + let cj1 = cjj.header().unwrap(); assert!(cj1.number_of_city_objects() == 0); assert!(cj1.vertices.is_empty()); - cjj.sort_cjfeatures(cjseq::SortingStrategy::Lexicographical); - let mut cjnext = cjj.get_cjfeature(0).unwrap().unwrap(); - assert_eq!(cjnext.thetype, cjseq::CityJSONFeatureType::CityJSONFeature); - assert_eq!(cjnext.id, "NL.IMBAG.Pand.0503100000028341"); - - cjnext = cjj.get_cjfeature(1).unwrap().unwrap(); - assert_eq!(cjnext.id, "NL.IMBAG.Pand.0503100000031927"); + let fs: Vec<_> = cjj + .features(cjseq::SortingStrategy::Lexicographical) + .map(|f| f.unwrap()) + .collect(); + assert_eq!(fs[0].thetype, cjseq::CityJSONFeatureType::CityJSONFeature); + assert_eq!(fs[0].id, "NL.IMBAG.Pand.0503100000028341"); + assert_eq!(fs[1].id, "NL.IMBAG.Pand.0503100000031927"); } diff --git a/tests/cjf_input.rs b/tests/cjf_input.rs index 32e09e2..b4e2365 100755 --- a/tests/cjf_input.rs +++ b/tests/cjf_input.rs @@ -1,33 +1,21 @@ -use cjseq::{CityJSON, CityJSONFeature}; -use std::path::PathBuf; +use cjseq::{CityJSON, CityJSONFeature, Collector, SortingStrategy}; use std::str::FromStr; -use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; - #[test] fn test1() { - let mut cjj = CityJSON::new(); - let path = PathBuf::from("data/3dbag_b2.city.jsonl"); - let f = File::open(path.canonicalize().unwrap()).unwrap(); - let br = BufReader::new(f); - for (i, line) in br.lines().enumerate() { - match &line { - Ok(l) => { - if i == 0 { - cjj = CityJSON::from_str(l).unwrap(); - assert!(cjj.number_of_city_objects() == 0); - assert!(cjj.get_cjfeature(0).unwrap().is_none()); - assert!(cjj.vertices.is_empty()); - } else { - let mut cjf: CityJSONFeature = CityJSONFeature::from_str(l).unwrap(); - cjj.add_cjfeature(&mut cjf); - assert_eq!(cjj.number_of_city_objects(), i); - } - } - Err(error) => eprintln!("Error reading line: {}", error), - } + let text = std::fs::read_to_string("data/3dbag_b2.city.jsonl").unwrap(); + let mut lines = text.lines(); + let header = CityJSON::from_str(lines.next().unwrap()).unwrap(); + assert_eq!(header.number_of_city_objects(), 0); + assert_eq!(header.features(SortingStrategy::AsIs).count(), 0); + assert!(header.vertices.is_empty()); + let mut c = Collector::new(header); + let mut n = 0; + for l in lines { + c.add(CityJSONFeature::from_str(l).unwrap()).unwrap(); + n += 1; } - assert_eq!(cjj.number_of_city_objects(), 2); + let cj = c.finish(); + assert_eq!(n, 2); + assert_eq!(cj.number_of_city_objects(), 2); } diff --git a/tests/robustness.rs b/tests/robustness.rs index e1a2793..d199b9e 100644 --- a/tests/robustness.rs +++ b/tests/robustness.rs @@ -19,12 +19,7 @@ fn doc(city_objects: Value, vertices: Value) -> CityJSON { } fn features(cj: &mut CityJSON, order: SortingStrategy) -> Vec { - cj.sort_cjfeatures(order); - let mut out = Vec::new(); - while let Some(f) = cj.get_cjfeature(out.len()).unwrap() { - out.push(f); - } - out + cj.features(order).map(|f| f.unwrap()).collect() } #[test] @@ -144,7 +139,7 @@ fn a_dangling_child_is_an_error_not_a_panic() { json!({"b": {"type": "Building", "children": ["nope"]}}), json!([]), ); - match cj.get_cjfeature(0) { + match cj.feature("b") { Err(CjseqError::DanglingReference { from, to }) => { assert_eq!(from, "b"); assert_eq!(to, "nope"); @@ -160,7 +155,7 @@ fn an_out_of_range_vertex_index_is_an_error_not_a_panic() { json!([[1, 2, 3]]), ); assert!(matches!( - cj.get_cjfeature(0), + cj.feature("b"), Err(CjseqError::IndexOutOfRange { .. }) )); } @@ -168,7 +163,7 @@ fn an_out_of_range_vertex_index_is_an_error_not_a_panic() { #[test] fn an_index_past_the_last_feature_is_none() { let cj = doc(json!({"b": {"type": "Building"}}), json!([])); - assert!(cj.get_cjfeature(1).unwrap().is_none()); + assert!(cj.feature("nope").unwrap().is_none()); } #[test] diff --git a/tests/seq.rs b/tests/seq.rs new file mode 100644 index 0000000..779bf42 --- /dev/null +++ b/tests/seq.rs @@ -0,0 +1,177 @@ +//! CityJSONSeq in and out: a document's features as an iterator, a Collector +//! for the reverse, and a streaming reader and writer. + +use cjseq::{ + CityJSON, CityJSONFeature, CjseqError, Collector, Line, SeqReader, SeqWriter, SortingStrategy, +}; +use serde_json::json; +use std::io::BufReader; +use std::str::FromStr; + +fn read(path: &str) -> (CityJSON, Vec) { + let mut header = None; + let mut features = Vec::new(); + for line in SeqReader::new(BufReader::new(std::fs::File::open(path).unwrap())) { + match line.unwrap() { + Line::Header(h) => header = Some(h), + Line::Feature(f) => features.push(f), + } + } + (header.unwrap(), features) +} + +fn collect(header: CityJSON, features: Vec) -> CityJSON { + let mut c = Collector::new(header); + for f in features { + c.add(f).unwrap(); + } + c.finish() +} + +#[test] +fn a_line_is_a_header_or_a_feature() { + let h = Line::from_str( + r#"{"type":"CityJSON","version":"2.0","transform":{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]},"CityObjects":{},"vertices":[]}"#, + ) + .unwrap(); + assert!(matches!(h, Line::Header(_))); + let f = Line::from_str(r#"{"type":"CityJSONFeature","id":"a","CityObjects":{"a":{"type":"Building"}},"vertices":[]}"#).unwrap(); + assert!(matches!(f, Line::Feature(ref f) if f.id == "a")); + //-- a broken feature reports the feature's problem, not a header's + let e = Line::from_str(r#"{"type":"CityJSONFeature","id":"a","CityObjects":{"a":{"type":"Buildng"}},"vertices":[]}"#).unwrap_err(); + assert!(e.to_string().contains("Buildng"), "{e}"); + assert!(Line::from_str(r#"{"type":"Something"}"#).is_err()); +} + +#[test] +fn the_reader_says_which_line_is_bad() { + let input = "{\"type\":\"CityJSON\",\"version\":\"2.0\",\"transform\":{\"scale\":[1.0,1.0,1.0],\"translate\":[0.0,0.0,0.0]},\"CityObjects\":{},\"vertices\":[]}\n\nnot json\n"; + let lines: Vec<_> = SeqReader::new(input.as_bytes()).collect(); + assert_eq!(lines.len(), 2, "a blank line is skipped"); + assert!(lines[0].is_ok()); + match &lines[1] { + Err(CjseqError::AtLine { line, .. }) => assert_eq!(*line, 3), + other => panic!("expected AtLine, got {other:?}"), + } +} + +#[test] +fn features_come_out_in_the_order_asked_for() { + let cj = CityJSON::from_str( + r#"{"type":"CityJSON","version":"2.0","transform":{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]},"CityObjects":{"c":{"type":"Building"},"a":{"type":"Building"},"b":{"type":"Building"}},"vertices":[]}"#, + ) + .unwrap(); + let ids = |o| -> Vec { cj.features(o).map(|f| f.unwrap().id).collect() }; + assert_eq!(ids(SortingStrategy::AsIs), ["c", "a", "b"]); + assert_eq!(ids(SortingStrategy::Lexicographical), ["a", "b", "c"]); + let mut r = ids(SortingStrategy::Random); + r.sort(); + assert_eq!(r, ["a", "b", "c"]); +} + +#[test] +fn collecting_and_slicing_round_trip_every_fixture() { + for path in [ + "tests/data/small.city.jsonl", + "tests/data/noise_extension.city.jsonl", + "data/3dbag_b2.city.jsonl", + ] { + let (header, features) = read(path); + let doc = collect(header, features.clone()); + doc.validate().unwrap(); + //-- slicing the collected document gives the same features back, + //-- up to vertex and palette numbering, which compares equal once + //-- collected again + let back: Vec = doc + .features(SortingStrategy::AsIs) + .map(|f| f.unwrap()) + .collect(); + assert_eq!(back.len(), features.len(), "{path}"); + for (a, b) in back.iter().zip(&features) { + assert_eq!(a.id, b.id); + assert_eq!(a.city_objects.len(), b.city_objects.len()); + } + let again = collect(doc.header().unwrap(), back); + assert_eq!(again, doc, "{path}"); + } +} + +#[test] +fn the_writer_writes_what_the_reader_reads() { + let (header, features) = read("tests/data/small.city.jsonl"); + let mut out = Vec::new(); + { + let mut w = SeqWriter::new(&mut out); + w.write_header(&header).unwrap(); + for f in &features { + w.write_feature(f).unwrap(); + } + } + let text = String::from_utf8(out).unwrap(); + assert_eq!(text.lines().count(), features.len() + 1); + let (h2, f2) = { + let mut header = None; + let mut fs = Vec::new(); + for l in SeqReader::new(text.as_bytes()) { + match l.unwrap() { + Line::Header(h) => header = Some(h), + Line::Feature(f) => fs.push(f), + } + } + (header.unwrap(), fs) + }; + assert_eq!(h2, header); + assert_eq!(f2, features); +} + +/// Features from sequences with different transforms keep their real +/// coordinates when collected into one document. +#[test] +fn a_second_header_re_expresses_later_features_in_the_first_transform() { + let header = |scale: f64, tx: f64| { + CityJSON::from_str( + &json!({"type": "CityJSON", "version": "2.0", + "transform": {"scale": [scale, scale, scale], "translate": [tx, 0.0, 0.0]}, + "CityObjects": {}, "vertices": []}) + .to_string(), + ) + .unwrap() + }; + let feature = |id: &str, v: [i64; 3]| { + CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": id, + "CityObjects": {id: {"type": "Building", "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [0]}]}}, + "vertices": [v]}) + .to_string(), + ) + .unwrap() + }; + let mut c = Collector::new(header(0.01, 100.0)); + c.add(feature("a", [100, 200, 300])).unwrap(); //-- real (101, 2, 3) + c.add_header(header(0.001, 200.0)).unwrap(); + c.add(feature("b", [500, 1000, 1500])).unwrap(); //-- real (200.5, 1, 1.5) + let doc = c.finish(); + let real = |id: &str| { + let f = doc + .features(SortingStrategy::AsIs) + .map(|f| f.unwrap()) + .find(|f| f.id == id) + .unwrap(); + let t = &doc.transform; + let v = f.vertices[0]; + [0, 1, 2].map(|k| ((v[k] as f64 * t.scale[k] + t.translate[k]) * 1000.0).round() / 1000.0) + }; + assert_eq!(real("a"), [101.0, 2.0, 3.0]); + assert_eq!(real("b"), [200.5, 1.0, 1.5]); +} + +#[test] +fn a_feature_whose_id_is_not_among_its_objects_cannot_be_collected() { + let (header, _) = read("tests/data/small.city.jsonl"); + let mut c = Collector::new(header); + let bad = CityJSONFeature::from_str( + r#"{"type":"CityJSONFeature","id":"x","CityObjects":{"a":{"type":"Building"}},"vertices":[]}"#, + ) + .unwrap(); + assert!(c.add(bad).is_err()); +} diff --git a/tests/texture_merge.rs b/tests/texture_merge.rs index 0dcd69b..49a5870 100644 --- a/tests/texture_merge.rs +++ b/tests/texture_merge.rs @@ -75,7 +75,7 @@ fn collecting_several_textured_features_keeps_every_uv() { let b = feature("b", &b_uvs, &[[3, 1, 2], [2, 0, 3]]); let c = feature("c", &a_uvs, &[[2, 1, 0]]); - let cj = cjseq_to_cj(header(), vec![a, b, c]); + let cj = cjseq_to_cj(header(), vec![a, b, c]).unwrap(); cj.validate().unwrap(); assert_eq!( @@ -99,10 +99,10 @@ fn collecting_several_textured_features_keeps_every_uv() { fn slicing_a_feature_out_keeps_only_the_uvs_it_uses() { let uvs = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.9, 0.9], [0.8, 0.8]]; //-- uses palette entries 4, 1, 2 only - let cj = cjseq_to_cj(header(), vec![feature("a", &uvs, &[[4, 1, 2]])]); + let cj = cjseq_to_cj(header(), vec![feature("a", &uvs, &[[4, 1, 2]])]).unwrap(); let cj = CityJSON::from_str(&serde_json::to_string(&cj).unwrap()).unwrap(); - let f = cj.get_cjfeature(0).unwrap().unwrap(); + let f = cj.feature("a").unwrap().unwrap(); let app = f.appearance.as_ref().unwrap(); assert_eq!( app.vertices_texture.as_deref(), @@ -120,17 +120,17 @@ fn a_cat_then_collect_round_trip_keeps_every_uv() { feature("a", &uvs, &[[3, 1, 2]]), feature("b", &uvs, &[[0, 2, 1], [1, 3, 0]]), ], - ); + ) + .unwrap(); let expected_a = resolved_uvs(&doc, "a"); let expected_b = resolved_uvs(&doc, "b"); - let mut doc = CityJSON::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); - doc.sort_cjfeatures(cjseq::SortingStrategy::Lexicographical); - let mut features = Vec::new(); - while let Some(f) = doc.get_cjfeature(features.len()).unwrap() { - features.push(f); - } - let back = cjseq_to_cj(doc.get_metadata().unwrap(), features); + let doc = CityJSON::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + let features: Vec = doc + .features(cjseq::SortingStrategy::Lexicographical) + .map(|f| f.unwrap()) + .collect(); + let back = cjseq_to_cj(doc.header().unwrap(), features).unwrap(); back.validate().unwrap(); assert_eq!(resolved_uvs(&back, "a"), expected_a); diff --git a/tests/validate.rs b/tests/validate.rs index 93d7749..eacb98a 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -423,7 +423,7 @@ fn every_fixture_and_everything_made_from_it_validates() { f.validate() .unwrap_or_else(|e| panic!("{name} feature {}: {e}", f.id)); } - vec![cjseq::cjseq_to_cj(header, features)] + vec![cjseq::cjseq_to_cj(header, features).unwrap()] } else if name.ends_with(".json") { vec![CityJSON::from_str(&text).unwrap()] } else { @@ -431,11 +431,10 @@ fn every_fixture_and_everything_made_from_it_validates() { }; for cj in docs { cj.validate().unwrap_or_else(|e| panic!("{name}: {e}")); - let mut i = 0; - while let Some(f) = cj.get_cjfeature(i).unwrap() { + for f in cj.features(cjseq::SortingStrategy::AsIs) { + let f = f.unwrap(); f.validate() .unwrap_or_else(|e| panic!("{name} sliced feature {}: {e}", f.id)); - i += 1; } checked += 1; } @@ -459,7 +458,7 @@ fn the_header_renumbers_its_templates_to_the_sliced_appearance() { } }), ); - let header = cj.get_metadata().unwrap(); + let header = cj.header().unwrap(); header.validate().unwrap(); let v = serde_json::to_value(&header).unwrap(); assert_eq!(v["appearance"]["materials"], json!([{"name": "c"}])); From c6859bc07301bed3d823c161e9c3946198d36a1a Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:39:04 +0200 Subject: [PATCH 46/65] docs: describe what the code does now, not how it got there Doc comments and test comments no longer carry review history (finding numbers, reviewer names, commit hashes, "used to", "pre-rewrite"); each says what the item does, and the schema rule behind it where that is the point. The long CityObjectType/SemanticSurfaceType docs keep the one thing callers trip on, matching the associated consts by value. The const blocks get their own doc comment again. --- src/appearance.rs | 278 +++++++-------------------------------- src/city_object.rs | 141 +++++--------------- src/cityjson.rs | 2 - src/error.rs | 18 +-- src/geometry.rs | 50 +++---- src/metadata.rs | 182 ++++++------------------- src/semantics.rs | 102 ++++---------- tests/depth_rejection.rs | 2 +- tests/roundtrip.rs | 28 +--- 9 files changed, 171 insertions(+), 632 deletions(-) diff --git a/src/appearance.rs b/src/appearance.rs index 6165acf..43ac163 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -83,15 +83,8 @@ impl Indices for TexturedRing { } } -/// Keep an explicit `null` distinct from an absent member. -/// -/// `serde_json` maps a JSON `null` onto the *outer* `None` of a plain -/// `Option>`, which collapses `{"values": null}` and `{}` onto the -/// same value and re-emits the first as the second. Deserializing the inner -/// `Option` and wrapping that in `Some` keeps the two apart. -/// -/// This delegates entirely to the derived `Deserialize` of `T` — it writes no -/// visitor — and it does not touch serialization, which stays fully derived. +/// Keep an explicit `null` distinct from an absent member: a plain +/// `Option>` reads both as `None`. fn present_even_if_null<'de, T, D>(de: D) -> std::result::Result, D::Error> where T: Deserialize<'de>, @@ -100,18 +93,12 @@ where T::deserialize(de).map(Some) } -/// One theme's material assignment for a geometry: either a `value` colouring -/// the whole object, or a depth-typed `values` array with one index per -/// surface. -/// -/// The schema requires exactly one of the two -/// (`oneOf: [{required: ["value"]}, {required: ["values"]}]`) but separately -/// permits `"values": null`. So `values` is a *double* option: the outer level -/// is present-vs-absent, the inner is `null`-vs-array. Without that -/// distinction a schema-valid `{"values": null}` re-emits as `{}`, which -/// satisfies neither branch of the `oneOf` and is rejected by a validator. +/// One theme's material for a geometry: a `value` for the whole geometry, or +/// `values` with one index per primitive. /// -/// `V` is the shape of `values`, fixed by the geometry type: +/// The schema requires exactly one of the two (checked by `validate`) and lets +/// `values` be `null`, so `values` is a double option: absent, `null`, or an +/// array. `V` is the shape of the array, fixed by the geometry type: /// [`PrimitiveValues`], [`crate::SolidValues`] or [`crate::MultiSolidValues`] /// of [`MaterialIndex`]. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -125,9 +112,7 @@ pub struct MaterialReference> { pub values: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, - /// The per-theme material object names `value` and `values` and declares - /// no `additionalProperties: false`, so anything further is legal - /// CityJSON and is kept rather than dropped. + /// Members the schema does not name, kept verbatim. #[serde(flatten)] pub other: Map, } @@ -143,18 +128,14 @@ impl> Indices for MaterialReference } } -/// One theme's texture assignment for a geometry. -/// -/// `values` is optional: the per-theme texture object in -/// `geomprimitives.schema.json` carries no `required` keyword at all (in -/// contrast to `material`, which requires exactly one of `value`/`values`), so -/// a bare `{"theme": {}}` is valid CityJSON. +/// One theme's texture for a geometry: `values` nested as deeply as the +/// boundaries, one [`TexturedRing`] per ring. The schema requires no member, +/// so `values` may be absent. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct TextureReference> { #[serde(skip_serializing_if = "Option::is_none")] pub values: Option, - /// As with [`MaterialReference`]: the per-theme texture object declares no - /// `additionalProperties: false`, so extra members are legal and kept. + /// Members the schema does not name, kept verbatim. #[serde(flatten)] pub other: Map, } @@ -168,31 +149,10 @@ impl> Indices for TextureReference { } } -/// A material or border colour: three numbers, per -/// `appearance.schema.json`'s `"minItems": 3, "maxItems": 3` on -/// `diffuseColor`, `emissiveColor` and `specularColor`. -/// -/// A fixed-size array, not a `Vec`, so the cardinality is checked by serde's -/// derived `Deserialize` rather than by a downstream `if len == 3`. The same -/// technique already types `GeometryInstance::transformation_matrix` as -/// `[f64; 16]`. +/// An RGB colour, three numbers. pub type Color = [f64; 3]; -/// A texture's `borderColor`, whose schema is the one place a CityJSON colour -/// is *not* fixed at three numbers: -/// -/// ```text -/// "borderColor": { -/// "type": "array", "items": {"type": "number"}, -/// "minItems": 3, "maxItems": 4 -/// } -/// ``` -/// -/// Three or four, and nothing else. A `Vec` would accept two or five and -/// leave the check to a consumer that may not make it; the untagged pair of -/// fixed-size arrays makes serde do it. Serialization is derived, and an -/// untagged enum of arrays serializes as the array itself, so `[0, 0, 0, 1]` -/// comes back spelled exactly as it went in. +/// A texture's `borderColor`: three or four numbers, written back as given. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum BorderColor { @@ -202,31 +162,15 @@ pub enum BorderColor { Rgba([f64; 4]), } -/// A texture image's file format: `"type": {"enum": ["PNG", "JPG"]}`. -/// -/// Note the case: this member is spelled upper case where its `wrapMode` and -/// `textureType` siblings are lower. Getting that wrong in a hand-written -/// `match` is precisely the defect this type removes. -/// -/// A closed enumeration in the schema -- unlike `CityObjectType` and -/// `SemanticSurfaceType`, which carry an `Extension(String)` case because -/// CityJSON Extensions may add to those two sets. Nothing in the spec lets an -/// Extension add a texture format, so there is no such case here and an -/// unrecognised spelling is an error. +/// A texture image's format, `"PNG"` or `"JPG"` (upper case, unlike +/// `wrapMode` and `textureType`). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum TextureFormat { PNG, JPG, } -/// How a texture repeats outside `[0, 1]`: -/// `"wrapMode": {"enum": ["none", "wrap", "mirror", "clamp", "border"]}`. -/// -/// All five are lower case. A texture written `"wrapMode": "wrap"` once came -/// back as `"None"` from FlatCityBuf because a downstream `match` expected a -/// different spelling and its `_` arm turned the miss into a default; with the -/// spellings here, the same mistake is a `serde` error at the boundary and a -/// non-exhaustive `match` at every consumer. +/// How a texture repeats outside `[0, 1]`. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum WrapMode { @@ -237,9 +181,7 @@ pub enum WrapMode { Border, } -/// What a texture depicts: -/// `"textureType": {"enum": ["unknown", "specific", "typical"]}`. Lower case, -/// and a closed set. +/// What a texture depicts. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum TextureType { @@ -248,35 +190,9 @@ pub enum TextureType { Typical, } -/// One entry of the document-level `appearance.materials` palette, referred to -/// by index from a [`MaterialReference`]. -/// -/// `appearance.schema.json#/Material`, in full: -/// -/// ```text -/// "Material": { -/// "type": "object", -/// "properties": { -/// "name": {"type": "string"}, -/// "ambientIntensity": {"type": "number"}, -/// "diffuseColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, -/// "emissiveColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, -/// "specularColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, -/// "shininess": {"type": "number"}, -/// "transparency": {"type": "number"}, -/// "isSmooth": {"type": "boolean"} -/// }, -/// "required": ["name"], -/// "additionalProperties": false -/// } -/// ``` -/// -/// So `name` is a plain `String` and everything else an `Option`, and there is -/// no `#[serde(flatten)] other` catch-all: `additionalProperties: false` means -/// an unnamed member is not CityJSON at all. `deny_unknown_fields` says so out -/// loud rather than dropping it, which matters because dropping is silent data -/// loss and, for the all-optional [`TextureObject`] below, an object of -/// nothing but mis-spelled members would otherwise parse as an empty one. +/// One entry of `appearance.materials`, referred to by a [`MaterialIndex`]. +/// `name` is required. The schema closes the object, so a member it does not +/// name is an error. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct MaterialObject { @@ -298,33 +214,13 @@ pub struct MaterialObject { pub is_smooth: Option, } -/// One entry of the document-level `appearance.textures` palette, referred to -/// by the `texture` of each [`TexturedRing`]. -/// -/// `appearance.schema.json#/Texture`, in full: -/// -/// ```text -/// "Texture": { -/// "type": "object", -/// "properties": { -/// "type": {"enum": ["PNG", "JPG"]}, -/// "image": {"type": "string"}, -/// "wrapMode": {"enum": ["none", "wrap", "mirror", "clamp", "border"]}, -/// "textureType": {"enum": ["unknown", "specific", "typical"]}, -/// "borderColor": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 4} -/// }, -/// "additionalProperties": false -/// } -/// ``` -/// -/// There is no `required` keyword, so *every* member is optional and a bare -/// `{}` is valid CityJSON — including `image`, which FlatCityBuf's `.fbs` -/// currently makes mandatory. +/// One entry of `appearance.textures`, referred to by a [`TextureIndex`]. +/// Every member is optional. The schema closes the object, so a member it does +/// not name is an error. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct TextureObject { - /// The image file format. Named `thetype` because `type` is a Rust - /// keyword, as in [`crate::CityJSONFeature`]. + /// The image format (`type` in JSON). #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub thetype: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -337,16 +233,9 @@ pub struct TextureObject { pub border_color: Option, } -/// The document-level appearance library. -/// -/// `cityjson.schema.json#/properties/appearance` names five members and then -/// declares `"additionalProperties": false`, exactly as the [`MaterialObject`] -/// and [`TextureObject`] it contains do — -/// `cityjsonfeature.schema.json#/properties/appearance` carries the same -/// clause verbatim, so the rule holds at both document levels. Hence -/// `deny_unknown_fields` here too: an extra member is not CityJSON, and -/// dropping it silently at this level while rejecting it one level down would -/// be inconsistent as well as lossy. +/// The appearance library of a document or feature: the palettes that +/// material, texture and texture-vertex indices point into. The schema closes +/// the object, so a member it does not name is an error. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct Appearance { @@ -354,11 +243,7 @@ pub struct Appearance { pub materials: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub textures: Option>, - /// The (u, v) texture vertices. The schema pins each one to - /// `minItems: 2, maxItems: 2`, so it is `[f64; 2]` and not `Vec`: - /// a fixed-size array rejects a 1- or 3-element vertex on read and makes - /// one unwritable, exactly as `[f64; 3]` does for a colour and `[f64; 6]` - /// for a geographical extent. + /// The (u, v) texture vertices, referred to by a [`UvIndex`]. #[serde(rename = "vertices-texture")] #[serde(skip_serializing_if = "Option::is_none")] pub vertices_texture: Option>, @@ -410,11 +295,7 @@ impl Appearance { mod tests { use super::*; - /// The per-theme `material` and `texture` objects in - /// `geomprimitives.schema.json` name their members and then stop: neither - /// declares `additionalProperties: false`, unlike the geometry object - /// enclosing them, which does. So an extra member is legal CityJSON, and - /// dropping it is silent data loss. + /// Theme objects are open, so members they do not name are kept. #[test] fn a_material_or_texture_theme_keeps_members_the_schema_does_not_name() { let m: MaterialReference = @@ -434,8 +315,8 @@ mod tests { ); } - /// The catch-all must not disturb what was already pinned: an absent - /// member stays absent, and `{"values": null}` stays distinct from `{}`. + /// An absent member stays absent, and `{"values": null}` stays distinct from + /// `{}`. #[test] fn the_catch_all_does_not_disturb_absent_versus_null() { let empty: MaterialReference = serde_json::from_value(serde_json::json!({})).unwrap(); @@ -489,8 +370,7 @@ mod tests { ); } - /// A `MaterialReference` may carry `value` instead of `values`; the absent - /// member must not be serialized at all (never as `null`, never as `[]`). + /// With `value` instead of `values`, the absent member is not written. #[test] fn material_value_is_a_bare_integer() { let m: MaterialReference = serde_json::from_value(serde_json::json!({"value": 3})).unwrap(); @@ -501,8 +381,7 @@ mod tests { ); } - /// The per-theme texture object has no `required` keyword in the schema, - /// so both of these are valid CityJSON and must parse. + /// A texture theme may have no `values`. #[test] fn a_texture_reference_may_carry_no_values() { let absent: TextureReference = serde_json::from_value(serde_json::json!({})).unwrap(); @@ -513,23 +392,16 @@ mod tests { serde_json::json!({}) ); - //-- Documented leniency, NOT a schema claim: `texture.values` is typed - //-- `"type": "array"`, so `{"values": null}` is *invalid* CityJSON. A - //-- plain `Option` field accepts it anyway, and normalizes it to the - //-- valid `{}` on the way out. Pinned so the normalization is - //-- deliberate rather than incidental. + //-- `{"values": null}` is not valid CityJSON (`values` is an array); + //-- it is accepted and written back as the valid `{}` let null: TextureReference = serde_json::from_value(serde_json::json!({"values": null})).unwrap(); assert_eq!(null.values, None); assert_eq!(serde_json::to_value(&null).unwrap(), serde_json::json!({})); } - /// A `material` theme has three distinct states, and the schema - /// distinguishes all three: absent (invalid on its own, but the `value` - /// sibling may be carrying the assignment), explicitly `null`, and an - /// array. Collapsing `null` onto absent re-emits `{"values": null}` as - /// `{}`, which satisfies neither branch of the schema's - /// `oneOf: [{required: ["value"]}, {required: ["values"]}]`. + /// `values` is absent, `null` or an array, and each is written back as it + /// was. #[test] fn material_values_distinguishes_absent_from_null_from_array() { //-- absent: only `value` carries the assignment @@ -573,19 +445,8 @@ mod tests { //-- the document-level appearance *library*: `materials`/`textures` //------------------------------------------------------------------- - /// The bug that motivated typing this library: a texture written - /// `"wrapMode": "wrap"` came back as `"None"`, because the consumer's - /// `match` expected a different spelling and its `_` arm coerced the - /// mis-spelling to a valid-looking default. - /// - /// `appearance.schema.json` fixes the five spellings exactly, all lower - /// case: - /// ```text - /// "wrapMode": { "enum": ["none", "wrap", "mirror", "clamp", "border"] } - /// ``` - /// so every one of them must survive a round trip byte-identical, and - /// anything else -- including a differently-cased spelling of a real one - /// -- must be rejected here rather than defaulted downstream. + /// The five `wrapMode` spellings round-trip; any other spelling, including a + /// differently-cased one, is an error. #[test] fn every_wrap_mode_round_trips_and_nothing_else_is_accepted() { for mode in ["none", "wrap", "mirror", "clamp", "border"] { @@ -610,10 +471,8 @@ mod tests { } } - /// `"textureType": { "enum": ["unknown", "specific", "typical"] }` and - /// `"type": { "enum": ["PNG", "JPG"] }` -- note the case difference - /// between the two members, which is exactly the kind of detail a string - /// `match` gets wrong. + /// `textureType` is lower case and `type` upper case; anything else is an + /// error. #[test] fn every_texture_type_and_format_round_trips_and_nothing_else_is_accepted() { for t in ["unknown", "specific", "typical"] { @@ -651,9 +510,7 @@ mod tests { } } - /// A texture object carries all five members, or none of them: the - /// `Texture` schema has no `required` keyword at all, so `{}` is valid, - /// and an absent member must not reappear. + /// Every texture member is optional: none is invented, none is lost. #[test] fn a_texture_object_keeps_every_member_it_was_given_and_invents_none() { let full = serde_json::json!({"textures": [{ @@ -676,9 +533,6 @@ mod tests { ); } - /// `"borderColor": {"type": "array", "items": {"type": "number"}, - /// "minItems": 3, "maxItems": 4}` -- three *or* four, never two, never - /// five. This cardinality has already bitten this project once. #[test] fn border_color_is_three_or_four_numbers() { for ok in [ @@ -706,9 +560,6 @@ mod tests { } } - /// The `Material` schema is the mirror image of `Texture`: it *requires* - /// `name`, and pins each of its three colours at exactly three numbers - /// (`"minItems": 3, "maxItems": 3`). #[test] fn a_material_object_requires_a_name_and_three_number_colours() { let full = serde_json::json!({"materials": [{ @@ -753,11 +604,8 @@ mod tests { assert_eq!(serde_json::to_value(&a).unwrap(), bare); } - /// Both `Material` and `Texture` declare `"additionalProperties": false`, - /// unlike the per-theme reference objects above. So an unnamed member is - /// *not* legal CityJSON, and quietly dropping it -- which is what a - /// catch-all-less struct does by default -- would let an invalid file - /// through and lose data on the way. Reject it instead. + /// Materials and textures are closed objects: an unnamed member, or a + /// misspelt one, is an error. #[test] fn material_and_texture_objects_admit_no_members_the_schema_does_not_name() { assert!( @@ -774,8 +622,7 @@ mod tests { .is_err(), "the Texture schema declares additionalProperties: false" ); - //-- a near-miss spelling is an unnamed member, and so is caught by the - //-- same rule: this is the whole point of the exercise + //-- a near-miss spelling is an unnamed member too assert!( serde_json::from_value::( serde_json::json!({"textures": [{"wrapmode": "wrap"}]}) @@ -785,28 +632,7 @@ mod tests { ); } - /// The `appearance` object itself declares `additionalProperties: false`, - /// exactly as the `Material` and `Texture` objects inside it do. - /// `cityjson.schema.json#/properties/appearance`: - /// - /// ```text - /// "appearance": { - /// "type": "object", - /// "properties": { - /// "default-theme-texture": {"type": "string"}, - /// "default-theme-material": {"type": "string"}, - /// "materials": {"type": "array", "items": {"$ref": "appearance.schema.json#/Material"}}, - /// "textures": {"type": "array", "items": {"$ref": "appearance.schema.json#/Texture"}}, - /// "vertices-texture": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}} - /// }, - /// "additionalProperties": false - /// } - /// ``` - /// - /// `cityjsonfeature.schema.json#/properties/appearance` carries the same - /// clause verbatim, so the rule holds at both document levels. Dropping an - /// extra member here while rejecting one a level down, inside `Material`, - /// would be asymmetric: the schema forbids the member either way. + /// The appearance object is closed too. #[test] fn the_appearance_object_admits_no_members_the_schema_does_not_name() { for bad in [ @@ -840,13 +666,6 @@ mod tests { assert_eq!(serde_json::to_value(&a).unwrap(), all_five); } - /// `cityjson.schema.json` pins each texture vertex to - /// `{"type": "array", "items": {"type": "number"}, "minItems": 2, - /// "maxItems": 2}`, so `vertices-texture` is `Vec<[f64; 2]>`: a vertex - /// with any other length is not CityJSON and must not parse, nor be - /// constructible in Rust. This is the same principle `Color` (`[f64; 3]`), - /// `GeographicalExtent` (`[f64; 6]`) and `transformationMatrix` - /// (`[f64; 16]`) already apply. #[test] fn a_texture_vertex_is_exactly_two_numbers() { for bad in [ @@ -869,8 +688,7 @@ mod tests { assert_eq!(serde_json::to_value(&a).unwrap(), good); } - /// An `appearance` with empty arrays in it is valid, and the arrays must - /// stay empty rather than becoming absent (`tests/data/empty_appearance`). + /// Empty palettes stay empty rather than becoming absent. #[test] fn an_empty_appearance_library_stays_empty() { let input = serde_json::json!({"materials": [], "textures": []}); diff --git a/src/city_object.rs b/src/city_object.rs index 867e85d..efb1bbe 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -3,10 +3,8 @@ use crate::metadata::GeographicalExtent; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -/// The known 1st- and 2nd-level City Object types (§ 2 The different City -/// Objects). Every variant is a unit variant and every identifier is already -/// spelled exactly as the spec requires (eg `TINRelief`, `WaterBody`, -/// `CityFurniture`), so each serializes as its bare name. +/// The 1st- and 2nd-level City Object types the spec defines (§ 2), each +/// serialized as its name. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum KnownCityObjectType { Bridge, @@ -44,29 +42,19 @@ pub enum KnownCityObjectType { WaterBody, } -/// The `type` member of a City Object (§ 2 The different City Objects). +/// The `type` of a City Object (§ 2): a known type, or an Extension type, +/// which starts with `+` (eg `"+NoiseCityFurnitureSegment"`, § 8) and is kept +/// verbatim. In JSON it is a string; a string that is neither a known name nor +/// `+` followed by a name is an error, so a misspelt core type (`"Buildng"`) +/// does not pass as an Extension. /// -/// `Known` covers the 1st- and 2nd-level City Object types defined by the -/// CityJSON 2.0.1 spec. Any other value is a CityJSON Extension type, which -/// the spec requires to start with `"+"` (§ 8 Extensions) -- `Extension` -/// carries that string verbatim so a file using an Extension (eg -/// `"+NoiseCityFurnitureSegment"`) round-trips byte for byte. +/// # Matching /// -/// In JSON it is a plain string, converted through `TryFrom`: a -/// string that is neither a known name nor `+` followed by a name is an -/// error, so a misspelt core type (`"Buildng"`) does not pass as an -/// Extension. -/// -/// # Match on the value, not on a reference -/// -/// Each known name is also available flat, as an associated `const` (eg -/// [`CityObjectType::BuildingPart`]) -- see the `impl` block below for the -/// full list. They are `const`s, not variants: this enum has exactly the two -/// variants above. That distinction bites in exactly one place, and it bites -/// silently, so it belongs here rather than in the small print: +/// Each known type is also an associated `const` ([`CityObjectType::BuildingPart`] +/// and so on). Match on a value, not a reference: /// /// ``` -/// # use cjseq::{CityObjectType, KnownCityObjectType}; +/// # use cjseq::CityObjectType; /// # let t = &CityObjectType::Building; /// match *t { /// CityObjectType::Building => { /* .. */ } @@ -75,34 +63,17 @@ pub enum KnownCityObjectType { /// } /// ``` /// -/// Note the `*`. Matching a `const` path against a `&CityObjectType` -- ie -/// `match t` where `t: &CityObjectType`, or `match &t` -- is `E0308` -/// ("mismatched types"), and rustc's fix-it suggests renaming the arm to a -/// fresh binding. That *compiles*, and silently turns the arm into a -/// catch-all that matches everything, because `Building` in pattern position -/// reads as a new irrefutable binding rather than as the constant. The `_` -/// arm is still required after `Extension(ref s)`: the match is not -/// exhaustive over just the known names, since `Known` and `Extension` are -/// the real variants. +/// Without the `*` the match does not compile, and the fix rustc suggests +/// turns the `Building` arm into a binding that matches everything. The `_` arm +/// is needed because the real variants are `Known` and `Extension`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(try_from = "String", into = "String")] pub enum CityObjectType { Known(KnownCityObjectType), - /// Not a core CityJSON type: a CityJSON Extension type, always spelled - /// with a leading `"+"`. + /// An Extension type, with its leading `+`. Extension(String), } -/// Flat, spec-spelling access to each known variant (eg -/// `CityObjectType::BuildingPart`), so call sites read exactly like the -/// CityJSON type name rather than `CityObjectType::Known(KnownCityObjectType::BuildingPart)`. -/// These are associated `const`s, not enum variants -- `CityObjectType` -/// itself has exactly the two variants above -- but a `const` path is usable -/// wherever a variant path would be: in expressions (`x == CityObjectType::Bridge`) -/// and, because the type derives `PartialEq`/`Eq`, in match patterns too -- -/// but only when matching on a *value*. See -/// [`CityObjectType`][CityObjectType#match-on-the-value-not-on-a-reference] -/// for why that matters and how it fails silently otherwise. impl TryFrom for CityObjectType { type Error = crate::error::CjseqError; /// A known name, or an Extension name: `+` followed by at least one @@ -132,6 +103,8 @@ impl From for String { } } +/// Each known type as a constant, spelled as in the spec; see +/// [`CityObjectType`] on matching them. #[allow(non_upper_case_globals)] impl CityObjectType { pub const Bridge: CityObjectType = CityObjectType::Known(KnownCityObjectType::Bridge); @@ -202,39 +175,20 @@ pub struct CityObject { pub geometry: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub children: Option>, - /// § 2.5 CityObjectGroup only: the role of each id in `children`, in the - /// same order. `cityobjects.schema.json` types each item as - /// `["string", "null"]`, so an unspecified role is `null`, not simply - /// absent from the array. + /// CityObjectGroup only (§ 2.5): the role of each id in `children`, in the + /// same order; an unspecified role is `null`. #[serde(skip_serializing_if = "Option::is_none")] pub children_roles: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub parents: Option>, - /// Members the schema does not name, carried verbatim so they survive a - /// round trip. `pub`, like every other `#[serde(flatten)]` catch-all in - /// this crate, so a `CityObject` can be written as a struct literal. + /// Members the schema does not name, kept verbatim. #[serde(flatten)] pub other: Map, } impl CityObject { - /// A City Object of `thetype` carrying nothing else. - /// - /// `other` is deliberately not a constructor parameter: it is the - /// `#[serde(flatten)]` catch-all for members the schema does not name, and - /// a value built in code has none. Callers that need one set the named - /// fields directly, which are all `pub`. - /// - /// This is a convenience, not a necessity: every field is `pub`, so a - /// struct literal works too. It saves writing seven `None`s and getting - /// the `other` sentinel right (see below). - /// - /// `other` is an empty *object*, not `Value::Null`: that is what - /// deserializing a City Object with no unrecognized members produces, and - /// the derived `PartialEq` compares it. A `Null` here would serialize - /// identically but make every constructed object compare unequal to its - /// parsed twin -- silently, and only ever noticed by a test that compares - /// whole objects. + /// A City Object of `thetype` with nothing else; it equals the one parsed + /// from `{"type": ...}`. Every field is `pub`, so set the others directly. pub fn new(thetype: CityObjectType) -> Self { CityObject { thetype, @@ -263,12 +217,8 @@ impl CityObject { mod tests { use super::*; - /// A constructed City Object must equal the one deserializing the - /// equivalent minimal JSON produces. Serializing both and comparing the - /// output is not enough: `other: Value::Null` and `other: Value::Object({})` - /// both serialize to nothing, so only `==` distinguishes them -- and `==` - /// is what a caller round-tripping a document through its own encoder will - /// reach for. + /// A constructed City Object equals the one parsed from the same JSON, which + /// only `==` can tell: an empty `other` and a missing one serialize alike. #[test] fn a_constructed_city_object_equals_its_parsed_twin() { let constructed = CityObject::new(CityObjectType::Building); @@ -306,10 +256,7 @@ mod tests { assert_eq!(t, CityObjectType::BuildingPart); } - /// A known name prefixed with `+` is not the known variant: extensions are - /// spelled with a leading `+` precisely so they never collide with a core - /// type, and ordering (known arm declared first) must not accidentally - /// swallow it either. + /// A known name with a `+` is an Extension type, not the known one. #[test] fn plus_prefixed_known_name_lands_in_extension_not_the_known_variant() { let t: CityObjectType = serde_json::from_value(serde_json::json!("+Building")).unwrap(); @@ -317,25 +264,8 @@ mod tests { assert_ne!(t, CityObjectType::Building); } - /// The stated critical requirement names a specific shape: a `CityObject` - /// (inside a `CityJSONFeature`, since that is how `flatcitybuf`'s - /// `noise_extension` fixture is shaped) whose `type` is an Extension - /// string, where `CityObjectType` sits behind `CityObject`'s - /// `#[serde(flatten)] other` field. The bare-`CityObjectType` tests above - /// don't exercise that flatten path at all, so this pins it at the - /// document level, asserting on the serialized *string* -- not a - /// re-parsed `Value` -- so a change that reorders or reformats output - /// would fail this test even though it would pass a structural check. - /// - /// The extra `"+noiseLevel"` member is deliberate and load-bearing: it is - /// *not* `"attributes"` (that member has its own dedicated, - /// already-typed `CityObject` field and so would never touch `other` at - /// all) but a genuinely unrecognized top-level member, so it is the one - /// that actually exercises `#[serde(flatten)] other: serde_json::Value`. - /// It carries a nested object (not a bare scalar) to prove nested - /// content survives too, and there is exactly one such extra member, - /// because `other`'s `serde_json::Map` has no stable iteration order - /// once it holds two or more keys. + /// An Extension-typed City Object with an extra, nested member, inside a + /// feature, round-trips byte for byte. #[test] fn extension_typed_city_object_roundtrips_byte_for_byte_inside_a_feature() { let input = concat!( @@ -357,8 +287,6 @@ mod tests { ); } - /// Every public type in this crate derives `PartialEq`; `CityObject` was - /// missing it. #[test] fn city_object_with_equal_fields_is_equal() { let input = serde_json::json!({"type": "Building"}); @@ -370,10 +298,7 @@ mod tests { assert_ne!(a, c); } - /// `cityobjects.schema.json`'s `_AbstractCityObject.geographicalExtent` - /// has `minItems`/`maxItems` 6 -- a fixed-length array, not an arbitrary - /// `Vec` -- so a 5- or 7-element extent must fail to deserialize - /// rather than being silently accepted. + /// `geographicalExtent` has exactly six numbers. #[test] fn city_object_geographical_extent_rejects_the_wrong_length() { let too_short = serde_json::json!({ @@ -403,11 +328,7 @@ mod tests { assert_eq!(serde_json::to_value(&co).unwrap(), input); } - /// § 2.5 CityObjectGroup: a group `CityObject` may carry a `children_roles` - /// member, one entry per id in `children`, describing that child's role - /// in the group. `cityobjects.schema.json`'s `CityObjectGroup` types each - /// item as `["string", "null"]`, so an unspecified role is `null`, not an - /// absent array entry -- this must round-trip too. + /// `children_roles` round-trips, `null` roles included. #[test] fn city_object_group_children_roles_round_trips_including_null_entries() { let input = serde_json::json!({ @@ -423,9 +344,7 @@ mod tests { assert_eq!(serde_json::to_value(&co).unwrap(), input); } - /// Every known CityObjectType name round-trips through its unit variant, - /// not through Extension -- a typo'd rename would otherwise silently fall - /// through to Extension and no other test would catch it. + /// Every known name parses to its own variant, not to `Extension`. #[test] fn every_known_city_object_type_round_trips_as_its_unit_variant() { let known: &[(&str, CityObjectType)] = &[ diff --git a/src/cityjson.rs b/src/cityjson.rs index cff0c13..01d4b53 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -555,8 +555,6 @@ pub enum CityJSONFeatureType { mod tests { use super::*; - /// Every public type in this crate derives `PartialEq`; `CityJSON` and - /// `CityJSONFeature` were missing it. #[test] fn cityjson_feature_with_equal_fields_is_equal() { let input = serde_json::json!({ diff --git a/src/error.rs b/src/error.rs index 0916f87..eb09155 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,18 +2,13 @@ use crate::geometry::GeometryType; use std::fmt; /// The crate's error type. -/// -/// Implemented by hand: this crate does not hand-write serde impls, and it -/// does not take a derive dependency for its error type either. #[derive(Debug)] pub enum CjseqError { /// A value was not the JSON the type expected, and nothing more specific /// could be said about why. Json(serde_json::Error), - /// A geometry's `boundaries` were nested to a depth its `type` does not - /// allow. serde reports this as "invalid type: integer, expected a - /// sequence", which names neither the geometry nor the depth it wanted; - /// this variant names both. + /// A geometry's `boundaries` are nested to a depth its `type` does not + /// allow; see [`crate::Geometry::from_json_value`]. GeometryDepth { geometry_type: GeometryType, /// The nesting depth the geometry type requires. @@ -23,13 +18,10 @@ pub enum CjseqError { }, /// An I/O operation failed, eg opening or reading a CityJSON(Seq) file. Io(std::io::Error), - /// A document parsed as valid JSON, and every typed field on it parsed - /// fine, but it still fails a structural rule this crate enforces beyond - /// what serde alone can express -- eg its `type` is not `"CityJSON"`, or - /// its `version` is not one this crate supports. + /// A rule beyond what the types express, eg a material theme with both + /// `value` and `values`, or a malformed LoD or texture ring. Validation(String), - /// A City Object names, as one of its children, an id that is not in the - /// document. + /// A City Object (or a feature's `id`) refers to an id that is not there. DanglingReference { /// The City Object holding the reference. from: String, diff --git a/src/geometry.rs b/src/geometry.rs index bbda5ad..4d2e27b 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -131,19 +131,15 @@ fn nesting_depth(v: &Value) -> usize { } } -/// A CityJSON geometry object. +/// A CityJSON geometry object (§ 3). /// -/// The nesting depth of `boundaries` is part of the type: a `MultiSurface` -/// whose boundaries are nested one level too deep does not deserialize, where -/// previously it decoded happily into an untyped `serde_json::Value` and left -/// every consumer to guess the depth. -/// -/// So is the nesting depth of semantics, material and texture `values`: each -/// variant names its own value types, so values nested to the wrong depth for -/// their geometry do not deserialize either. +/// The nesting depth of `boundaries` is part of the type, and so is the depth +/// of semantics, material and texture `values`: each variant names its own +/// types, so boundaries or values nested to the wrong depth for the geometry +/// type do not parse. /// /// Each variant has exactly the members the schema names for it, and the -/// schema closes every geometry object, so any other member is rejected. A +/// schema closes every geometry object, so any other member is an error. A /// MultiPoint or MultiLineString carries no material or texture. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(tag = "type", deny_unknown_fields)] @@ -220,19 +216,13 @@ pub enum Geometry { } impl Geometry { - /// Parse a geometry, reporting a wrong `boundaries` depth in terms of the - /// geometry type rather than in terms of serde's innards. - /// - /// `serde_json::from_value::` works just as well and is what - /// `#[derive(Deserialize)]` gives every containing type; the only thing - /// this adds is a readable error. Deserializing a deeply nested `Vec` - /// otherwise fails with "invalid type: integer, expected a sequence", - /// which says neither which geometry was at fault nor what depth it - /// wanted. + /// Parse a geometry, reporting a wrong `boundaries` depth as a + /// [`CjseqError::GeometryDepth`] that names the geometry type and both + /// depths, where serde alone says "invalid type: integer, expected a + /// sequence". pub fn from_json_value(v: Value) -> Result { - //-- `&Value` is itself a Deserializer, so the happy path borrows `v` - //-- instead of deep-copying every boundary just to keep `v` alive for - //-- the error branch below. + //-- `&Value` is a Deserializer, so this borrows `v` rather than + //-- copying it let err = match Geometry::deserialize(&v) { Ok(g) => return Ok(g), Err(e) => e, @@ -515,9 +505,8 @@ mod tests { assert_eq!(map.len(), 3); } - /// The same first-encounter renumbering, but at the deepest traversal the - /// enum has, so the flattening order of the 5-level variants is pinned too. - /// Expected values produced by the pre-rewrite implementation at a97bc2d. + /// The same renumbering at the deepest nesting, which pins the order the + /// 5-level variants are walked in. #[test] fn update_boundaries_compacts_indices_at_max_depth() { let mut g: Geometry = serde_json::from_value(serde_json::json!({ @@ -589,10 +578,8 @@ mod tests { ); } - /// `update_material` renumbers `material.values` at the depth the geometry - /// type implies, and `null` holes (surfaces with no material) must survive - /// untouched. Expected values produced by the pre-rewrite implementation at - /// a97bc2d on this exact input. + /// `update_material` renumbers `values` at the depth the geometry type + /// implies, and leaves `null` entries alone. #[test] fn update_material_preserves_null_holes() { let mut g: Geometry = serde_json::from_value(serde_json::json!({ @@ -604,7 +591,6 @@ mod tests { let mut m_oldnew: HashMap = HashMap::new(); g.update_material(&mut m_oldnew); - //-- as above: typed `values`, oracle expectations unchanged. let values = serde_json::to_value(&g).unwrap()["material"]["irradiation"]["values"].clone(); assert_eq!( values, @@ -617,9 +603,7 @@ mod tests { assert_eq!(m, vec![(0, 0), (2, 1)]); } - /// A raw serde error on a deeply nested `Vec` reads "invalid type: - /// integer, expected a sequence", which names neither the geometry nor the - /// depth it wanted. `from_json_value` must say both. + /// `from_json_value` names the geometry and both depths. #[test] fn wrong_depth_reports_which_geometry_and_which_depth() { let err = Geometry::from_json_value(serde_json::json!({ diff --git a/src/metadata.rs b/src/metadata.rs index aff0be1..be1e5d6 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -4,6 +4,8 @@ use serde_json::{Map, Value}; const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs"; +/// The `transform` member (§ 4.1): a vertex's real coordinates are its integer +/// coordinates times `scale`, plus `translate`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct Transform { @@ -26,23 +28,14 @@ impl Transform { } } +/// `[minx, miny, minz, maxx, maxy, maxz]`. pub type GeographicalExtent = [f64; 6]; -/// The `address` member of a [`PointOfContact`] (§ 5.3 pointOfContact). +/// The `address` member of a [`PointOfContact`] (§ 5.3). /// -/// Deliberately free-form, and that is what the normative schema asks for: -/// `metadata.schema.json` types `address` as a bare `{"type": "object"}` -- -/// no `properties`, no `required`, no `additionalProperties`. The prose says -/// why in as many words: "any properties can be used, to accommodate the -/// different ways addresses are structured in different countries" (§ 5.3). -/// -/// This type used to declare five *required* members (`thoroughfareNumber` -/// typed `i64`, `thoroughfareName`, `locality`, `postalCode`, `country`) and -/// so rejected nearly every legal address -- including both of the spec's own -/// examples, which spell the number as a *string* (`"134"`, `"24"`) and the -/// postcode as `postcode`, not `postalCode`. Structure the model does not -/// actually know is worse than no structure: it turns valid input into a -/// parse error. +/// Free-form: the schema types it as a bare object, since addresses are +/// structured differently from country to country. Its members are kept as +/// they are. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Address { #[serde(flatten)] @@ -51,10 +44,8 @@ pub struct Address { /// The `pointOfContact` member of [`Metadata`] (§ 5.3). /// -/// `contactName` and `emailAddress` are the schema's only two `required` -/// members; everything else is optional. As with [`Metadata`], the schema's -/// `contactDetails` definition declares no `additionalProperties: false`, so -/// `other` keeps anything further rather than dropping it. +/// `contactName` and `emailAddress` are required; the rest is optional. +/// Members the schema does not name are kept in `other`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PointOfContact { #[serde(rename = "contactName")] @@ -70,10 +61,7 @@ pub struct PointOfContact { pub email_address: String, #[serde(skip_serializing_if = "Option::is_none")] pub website: Option, - /// The name of the organisation, "to be used if the `contactName` is the - /// name of a person" (§ 5.3). Named by both the prose and the schema's - /// `contactDetails.properties`; it had no field here at all, so it was - /// silently dropped. + /// The organisation, when `contact_name` names a person. #[serde(skip_serializing_if = "Option::is_none")] pub organization: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -118,52 +106,16 @@ pub enum ContactRole { Stakeholder, } -/// A reference system following the OGC Name Type Specification. -/// -/// The format follows: `http://www.opengis.net/def/crs/{authority}/{version}/{code}` -/// where: -/// - `{authority}` designates the authority responsible for the definition of this CRS -/// (usually "EPSG" or "OGC") -/// - `{version}` designates the specific version of the CRS -/// (use "0" if there is no version) -/// - `{code}` is the identifier for the specific coordinate reference system -/// -/// That three-element form is what OGC names and what every real file uses, -/// but it is **not** what makes a `referenceSystem` legal. The normative -/// constraint in `metadata.schema.json` is a prefix pattern and nothing else: +/// A `referenceSystem` URL, `https://www.opengis.net/def/crs/{authority}/{version}/{code}` +/// in the OGC form every real file uses. /// -/// ```text -/// "referenceSystem": { -/// "type": "string", -/// "pattern": "^(http|https)://www.opengis.net/def/crs/" -/// } -/// ``` +/// The schema only requires the prefix `(http|https)://www.opengis.net/def/crs/`, +/// so the path after it is kept as `segments`, verbatim: a path with more or +/// fewer than three elements, or a trailing slash, round-trips unchanged. The +/// OGC reading is [`ReferenceSystem::authority`], [`ReferenceSystem::version`] +/// and [`ReferenceSystem::code`], each `None` when the path is too short. /// -/// So `.../crs/EPSG/0/4326/extra` and `.../crs/EPSG/0/7415/` are both legal -/// CityJSON, and an earlier version of this type rejected them outright by -/// demanding exactly three `/`-separated elements. That is the same -/// rejects-legal defect as the old [`Address`], and the fix is the same: -/// stop inventing structure the format does not mandate. -/// -/// Hence `segments` rather than three named fields. It holds the path -/// elements after `.../def/crs/` verbatim, so [`ReferenceSystem::to_url`] can -/// rebuild *any* legal URL byte for byte -- including a trailing slash, which -/// is simply a final empty element. The OGC reading is still available, as -/// [`ReferenceSystem::authority`], [`ReferenceSystem::version`] and -/// [`ReferenceSystem::code`], which return `None` when the URL does not have -/// that shape. Keeping the three as plain fields *alongside* the verbatim -/// path was the obvious alternative and is a trap: the two representations -/// can disagree, and then `to_url` silently contradicts the fields. -/// -/// In JSON this is a single string, not an object, so the derive needs -/// telling how to get between the two. `#[serde(try_from/into)]` does exactly -/// that and nothing else: `Serialize` and `Deserialize` stay **derived**, and -/// the conversion is ordinary `TryFrom`/`From` code that serde calls on -/// either side of a plain `String`. This replaces a hand-written `Serialize` -/// and `Deserialize` pair, which violated the crate's derived-only rule -- -/// hand-written impls are the one place a field can be silently dropped or -/// reshaped without any type-level trace, which is the whole reason for the -/// rule. +/// In JSON it is a string, converted through `TryFrom`/`Into`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(try_from = "String", into = "String")] pub struct ReferenceSystem { @@ -199,27 +151,15 @@ impl ReferenceSystem { self.segments.get(2).map(String::as_str) } - /// Rebuild the URL. Exact for every input [`ReferenceSystem::from_url`] - /// accepts, whatever its element count -- `segments` is stored verbatim, - /// so there is nothing to reconstruct incorrectly. + /// The URL, exactly as [`ReferenceSystem::from_url`] read it. pub fn to_url(&self) -> String { format!("{}/{}", self.base_url, self.segments.join("/")) } - /// Parse a `referenceSystem` string. - /// - /// The only thing checked is the prefix, because the prefix is the only - /// thing `metadata.schema.json` constrains. The OGC three-element form is - /// a convention this honours (see [`ReferenceSystem::authority`] and - /// friends) but does not enforce, so a longer or shorter path is carried - /// through rather than rejected. + /// Parse a `referenceSystem` string. Only the prefix the schema requires + /// is checked. pub fn from_url(url: &str) -> crate::error::Result { - //-- the schema's pattern is `^(http|https)://www.opengis.net/def/crs/`, - //-- and the `^` is load-bearing: it anchors the scheme. Searching for - //-- the host anywhere in the string would accept `ftp://www.opengis - //-- .net/def/crs/x`, or any URL that merely embeds the host in a query - //-- string, neither of which the pattern matches. So: `starts_with`, - //-- once per permitted scheme. + //-- the pattern is anchored: the URL must start with it const PREFIXES: [&str; 2] = [ "http://www.opengis.net/def/crs/", "https://www.opengis.net/def/crs/", @@ -239,9 +179,8 @@ impl ReferenceSystem { } } -/// The two conversions `#[serde(try_from = "String", into = "String")]` calls. -/// They carry no serde logic of their own -- they are the same `to_url` and -/// `from_url` the rest of the crate uses. +/// The conversions serde uses: [`ReferenceSystem::to_url`] and +/// [`ReferenceSystem::from_url`]. impl From for String { fn from(rs: ReferenceSystem) -> String { rs.to_url() @@ -255,21 +194,11 @@ impl TryFrom for ReferenceSystem { } } -/// The `metadata` member of a [`crate::CityJSON`] object (§ 5 Metadata). -/// -/// The six members below are the ones the core spec names. They are *not* the -/// only ones a valid document may carry: `metadata.schema.json`'s `metadata` -/// object declares those six under `properties` and then stops -- there is no -/// `additionalProperties: false`, unlike (say) `transform` or -/// `geometry-templates`, which both do declare it. So any further member is -/// legal CityJSON, and the spec itself points at the MetadataExtended -/// Extension as the place to put more. +/// The `metadata` member of a [`crate::CityJSON`] (§ 5). /// -/// Hence `other`: without it every such member is silently dropped on the way -/// through. Real files rely on this -- every 3DBAG dataset carries -/// `fullMetadataUrl` and `version` in its metadata (see -/// `tests/data/small.city.jsonl`), and before this field existed both simply -/// vanished. +/// The fields are the six members the spec names. The schema leaves the object +/// open, and real files use that (3DBAG files carry `fullMetadataUrl` and +/// `version`), so any other member is kept in `other`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Metadata { #[serde(rename = "geographicalExtent")] @@ -297,10 +226,6 @@ pub struct Metadata { mod tests { use super::*; - /// Every public type in this crate derives `PartialEq` so values can be - /// compared directly, not just formatted and eyeballed via `Debug`. - /// `Transform`, `Address`, `PointOfContact`, `ReferenceSystem`, and - /// `Metadata` were missing it; this pins that they now support `==`. #[test] fn transform_with_equal_fields_is_equal() { let a = Transform::new(); @@ -343,12 +268,8 @@ mod tests { assert_eq!(a, b); } - /// The `pointOfContact` example in § 5.3 of the spec, verbatim. cjseq - /// rejected it outright -- `"thoroughfareNumber": "24"` is a string and - /// `Address.thoroughfare_number` was an `i64` -- which is the plainest - /// possible demonstration that the old `Address` invented structure the - /// spec does not have. `metadata.schema.json` types `address` as bare - /// `{"type": "object"}`. + /// The `pointOfContact` example of § 5.3, verbatim: its `thoroughfareNumber` + /// is a string. #[test] fn the_specs_own_point_of_contact_example_parses_and_round_trips() { let input = serde_json::json!({ @@ -390,8 +311,6 @@ mod tests { } } - /// `organization` is named by both § 5.3 and the schema's - /// `contactDetails.properties`, and had no field here, so it vanished. #[test] fn point_of_contact_keeps_organization_and_unnamed_members() { let input = serde_json::json!({ @@ -408,8 +327,7 @@ mod tests { assert_eq!(serde_json::to_value(&poc).unwrap(), input); } - /// The two members the schema marks `required` are still required: this - /// change relaxes `address`, not the contact itself. + /// `contactName` and `emailAddress` are required. #[test] fn point_of_contact_still_requires_the_two_members_the_schema_requires() { assert!(serde_json::from_value::( @@ -431,11 +349,7 @@ mod tests { assert_ne!(a, c); } - /// `ReferenceSystem` is a struct in Rust and a bare string in JSON. - /// Swapping its hand-written `Serialize`/`Deserialize` for - /// `#[serde(try_from/into)]` must not change one byte of that: a derived - /// `Serialize` on a four-field struct would otherwise emit an *object*, - /// which is what this pins. + /// A struct in Rust, a string in JSON. #[test] fn reference_system_is_a_string_in_json_not_an_object() { let url = "https://www.opengis.net/def/crs/EPSG/0/7415"; @@ -446,19 +360,12 @@ mod tests { assert_eq!(serde_json::to_value(&rs).unwrap(), serde_json::json!(url)); } - /// `metadata.schema.json` constrains `referenceSystem` by a *prefix - /// pattern* and nothing else, so a path with more or fewer than three - /// elements is legal CityJSON. cjseq demanded exactly three and rejected - /// the rest outright -- the same rejects-legal defect as the old - /// `Address`, and caught by the same review (codex finding A2, whose - /// `.../EPSG/0/4326/extra` example is the first case below). - /// - /// Each must survive byte for byte: `to_url` rebuilds from the verbatim - /// path, so a trailing slash (a final empty element) comes back too. + /// The schema constrains `referenceSystem` by its prefix only, so a path + /// with more or fewer than three elements is legal, and each must round-trip + /// byte for byte, a trailing slash included. #[test] fn any_url_matching_the_schemas_prefix_pattern_is_accepted_and_round_trips() { for url in [ - //-- codex's own example "https://www.opengis.net/def/crs/EPSG/0/4326/extra", //-- a trailing slash: a fourth, empty element "https://www.opengis.net/def/crs/EPSG/0/7415/", @@ -499,8 +406,7 @@ mod tests { assert_eq!(long.segments.len(), 4); } - /// Relaxing the path must not relax the prefix: the pattern is the one - /// thing the schema does constrain. + /// The prefix is still required. #[test] fn a_url_failing_the_prefix_pattern_is_still_rejected() { for bad in [ @@ -509,9 +415,7 @@ mod tests { //-- the prefix pattern ends in a slash; `.../crs` alone lacks it "https://www.opengis.net/def/crs", "", - //-- the pattern is anchored, and names exactly two schemes. A - //-- substring search for the host would have let all three of - //-- these through with a nonsense `base_url`. + //-- the pattern is anchored, and names exactly two schemes "ftp://www.opengis.net/def/crs/EPSG/0/7415", "www.opengis.net/def/crs/EPSG/0/7415", "https://example.com/?redirect=https://www.opengis.net/def/crs/EPSG/0/7415", @@ -523,8 +427,6 @@ mod tests { } } - /// And inside the metadata object it composes, which is the only place it - /// is ever actually serialized. #[test] fn reference_system_round_trips_inside_metadata() { let input = serde_json::json!({ @@ -534,8 +436,6 @@ mod tests { assert_eq!(serde_json::to_value(&m).unwrap(), input); } - /// A string that is not a CRS URL is still an error rather than a silent - /// default -- the conversion is `TryFrom`, and serde surfaces its error. #[test] fn a_non_crs_reference_system_is_rejected() { assert!(serde_json::from_value::(serde_json::json!("not-a-url")).is_err()); @@ -556,12 +456,8 @@ mod tests { assert_eq!(a, b); } - /// `metadata.schema.json`'s `metadata` object lists six properties and - /// declares no `additionalProperties: false`, so any further member is - /// legal CityJSON. Real 3DBAG files carry `fullMetadataUrl` and - /// `version`; before `Metadata.other` existed, both were silently dropped - /// on the way through, which `tests/roundtrip.rs` caught on - /// `tests/data/small.city.jsonl`. + /// Members the spec does not name, such as 3DBAG's `fullMetadataUrl`, are + /// kept. #[test] fn metadata_keeps_members_the_core_spec_does_not_name() { let input = serde_json::json!({ diff --git a/src/semantics.rs b/src/semantics.rs index b831de7..efaa173 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -4,10 +4,8 @@ use crate::index::{MultiSolidValues, SolidValues}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -/// The known surface types the spec assigns to Building-, WaterBody-, and -/// Transportation-family City Objects (§ 3.3 Semantics of geometric -/// primitives). Every variant is a unit variant already spelled exactly as -/// the spec requires, so each serializes as its bare name. +/// The semantic surface types the spec defines (§ 3.3), each serialized as +/// its name. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum KnownSemanticSurfaceType { RoofSurface, @@ -30,24 +28,15 @@ pub enum KnownSemanticSurfaceType { TransportationHole, } -/// The `type` member of a Semantic Object (§ 3.3 Semantics of geometric -/// primitives). +/// The `type` of a semantic surface (§ 3.3): a known type, or an Extension +/// type, which starts with `+` (eg `"+ThermalSurface"`, § 8.5) and is kept +/// verbatim. In JSON it is a string; a string that is neither a known name nor +/// `+` followed by a name is an error. /// -/// `Known` covers the surface types above. Any other value is a CityJSON -/// Extension semantic surface, which the spec requires to start with `"+"` -/// (eg `"+ThermalSurface"`, § 8.5); `Extension` carries that string verbatim -/// so it round-trips byte for byte. +/// # Matching /// -/// As with [`crate::CityObjectType`], it is a plain string in JSON, and a -/// string that is neither a known name nor `+` followed by a name is an -/// error. -/// -/// # Match on the value, not on a reference -/// -/// Each known name is also available flat, as an associated `const` (eg -/// [`SemanticSurfaceType::RoofSurface`]) -- see the `impl` block below for -/// the full list. They are `const`s, not variants, and that distinction bites -/// exactly once, silently: +/// Each known type is also an associated `const` ([`SemanticSurfaceType::RoofSurface`] +/// and so on). Match on a value, not a reference: /// /// ``` /// # use cjseq::SemanticSurfaceType; @@ -59,28 +48,17 @@ pub enum KnownSemanticSurfaceType { /// } /// ``` /// -/// Note the `*`. `match t` where `t: &SemanticSurfaceType`, or `match &t`, is -/// `E0308` ("mismatched types"), and rustc's fix-it suggests renaming the arm -/// to a fresh binding -- which compiles, and silently turns that arm into a -/// catch-all matching everything, because `RoofSurface` in pattern position -/// reads as a new irrefutable binding rather than as the constant. The `_` -/// arm is still required: `Known` and `Extension` are the real variants, so -/// the known names alone are not exhaustive. +/// Without the `*` the match does not compile, and the fix rustc suggests +/// turns the `RoofSurface` arm into a binding that matches everything. The `_` +/// arm is needed because the real variants are `Known` and `Extension`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(try_from = "String", into = "String")] pub enum SemanticSurfaceType { Known(KnownSemanticSurfaceType), - /// Not a core CityJSON semantic surface type: a CityJSON Extension - /// surface type, always spelled with a leading `"+"`. + /// An Extension type, with its leading `+`. Extension(String), } -/// Flat, spec-spelling access to each known variant (eg -/// `SemanticSurfaceType::RoofSurface`); see [`crate::CityObjectType`]'s -/// equivalent block for why these are associated `const`s rather than enum -/// variants -- and, on -/// [`SemanticSurfaceType`][SemanticSurfaceType#match-on-the-value-not-on-a-reference], -/// for why they must be matched on a value rather than on a reference. impl TryFrom for SemanticSurfaceType { type Error = crate::error::CjseqError; /// A known name, or an Extension name: `+` followed by at least one @@ -111,6 +89,8 @@ impl From for String { } } +/// Each known type as a constant, spelled as in the spec; see +/// [`SemanticSurfaceType`] on matching them. #[allow(non_upper_case_globals)] impl SemanticSurfaceType { pub const RoofSurface: SemanticSurfaceType = @@ -206,11 +186,7 @@ impl> Indices for Semantics { mod tests { use super::*; - /// The `semantics` object names `surfaces` and `values` and declares no - /// `additionalProperties: false` -- unlike the geometry object that - /// contains it, which does -- so a further member is legal CityJSON and - /// must not vanish. `SemanticsSurface` already kept its extras; the - /// enclosing `Semantics` did not. + /// The `semantics` object is open, so members it does not name are kept. #[test] fn semantics_keeps_members_the_schema_does_not_name() { let input = serde_json::json!({ @@ -255,20 +231,13 @@ mod tests { "surfaces": [{"type": "RoofSurface"}], "values": [null, 0] })) .unwrap(); - // Must be `null`, never `[]` -- see finding #7. + //-- `null`, never `[]` assert_eq!( serde_json::to_value(&s).unwrap()["values"], serde_json::json!([null, 0]) ); } - /// develop's 6917631 added `Eq` (alongside `PartialEq`) to - /// `SemanticsSurface`/`Semantics`. `SemanticsSurface.other` is - /// `HashMap`, and this serde_json version - /// (1.0.151) implements `Eq` for both `Number` (`impl Eq for N {}`, - /// `src/number.rs:50` -- sound because a JSON number can never be NaN) - /// and `Value` (`#[derive(Clone, Eq, PartialEq, Hash)]`, - /// `src/value/mod.rs:115`), so nothing here actually blocks `Eq`. #[test] fn semantics_surface_and_semantics_implement_eq() { fn assert_impls_eq() {} @@ -276,8 +245,7 @@ mod tests { assert_impls_eq::(); } - /// A semantic surface may carry a hierarchy and arbitrary extra members; - /// both must survive a round-trip byte for byte. + /// A semantic surface's hierarchy and extra members round-trip. #[test] fn surface_extras_and_hierarchy_round_trip() { let input = serde_json::json!({ @@ -298,13 +266,7 @@ mod tests { assert_eq!(serde_json::to_value(&s).unwrap(), input); } - /// develop's aff25a1 made `SemanticsSurface.other` an - /// `Option` specifically so a surface with no extra - /// members serializes without an empty placeholder. Task 3 reaches the - /// same outcome a different way: `other` is a plain (non-`Option`) - /// `HashMap`, and `#[serde(flatten)]` on an empty map - /// contributes zero keys, so a surface with nothing extra round-trips - /// with no spurious members either way -- no `Option` wrapper needed. + /// With no extra members, none are written. #[test] fn surface_with_no_extras_round_trips_without_spurious_members() { let input = serde_json::json!({ @@ -316,9 +278,8 @@ mod tests { assert_eq!(serde_json::to_value(&s).unwrap(), input); } - /// `values` is required by the schema but may be `null`. It must parse, - /// and it must be written back out as `null` — dropping the member - /// entirely would violate `"required": ["surfaces", "values"]`. + /// `values` is required but may be `null`, so a `null` is written back, not + /// dropped. #[test] fn a_null_semantics_values_member_round_trips() { let input = serde_json::json!({ @@ -330,9 +291,6 @@ mod tests { assert_eq!(serde_json::to_value(&s).unwrap(), input); } - /// Extension semantic surfaces (`"+ThermalSurface"`, `"+PatioDoor"`, ...) - /// must survive a round trip with their leading `+` intact -- mirrors the - /// `CityObjectType` extension-round-trip requirement, but for surfaces. #[test] fn extension_semantic_surface_type_roundtrips_with_its_plus() { let t: SemanticSurfaceType = @@ -352,19 +310,8 @@ mod tests { assert_ne!(t, SemanticSurfaceType::RoofSurface); } - /// Mirrors `city_object::tests::extension_typed_city_object_roundtrips_byte_for_byte_inside_a_feature`: - /// the bare-`SemanticSurfaceType` tests above never exercise the flatten - /// path an extension surface actually sits behind in practice -- a - /// `SemanticsSurface` nested inside a real `Geometry`'s `semantics` - /// member. Asserts on the serialized *string*, not a re-parsed `Value`, - /// so a change to `other`/flatten handling would fail this even with an - /// otherwise-green suite. - /// - /// `"azimuth"` (lifted from the spec's own `"+ThermalSurface"` example, - /// § 8.5) is the one extra, genuinely unrecognized member landing in - /// `#[serde(flatten)] other: HashMap`; kept to exactly - /// one key since that `HashMap` has no stable iteration order once it - /// holds two or more. + /// An Extension surface with an extra member, inside a geometry, round-trips + /// byte for byte. #[test] fn extension_typed_semantics_surface_roundtrips_byte_for_byte_inside_a_geometry() { let input = concat!( @@ -380,8 +327,7 @@ mod tests { ); } - /// Every known SemanticSurfaceType name round-trips through its own unit - /// variant, not through Extension. + /// Every known name parses to its own variant, not to `Extension`. #[test] fn every_known_semantic_surface_type_round_trips_as_its_unit_variant() { let known: &[(&str, SemanticSurfaceType)] = &[ diff --git a/tests/depth_rejection.rs b/tests/depth_rejection.rs index 0d0e7e5..315294d 100644 --- a/tests/depth_rejection.rs +++ b/tests/depth_rejection.rs @@ -15,7 +15,7 @@ fn multisurface_accepts_three_levels() { #[test] fn multisurface_rejects_solid_depth() { - // One level too deep for a MultiSurface. Previously decoded happily. + //-- one level too deep for a MultiSurface let r: Result = serde_json::from_value(serde_json::json!({ "type": "MultiSurface", "lod": "2", "boundaries": [[[[0, 3, 2, 1]]]] diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 5c2bfa4..b494377 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,18 +1,8 @@ -//! Real-file round trips. +//! Real-file round trips: every fixture is parsed, serialized and compared +//! with its input, which catches any member the model drops. //! -//! Every other test in this crate is synthetic: hand-built JSON literals -//! written from the spec. These are the first tests that put the typed model -//! in front of files nobody wrote for it. The shape matters: a reader -//! compared against another reader's output can agree on the wrong answer -//! (that is exactly how the two bugs motivating this rewrite stayed -//! invisible), but a parse -> serialize -> compare cycle cannot hide a -//! silently dropped member. -//! -//! Comparison is on parsed `serde_json::Value`s, not on strings: several -//! members are backed by `HashMap` and have no stable serialization order, so -//! a byte comparison would fail for reasons that are not data loss. `Value` -//! equality is order-insensitive for object members and exact for everything -//! else, which is the property this test wants. +//! Comparison is on parsed `serde_json::Value`s, not on strings, so that +//! number spelling (`0` vs `0.0`) is not mistaken for data loss. use cjseq::{CityJSON, CityJSONFeature}; use serde_json::{Number, Value}; @@ -69,13 +59,11 @@ fn seq_fixtures() -> Vec { //-- names at any depth (verified with an `object_pairs_hook` that //-- reports them). It is five structurally identical Building //-- features whose `grp` *attribute value* repeats -- a duplicate - //-- grouping case, not a duplicate-key case. It was briefly excluded - //-- on the strength of its filename; that was wrong. + //-- grouping case, not a duplicate-key case. "tests/data/duplicate_keys.city.jsonl", "tests/data/inferable_types.city.jsonl", "tests/data/long_strings.city.jsonl", "tests/data/single_feature.city.jsonl", - //-- already committed to this repo before this test existed "data/3dbag_b2.city.jsonl", ] .iter() @@ -83,10 +71,8 @@ fn seq_fixtures() -> Vec { .collect() } -/// Fixtures in `tests/data/` deliberately left out of the round trip, each -/// with the reason it cannot be round-tripped. Empty, and it should stay that -/// way: a fixture that cannot survive a round trip is a finding, not a -/// housekeeping detail. +/// Fixtures in `tests/data/` left out of the round trip, each with the +/// reason. Empty, and it should stay that way. const EXCLUDED: &[(&str, &str)] = &[]; /// Whole-document CityJSON fixtures (not a sequence). From 5ee9448d7652dae1a92e4b2d04ab415676b2adef Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:41:14 +0200 Subject: [PATCH 47/65] perf: merge palette entries through a hash map, not a linear scan Collector keeps, for each distinct material and texture already in the document's palettes (header entries included), where it is, keyed on every member with floats compared by value (-0.0 and 0.0 alike). Adding a feature's material or texture is now a lookup instead of a scan over the palette, so collecting is no longer quadratic in the number of distinct entries: 80,000 features with one distinct material each collect in 0.27 s instead of 8.1 s (release, identical output). Appearance::add_material/add_texture are gone. --- src/appearance.rs | 78 +++++++++++++++++++++++++++++++++++------------ src/cityjson.rs | 45 ++++++++++++++++++++++++--- tests/seq.rs | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 24 deletions(-) diff --git a/src/appearance.rs b/src/appearance.rs index 43ac163..def9f77 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -233,6 +233,64 @@ pub struct TextureObject { pub border_color: Option, } +/// A float's bits, with `-0.0` folded onto `0.0` so that values equal under +/// `==` hash alike. +fn bits(x: f64) -> u64 { + (x + 0.0).to_bits() +} + +/// What two materials must share to be the same palette entry: every member, +/// floats compared by value. Hashable, unlike `MaterialObject` itself. +#[derive(PartialEq, Eq, Hash)] +pub(crate) struct MaterialKey { + name: String, + floats: [Option; 3], + colours: [Option<[u64; 3]>; 3], + is_smooth: Option, +} + +impl MaterialObject { + pub(crate) fn key(&self) -> MaterialKey { + let colour = |c: &Option| c.map(|c| c.map(bits)); + MaterialKey { + name: self.name.clone(), + floats: [self.ambient_intensity, self.shininess, self.transparency] + .map(|f| f.map(bits)), + colours: [ + colour(&self.diffuse_color), + colour(&self.emissive_color), + colour(&self.specular_color), + ], + is_smooth: self.is_smooth, + } + } +} + +/// As [`MaterialKey`], for textures. +#[derive(PartialEq, Eq, Hash)] +pub(crate) struct TextureKey { + thetype: Option, + image: Option, + wrap_mode: Option, + texture_type: Option, + border_color: Option>, +} + +impl TextureObject { + pub(crate) fn key(&self) -> TextureKey { + TextureKey { + thetype: self.thetype, + image: self.image.clone(), + wrap_mode: self.wrap_mode, + texture_type: self.texture_type, + border_color: self.border_color.as_ref().map(|b| match b { + BorderColor::Rgb(c) => c.iter().copied().map(bits).collect(), + BorderColor::Rgba(c) => c.iter().copied().map(bits).collect(), + }), + } + } +} + /// The appearance library of a document or feature: the palettes that /// material, texture and texture-vertex indices point into. The schema closes /// the object, so a member it does not name is an error. @@ -264,26 +322,6 @@ impl Appearance { default_theme_material: None, } } - pub(crate) fn add_material(&mut self, jm: MaterialObject) -> usize { - let ls = self.materials.get_or_insert_with(Vec::new); - match ls.iter().position(|e| *e == jm) { - Some(y) => y, - None => { - ls.push(jm); - ls.len() - 1 - } - } - } - pub(crate) fn add_texture(&mut self, jm: TextureObject) -> usize { - let ls = self.textures.get_or_insert_with(Vec::new); - match ls.iter().position(|e| *e == jm) { - Some(y) => y, - None => { - ls.push(jm); - ls.len() - 1 - } - } - } pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec<[f64; 2]>) { self.vertices_texture .get_or_insert_with(Vec::new) diff --git a/src/cityjson.rs b/src/cityjson.rs index 01d4b53..2230810 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,4 +1,4 @@ -use crate::appearance::Appearance; +use crate::appearance::{Appearance, MaterialKey, MaterialObject, TextureKey, TextureObject}; use crate::city_object::CityObject; use crate::error::{CjseqError, Result}; use crate::geometry::GeometryTemplates; @@ -344,25 +344,52 @@ impl CityJSON { } } +/// The first position of each key. +fn positions(keys: impl Iterator) -> HashMap { + let mut m = HashMap::new(); + for (i, k) in keys.enumerate() { + m.entry(k).or_insert(i); + } + m +} + /// Collects a CityJSONSeq back into one CityJSON document: start from the /// header, [`Collector::add`] each feature, then [`Collector::finish`]. /// /// Features from several sequences can be collected into one document: hand /// each further sequence's header to [`Collector::add_header`], and the /// features that follow are re-expressed in the first header's transform. -#[derive(Debug, Clone)] pub struct Collector { doc: CityJSON, /// The transform the features being added are expressed in, when it is /// not the document's. correction: Option, + /// Where each distinct material and texture already is in the + /// document's palettes. + materials: HashMap, + textures: HashMap, } impl Collector { pub fn new(header: CityJSON) -> Self { + let app = header.appearance.as_ref(); + let materials = positions( + app.and_then(|a| a.materials.as_ref()) + .into_iter() + .flatten() + .map(MaterialObject::key), + ); + let textures = positions( + app.and_then(|a| a.textures.as_ref()) + .into_iter() + .flatten() + .map(TextureObject::key), + ); Collector { doc: header, correction: None, + materials, + textures, } } @@ -391,10 +418,20 @@ impl Collector { if let Some(cjf_app) = cjf.appearance.take() { let app = doc.appearance.get_or_insert_with(Appearance::new); for (i, m) in cjf_app.materials.into_iter().flatten().enumerate() { - m_oldnew.insert(MaterialIndex(i), MaterialIndex(app.add_material(m))); + let all = app.materials.get_or_insert_with(Vec::new); + let at = *self.materials.entry(m.key()).or_insert_with(|| { + all.push(m); + all.len() - 1 + }); + m_oldnew.insert(MaterialIndex(i), MaterialIndex(at)); } for (i, t) in cjf_app.textures.into_iter().flatten().enumerate() { - t_oldnew.insert(TextureIndex(i), TextureIndex(app.add_texture(t))); + let all = app.textures.get_or_insert_with(Vec::new); + let at = *self.textures.entry(t.key()).or_insert_with(|| { + all.push(t); + all.len() - 1 + }); + t_oldnew.insert(TextureIndex(i), TextureIndex(at)); } if let Some(uvs) = cjf_app.vertices_texture { //-- the feature's texture vertices are appended verbatim, so diff --git a/tests/seq.rs b/tests/seq.rs index 779bf42..8e95f35 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -175,3 +175,56 @@ fn a_feature_whose_id_is_not_among_its_objects_cannot_be_collected() { .unwrap(); assert!(c.add(bad).is_err()); } + +/// Collecting merges identical materials and textures into one palette entry, +/// including ones already in the header, and keeps different ones apart. +#[test] +fn collecting_merges_identical_palette_entries() { + let header = CityJSON::from_str( + &json!({"type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [], + "appearance": {"materials": [{"name": "h", "diffuseColor": [0.0, 0.0, 0.0]}]}}) + .to_string(), + ) + .unwrap(); + let feature = |id: &str, mat: serde_json::Value, tex: serde_json::Value| { + CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": id, + "CityObjects": {id: {"type": "Building", "geometry": [{ + "type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], + "material": {"m": {"value": 0}}, + "texture": {"t": {"values": [[[0, 0, 1, 2]]]}}}]}}, + "vertices": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + "appearance": {"materials": [mat], "textures": [tex], + "vertices-texture": [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]}}) + .to_string(), + ) + .unwrap() + }; + let png = json!({"type": "PNG", "image": "a.png", "borderColor": [0.0, 0.0, 0.0, 1.0]}); + let mut c = Collector::new(header); + //-- the header's own material, spelled with a negative zero + c.add(feature( + "a", + json!({"name": "h", "diffuseColor": [-0.0, 0.0, 0.0]}), + png.clone(), + )) + .unwrap(); + c.add(feature("b", json!({"name": "x"}), png.clone())) + .unwrap(); + c.add(feature( + "c", + json!({"name": "x"}), + json!({"type": "JPG", "image": "a.png"}), + )) + .unwrap(); + let doc = c.finish(); + doc.validate().unwrap(); + let app = doc.appearance.as_ref().unwrap(); + assert_eq!(app.materials.as_ref().unwrap().len(), 2); + assert_eq!(app.textures.as_ref().unwrap().len(), 2); + let v = serde_json::to_value(&doc).unwrap(); + let m = |id: &str| v["CityObjects"][id]["geometry"][0]["material"]["m"]["value"].clone(); + assert_eq!((m("a"), m("b"), m("c")), (json!(0), json!(1), json!(1))); +} From 7b2b63927904bba171c25bfb1db3f13987391bb5 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:46:11 +0200 Subject: [PATCH 48/65] docs: CHANGELOG and README for 0.5.0 The CHANGELOG lists the typed model, the unknown-member policy, the split between the document and CityJSONSeq sequencing, validate(), the two data-corrupting bugs fixed, and timings against 0.4.0. The README documents --order, collecting several sequences, the validate subcommand, the library API with an example that runs as a doctest, and the wasm feature flag. --- CHANGELOG.md | 35 +++++++++++++++++++-------- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++-------- src/lib.rs | 5 ++++ 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f431a9e..f764899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,34 @@ ## [0.5.0] - unreleased ### Modified (breaking) -- The library is split into per-concern modules (`geometry`, `semantics`, `appearance`, `city_object`, `metadata`, `error`, `cityjson`); every type is still re-exported at the crate root. -- Geometry boundaries, semantics values and appearance values carry their nesting depth in their type; `NestedArray` is removed. Null sub-arrays are accepted where the CityJSON schema allows them, and an explicit `null` material `values` stays distinct from an absent one. -- CityObject and semantic-surface types are typed enums, with support for extension types (`+Name`). -- Materials and textures in the appearance library are typed structs; members the schema does not name are rejected. -- `CityObject.geographicalExtent` is `[f64; 6]`, texture vertices are `[f64; 2]`. -- Errors are a `CjseqError` type; `Result` lives at `cjseq::error::Result` and is no longer re-exported at the root. +- The CityJSON schema's structure is carried in the types: + - geometry boundaries, and semantics, material and texture `values`, are nested exactly as deeply as their geometry type requires; `NestedArray` and the untagged value enums are gone, and each `Geometry` variant names its own value shapes (`PrimitiveValues`, `SolidValues`, `MultiSolidValues`); + - one index type per kind of index (`VertexIndex`, `UvIndex`, `MaterialIndex`, `TextureIndex`, `SurfaceIndex`, `TemplateIndex`); + - vertices are `[i64; 3]`, `transform` scale/translate `[f64; 3]`, `vertices-templates` `[f64; 3]`, a GeometryInstance's boundaries `[VertexIndex; 1]`, texture vertices `[f64; 2]`, extents `[f64; 6]`; + - `Lod` holds one of the schema's twenty LoD values and is required on every primitive; `version` is a `CityJSONVersion` (1.1 or 2.0); `attributes` is a JSON object; `extensions` maps a name to an `Extension { url, version }`; `contactType` and `role` are enums; a texture ring is `TexturedRing::Untextured` or `Textured { texture, uvs }`; + - City Object and semantic surface types are enums with an `Extension` case, which must start with `+`. +- Objects the schema closes (geometries, `transform`, `appearance`, `geometry-templates`, materials, textures) reject members it does not name; every other object keeps them in an `other: serde_json::Map`. +- `CityObjects` and the material/texture theme maps are `IndexMap`s, and object member order is preserved, so output follows input order and is deterministic. +- The document model and CityJSONSeq sequencing are split. `CityJSON` holds only the document; `header()` (was `get_metadata`) and `features(order)` slice it into a sequence, `feature(id)` slices one feature, and `Collector` (`new`, `add`, `add_header`, `finish`) replaces `add_cjfeature`/`add_transform_correction`. `SeqReader`/`SeqWriter` read and write a sequence line by line, as `Line::Header`/`Line::Feature`. `get_cjfeature`, `sort_cjfeatures` and the hidden sorting state are gone; `cjseq_to_cj` returns a `Result`. +- `SortingStrategy` is `AsIs` (input order, now the CLI default), `Random` (a real shuffle) or `Lexicographical`; the unimplemented `Morton`/`Hilbert` are gone. +- Errors are a `CjseqError` with structured variants (`IndexOutOfRange`, `DanglingReference`, `HierarchyMismatch`, `ValueCount`, `MisplacedMember`, located by `InCityObject`, `InTemplate`, `AtLine`); `Result` lives at `cjseq::error::Result`. Malformed input is an error, never a panic. +- The library is split into per-concern modules; every type is re-exported at the crate root. `CityJSON` and `CityJSONFeature` implement `FromStr` and `Default`. - wasm-bindgen bindings are behind an off-by-default `wasm` cargo feature, so library consumers building for wasm32 no longer inherit them. ### Added -- `CityObject::new`, `children_roles` on `CityObject`, `PartialEq`/`Eq` on the public structs. -- Round-trip tests over real CityJSONSeq fixtures. +- `validate()`/`errors()` on `CityJSON` and `CityJSONFeature`: every vertex, template, semantic surface, material, texture and texture-vertex index is in range, value arrays match the boundaries they describe, the parent/child hierarchy agrees with itself, and a feature carries no document-only members. The CLI has a `validate` subcommand. +- Features keep every descendant of their top-level City Object, not only its children. +- `CityObject::new`, `children_roles`, `Geometry::surfaces`, `PartialEq` on the public types. +- Round-trip, validation and sequencing tests over real CityJSONSeq fixtures; CI runs fmt, clippy with warnings denied, the tests and the wasm build. ### Fixed -- Metadata, address, `pointOfContact`, semantics and appearance members that the schema permits are kept instead of dropped or rejected. -- Every `referenceSystem` the schema's URL pattern permits is accepted. +- `collect` corrupted the texture coordinates of every textured feature after the first. +- The header written by `cat` cut the appearance down to what the geometry templates use but left the templates' indices unrenumbered, so they pointed at the wrong materials and textures. +- Metadata, address, `pointOfContact`, semantics, appearance and feature members the schema permits are kept instead of dropped or rejected; every `referenceSystem` the schema's URL pattern permits is accepted. +- Coordinates round-trip exactly (`serde_json`'s `float_roundtrip`). +- An empty document no longer gets a nonsense `translate` from `collect`, and an empty feature no longer has a NaN centroid. +### Performance +Against 0.4.0, release builds, median of 3 runs on one machine: +- a 65 MB, 15,000-feature CityJSONSeq (`tests/data/small.city.jsonl` repeated): `collect` 0.95 s to 0.47 s, `cat` 0.57 s to 0.44 s, `filter --radius` 0.40 s to 0.25 s; `collect` gives the same objects, vertices, transform and real coordinates; +- 80,000 features with one distinct material each: `collect` 68.3 s to 0.23 s, as materials and textures are merged through a hash map instead of a linear scan. ## [0.4.0] - 2025-09-12 ### Modified diff --git a/README.md b/README.md index 388aff8..1a01ff2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ [![crates.io](https://img.shields.io/crates/v/cjseq.svg)](https://crates.io/crates/cjseq) -`cjseq` is a Rust libray+binary for creating, processing, and modifying [CityJSONSeq](https://cityjson.org/cityjsonseq) files, as well as converting to/from [CityJSON](https://cityjson.org). +`cjseq` is a Rust library and binary for creating, processing, and modifying [CityJSONSeq](https://cityjson.org/cityjsonseq) files, as well as converting to and from [CityJSON](https://cityjson.org). + +Its types follow the CityJSON 2.0 schema: a geometry's boundaries and its semantics, material and texture values are nested exactly as deeply as its type requires, each kind of index has its own type, and strings the schema constrains (LoD, City Object types, versions) are typed. What the types cannot express, `validate()` checks. ## Installation @@ -13,10 +15,11 @@ 2. Run `cargo install cjseq` 3. Then a binary called `cjseq` is installed system-wide -### Installing the library +### Using the library -1. Install the [Rust compiler](https://www.rust-lang.org/learn/get-started) -2. Run `cargo install cjseq` +```sh +cargo add cjseq +``` ### Compiling the project @@ -44,6 +47,8 @@ Alternatively, to use stdin as input: cat myfile.city.json | cjseq cat ``` +By default the features are written in the order of the input; `--order lexicographical` sorts them by id, `--order random` shuffles them. + ### Convert CityJSONSeq to CityJSON The operator "collect" converts a CityJSONSeq stream to a CityJSON file: @@ -56,12 +61,14 @@ cat ./data/3dbag_b2.city.jsonl | cjseq collect > 3dbag_b2.city.json cjseq collect ./data/3dbag_b2.city.jsonl > 3dbag_b2.city.json ``` -Notice that [globbing](https://en.wikipedia.org/wiki/Glob_(programming)) works for the `collect` command: +Several sequences can be collected into one file, with a file list or [globbing](https://en.wikipedia.org/wiki/Glob_(programming)): ```sh -cat ./data/*.city.jsonl | cjseq collect > hugefile.city.json +cjseq collect ./data/*.city.jsonl > hugefile.city.json ``` +When their transforms differ, the features of each sequence are re-expressed in the first one's transform. + ### Filter CityJSONSeq An input stream of CityJSONSeq can be filtered with the following operators: @@ -85,10 +92,51 @@ As an example: cat myfile.city.jsonl | cjseq filter --bbox 85007 446179 85168 446290 > mysubset.city.jsonl ``` +### Validate + +`validate` reads a CityJSON file or a CityJSONSeq and prints every reference that does not resolve: indices past the end of `vertices` or of an appearance palette, semantics or material values that do not match their geometry, children and parents that do not name each other back, and so on. + +```sh +cjseq validate myfile.city.jsonl +``` + +It complements, and does not replace, schema validation with [cjval](https://github.com/cityjson/cjval). + ## Input constraints - 1. the input CityJSON/Seq must be v1.1 or v2.0 (v1.0 will panic). - 2. the input JSON must be CityJSON schema-valid, use [cjval](https://github.com/cityjson/cjval) to validate. + 1. the input must be CityJSON v1.1 or v2.0; anything else is reported as an error. + 2. input the schema forbids (a geometry nested to the wrong depth, a member a closed object does not name, a misspelt type) is reported as an error, with the line it is on for a CityJSONSeq. + +## The library + +```rust +use cjseq::{CityJSON, Collector, Line, SeqReader, SeqWriter, SortingStrategy}; +use std::io::BufReader; + +fn main() -> cjseq::error::Result<()> { + //-- CityJSON -> CityJSONSeq + let cj: CityJSON = std::fs::read_to_string("data/3dbag_b2.city.json")?.parse()?; + cj.validate()?; + let mut out = SeqWriter::new(Vec::new()); + out.write_header(&cj.header()?)?; + for feature in cj.features(SortingStrategy::AsIs) { + out.write_feature(&feature?)?; + } + + //-- CityJSONSeq -> CityJSON + let file = std::fs::File::open("data/3dbag_b2.city.jsonl")?; + let mut collector: Option = None; + for line in SeqReader::new(BufReader::new(file)) { + match line? { + Line::Header(h) => collector = Some(Collector::new(h)), + Line::Feature(f) => collector.as_mut().expect("header first").add(f)?, + } + } + let cj = collector.expect("a header").finish(); + println!("{} features", cj.number_of_city_objects()); + Ok(()) +} +``` ## WASM bindings @@ -98,8 +146,8 @@ cat myfile.city.jsonl | cjseq filter --bbox 85007 446179 85168 446290 > mysubset cargo install wasm-pack ``` -Build the WASM bindings: +Build the WASM bindings, which are behind the `wasm` cargo feature: ```sh -wasm-pack build --release --target web --out-dir js +wasm-pack build --release --target web --out-dir js -- --features wasm ``` diff --git a/src/lib.rs b/src/lib.rs index abecd35..45c573c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,3 +54,8 @@ pub mod wasm; // Re-export WASM functions for convenience #[cfg(all(target_arch = "wasm32", feature = "wasm"))] pub use wasm::*; + +/// The README's examples, compiled and run as doctests. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +pub struct ReadmeDoctests; From e7b38dfe19daed750d0432da6887622eabe18904 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 16:49:19 +0200 Subject: [PATCH 49/65] test: the four ways an Extension extends CityJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document using all four cases of spec § 8 (a +census root property, +colour/+area-parcel attributes, a +ThermalSurface semantic surface with its own member, a +NoiseBuilding with a +NoiseBuildingPart child and an extra City Object member) parses, validates, round-trips byte-equal as JSON, and survives cat and collect unchanged; the root property and the extensions declaration travel in the header, and the Extension child goes with its parent's feature. --- tests/extensions.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/extensions.rs diff --git a/tests/extensions.rs b/tests/extensions.rs new file mode 100644 index 0000000..8697cb3 --- /dev/null +++ b/tests/extensions.rs @@ -0,0 +1,129 @@ +//! The four ways a CityJSON Extension extends the core model (§ 8): new root +//! properties, new attributes, new semantic surfaces, new City Objects. Each +//! must parse, validate, and survive slicing into a CityJSONSeq and +//! collecting back. + +use cjseq::{CityJSON, CityObjectType, Collector, SemanticSurfaceType, SortingStrategy}; +use serde_json::{json, Value}; +use std::str::FromStr; + +fn extended() -> Value { + json!({ + "type": "CityJSON", + "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "extensions": { + "Noise": {"url": "https://someurl.org/noise.ext.json", "version": "2.0"}, + "Census": {"url": "https://someurl.org/census.ext.json", "version": "0.7"} + }, + "CityObjects": { + //-- case 2: extra attributes on a core City Object + "b": { + "type": "Building", + "attributes": { + "storeysAboveGround": 2, + "+colour": {"rgba": [255, 255, 255, 1]}, + "+area-parcel": {"value": 437, "uom": "m2"} + }, + "geometry": [{ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[0, 1, 2]], [[0, 2, 3]]], + //-- case 3: a new semantic surface, with its own member + "semantics": { + "surfaces": [ + {"type": "RoofSurface"}, + {"type": "+ThermalSurface", "azimuth": 30.5} + ], + "values": [0, 1] + } + }] + }, + //-- case 4: a new 1st-level City Object, with a new 2nd-level child + "n": { + "type": "+NoiseBuilding", + "attributes": {"buildingLDenMin": 55.0}, + "children": ["np"], + "+noiseSource": {"kind": "road"}, + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [0, 1]}] + }, + "np": { + "type": "+NoiseBuildingPart", + "parents": ["n"], + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [2, 3]}] + } + }, + "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], + //-- case 1: a new root property + "+census": {"percent_men": 49.5, "percent_women": 51.5} + }) +} + +#[test] +fn every_kind_of_extension_parses_validates_and_round_trips() { + let input = extended(); + let cj = CityJSON::from_str(&input.to_string()).unwrap(); + cj.validate().unwrap(); + assert_eq!(serde_json::to_value(&cj).unwrap(), input); + + assert_eq!(cj.other["+census"]["percent_men"], json!(49.5)); + assert_eq!(cj.extensions.as_ref().unwrap()["Noise"].version, "2.0"); + let b = &cj.city_objects["b"]; + assert_eq!( + b.attributes.as_ref().unwrap()["+colour"]["rgba"][0], + json!(255) + ); + let surfaces = b.geometry.as_ref().unwrap()[0].surfaces().unwrap(); + assert_eq!( + surfaces[1].thetype, + SemanticSurfaceType::Extension("+ThermalSurface".into()) + ); + assert_eq!(surfaces[1].other["azimuth"], json!(30.5)); + let n = &cj.city_objects["n"]; + assert_eq!( + n.thetype, + CityObjectType::Extension("+NoiseBuilding".into()) + ); + assert_eq!(n.other["+noiseSource"], json!({"kind": "road"})); +} + +#[test] +fn every_kind_of_extension_survives_cat_and_collect() { + let cj = CityJSON::from_str(&extended().to_string()).unwrap(); + let header = cj.header().unwrap(); + //-- root properties and the extension declarations travel in the header + assert_eq!(header.other["+census"], cj.other["+census"]); + assert_eq!(header.extensions, cj.extensions); + + let features: Vec<_> = cj + .features(SortingStrategy::AsIs) + .map(|f| f.unwrap()) + .collect(); + //-- the Extension child goes with its Extension parent + assert_eq!(features.len(), 2); + let noise = features.iter().find(|f| f.id == "n").unwrap(); + assert!(noise.city_objects.contains_key("np")); + for f in &features { + f.validate().unwrap(); + } + + let mut c = Collector::new(header); + for f in features { + c.add(f).unwrap(); + } + let back = c.finish(); + back.validate().unwrap(); + assert_eq!(back, cj); +} + +#[test] +fn filtering_by_an_extension_type_works() { + let t: CityObjectType = serde_json::from_value(json!("+NoiseBuilding")).unwrap(); + let cj = CityJSON::from_str(&extended().to_string()).unwrap(); + let ids: Vec = cj + .features(SortingStrategy::AsIs) + .map(|f| f.unwrap()) + .filter(|f| f.city_objects[&f.id].thetype == t) + .map(|f| f.id) + .collect(); + assert_eq!(ids, ["n"]); +} From b1022f3ed589210088345176c71b7d958eb38e25 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 17:01:55 +0200 Subject: [PATCH 50/65] feat: validate() checks the schemas' Extension name patterns An Extension City Object type must match (\+)([A-Z])\w+, an Extension semantic surface type (\+)\w+, and an entry of "extensions" must have a version X.Y or X.Y.Z (cityjson.schema.json). A mismatch is a CjseqError::Pattern naming the value and the pattern, located in its City Object or template. Parsing stays lenient (any '+'-prefixed name), so a file with a non-conforming name can still be read and repaired. --- src/error.rs | 14 ++++++++ src/validate.rs | 90 ++++++++++++++++++++++++++++++++++++++++------- tests/validate.rs | 48 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/error.rs b/src/error.rs index eb09155..bca64f5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,6 +40,14 @@ pub enum CjseqError { expected: usize, found: usize, }, + /// A name or version does not match the pattern the schema gives it. + Pattern { + /// What the value is, eg `"Extension City Object type"`. + what: &'static str, + value: String, + /// The pattern, in words. + expected: &'static str, + }, /// A CityJSONFeature carries a root member only a CityJSON document may /// carry, eg `transform`. MisplacedMember { member: String }, @@ -102,6 +110,11 @@ impl fmt::Display for CjseqError { expected, found, } => write!(f, "{found} {what} where the boundaries call for {expected}"), + CjseqError::Pattern { + what, + value, + expected, + } => write!(f, "{what} {value:?} is not {expected}"), CjseqError::MisplacedMember { member } => write!( f, "a CityJSONFeature cannot carry {member:?}; only the CityJSON document can" @@ -132,6 +145,7 @@ impl std::error::Error for CjseqError { | CjseqError::HierarchyMismatch { .. } | CjseqError::ValueCount { .. } | CjseqError::MisplacedMember { .. } + | CjseqError::Pattern { .. } | CjseqError::IndexOutOfRange { .. } => None, } } diff --git a/src/validate.rs b/src/validate.rs index 0a736f8..12c003b 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -3,7 +3,7 @@ //! Object hierarchy agrees with itself. use crate::appearance::{Appearance, MaterialReference, TextureReference, TexturedRing}; -use crate::city_object::CityObject; +use crate::city_object::{CityObject, CityObjectType}; use crate::cityjson::{CityJSON, CityJSONFeature}; use crate::error::{CjseqError, Result}; use crate::geometry::{Geometry, Shell, Surface, Themes}; @@ -11,7 +11,7 @@ use crate::index::{ Index, Indices, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, TextureIndex, UvIndex, }; -use crate::semantics::Semantics; +use crate::semantics::{SemanticSurfaceType, Semantics}; use indexmap::IndexMap; /// The lengths that the indices of one geometry are checked against. @@ -183,7 +183,63 @@ fn texture( } } +/// `\w` in the schemas' patterns. +fn word(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// `(\+)([A-Z])\w+`, the schema's pattern for an Extension City Object type. +fn city_object_type(t: &CityObjectType, errs: &mut Vec) { + let CityObjectType::Extension(s) = t else { + return; + }; + let mut c = s.chars(); + let ok = c.next() == Some('+') + && c.next().is_some_and(|c| c.is_ascii_uppercase()) + && c.next().is_some_and(word); + if !ok { + errs.push(CjseqError::Pattern { + what: "Extension City Object type", + value: s.clone(), + expected: "'+', a capital letter, then letters, digits or '_'", + }); + } +} + +/// `(\+)\w+`, the schema's pattern for an Extension semantic surface type. +fn semantic_surface_type(t: &SemanticSurfaceType, errs: &mut Vec) { + let SemanticSurfaceType::Extension(s) = t else { + return; + }; + let mut c = s.chars(); + if !(c.next() == Some('+') && c.next().is_some_and(word)) { + errs.push(CjseqError::Pattern { + what: "Extension semantic surface type", + value: s.clone(), + expected: "'+' then letters, digits or '_'", + }); + } +} + +/// `^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$`, an Extension's version. +fn extension_version(v: &str, errs: &mut Vec) { + let number = |p: &str| { + !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) && (p == "0" || !p.starts_with('0')) + }; + let parts: Vec<&str> = v.split('.').collect(); + if !((2..=3).contains(&parts.len()) && parts.iter().all(|p| number(p))) { + errs.push(CjseqError::Pattern { + what: "Extension version", + value: v.to_string(), + expected: "X.Y or X.Y.Z", + }); + } +} + fn geometry(g: &Geometry, l: &Lengths, errs: &mut Vec) { + for s in g.surfaces().into_iter().flatten() { + semantic_surface_type(&s.thetype, errs); + } g.visit_vertices(&mut |v| in_range(v, l.vertices, errs)); match g { Geometry::MultiPoint { @@ -263,11 +319,14 @@ fn hierarchy(cos: &IndexMap, errs: &mut Vec) { from: id.clone(), to: child.clone(), }), - Some(c) if !names(&c.parents, id) => errs.push(CjseqError::HierarchyMismatch { - parent: id.clone(), - child: child.clone(), - }), - _ => {} + Some(c) => { + if !names(&c.parents, id) { + errs.push(CjseqError::HierarchyMismatch { + parent: id.clone(), + child: child.clone(), + }); + } + } } } for parent in co.parents.iter().flatten() { @@ -276,11 +335,14 @@ fn hierarchy(cos: &IndexMap, errs: &mut Vec) { from: id.clone(), to: parent.clone(), }), - Some(p) if !names(&p.children, id) => errs.push(CjseqError::HierarchyMismatch { - parent: parent.clone(), - child: id.clone(), - }), - _ => {} + Some(p) => { + if !names(&p.children, id) { + errs.push(CjseqError::HierarchyMismatch { + parent: parent.clone(), + child: id.clone(), + }); + } + } } } } @@ -290,6 +352,7 @@ fn city_objects(cos: &IndexMap, l: &Lengths, errs: &mut Vec< hierarchy(cos, errs); for (id, co) in cos { let mut inner = Vec::new(); + city_object_type(&co.thetype, &mut inner); for g in co.geometry.iter().flatten() { geometry(g, l, &mut inner); } @@ -323,6 +386,9 @@ impl CityJSON { /// valid. pub fn errors(&self) -> Vec { let mut errs = Vec::new(); + for e in self.extensions.iter().flat_map(|e| e.values()) { + extension_version(&e.version, &mut errs); + } let app = self.appearance.as_ref(); if let Some(gt) = &self.geometry_templates { let l = Lengths::new(gt.vertices_templates.len(), None, app); diff --git a/tests/validate.rs b/tests/validate.rs index eacb98a..a2f8492 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -467,3 +467,51 @@ fn the_header_renumbers_its_templates_to_the_sliced_appearance() { json!(0) ); } + +/// The schemas' name patterns: an Extension City Object type is `+` then a +/// capital letter then word characters, an Extension semantic surface type +/// `+` then word characters, and an Extension's version `X.Y` (or `X.Y.Z`). +#[test] +fn extension_names_and_versions_follow_the_schema_patterns() { + let co = |t: &str| doc(json!({"x": {"type": t}}), json!([]), json!({})); + co("+NoiseBuilding").validate().unwrap(); + for bad in ["+noiseBuilding", "+N", "+1Building"] { + let e = inner(first_error(&co(bad)), "x"); + assert!( + matches!(e, CjseqError::Pattern { ref value, .. } if value == bad), + "{bad}: {e:?}" + ); + } + + let sem = |t: &str| { + building( + json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]], + "semantics": {"surfaces": [{"type": t}], "values": [0]}}), + ) + }; + doc(sem("+thermal_surface"), json!(V4), json!({})) + .validate() + .unwrap(); + let e = inner(first_error(&doc(sem("+-x"), json!(V4), json!({}))), "b"); + assert!( + matches!(e, CjseqError::Pattern { ref value, .. } if value == "+-x"), + "{e:?}" + ); + + let ext = |v: &str| { + doc( + json!({}), + json!([]), + json!({"extensions": {"Noise": {"url": "https://x.org/n.json", "version": v}}}), + ) + }; + ext("2.0").validate().unwrap(); + ext("0.10.1").validate().unwrap(); + for bad in ["2", "v2.0", "2.0.", "02.0", "2.x"] { + let e = first_error(&ext(bad)); + assert!( + matches!(e, CjseqError::Pattern { ref value, .. } if value == bad), + "{bad}: {e:?}" + ); + } +} From aebc4325aec6d493b94c0d8a6b4c2518fd1fc739 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 18:57:52 +0200 Subject: [PATCH 51/65] test: repair the geom_temp fixture's corrupt header tests/data/geom_temp.city.jsonl was cut from the CityJSON Railway example (all 18 of its City Objects, its three geometry templates and its header palette are Railway's) by a cat with the header bug fixed in 0913656: the palette was sliced to Railway's materials 1 and 2, but the templates kept pointing at 1 and 2, one past the end. Its header line is now the fixed cat's header for Railway.city.json, which differs only in those template material indices (now 0 and 1); the four feature lines are unchanged. The validate test no longer needs to treat the file as known-invalid. --- tests/data/geom_temp.city.jsonl | 2 +- tests/validate.rs | 19 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/tests/data/geom_temp.city.jsonl b/tests/data/geom_temp.city.jsonl index 0db8552..14601d0 100644 --- a/tests/data/geom_temp.city.jsonl +++ b/tests/data/geom_temp.city.jsonl @@ -1,4 +1,4 @@ -{"type":"CityJSON","version":"2.0","transform":{"scale":[0.001,0.001,0.001],"translate":[0.56,0.64,7.579]},"CityObjects":{},"vertices":[],"metadata":{"geographicalExtent":[0.56,0.64,7.579,12.64,7.68,9.103]},"appearance":{"materials":[{"diffuseColor":[0.49609375,0.4296875,0.296875],"emissiveColor":[0.0,0.0,0.0],"name":"UUID_e58d9d68-2e8c-4b81-bec1-1892e9d71c81","specularColor":[1.0,1.0,1.0],"transparency":0.0},{"diffuseColor":[0.49609375,0.4296875,0.296875],"emissiveColor":[0.0,0.0,0.0],"name":"UUID_f55b5612-d5f9-4ef8-842d-ca4849d90b59","specularColor":[1.0,1.0,1.0],"transparency":0.0}],"textures":[{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Vegetation_Juniper2.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"},{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/MaerZ-0.png","textureType":"unknown","type":"PNG","wrapMode":"wrap"}],"vertices-texture":[[0.038837,0.0129458],[0.0428056,0.0188853],[0.0441993,0.0258914],[0.0428056,0.0328975],[0.038837,0.038837],[0.0328975,0.0428057],[0.0258914,0.0441993],[0.0188852,0.0428057],[0.0129458,0.038837],[0.0089771,0.0328976],[0.0075835,0.0258914],[0.0089771,0.0188853],[0.0129457,0.0129458],[0.0188852,0.0089772],[0.0258913,0.0075836],[0.0328975,0.0089771],[-0.0285743,0.1513162],[0.0428595,0.1513165],[0.0107114,0.7826142],[0.0035681,0.7826143],[-0.0428609,0.1513161],[0.028573,0.1513166],[-0.0035751,0.7826142],[-0.0107185,0.7826141],[-0.0560601,0.1527158],[0.0153736,0.152716],[-0.0167737,0.7840137],[-0.0239171,0.7840136],[-0.066162,0.1553019],[0.0052719,0.1553021],[-0.0268747,0.7865998],[-0.0340181,0.7865997],[-0.0716283,0.1623381],[-0.0001945,0.1623383],[-0.0323392,0.7936359],[-0.0394826,0.7936359],[-0.0661614,0.1657173],[0.0052724,0.165717],[-0.0268707,0.7970147],[-0.0340141,0.7970149],[-0.0560588,0.1683033],[0.015375,0.1683031],[-0.0167675,0.7996008],[-0.0239109,0.7996008],[-0.0428596,0.1697029],[0.0285742,0.1697026],[-0.003568,0.8010004],[-0.0107113,0.8010004],[-0.028573,0.1697029],[0.042861,0.1697025],[0.0107186,0.8010004],[0.0035752,0.8010004],[-0.0153737,0.1683032],[0.0560601,0.168303],[0.0239171,0.7996008],[0.0167737,0.7996008],[-0.0052716,0.1657171],[0.0661622,0.1657169],[0.0340182,0.7970146],[0.0268748,0.7970148],[0.0001952,0.1623382],[0.071629,0.1623384],[0.0394841,0.7936358],[0.0323407,0.7936359],[0.0001953,0.1586811],[0.0716291,0.158681],[0.0394827,0.7899788],[0.0323393,0.7899787],[-0.0052725,0.1553019],[0.0661613,0.1553022],[0.0340141,0.7865999],[0.0268707,0.7865999],[-0.0153749,0.1527159],[0.0560588,0.1527161],[0.023911,0.7840139],[0.0167676,0.7840138],[-0.0716295,0.158681],[-0.0001958,0.1586808],[-0.0323407,0.7899786],[-0.0394841,0.7899788],[0.0,0.0],[0.0,0.125],[0.125,0.125],[0.125,0.0],[0.25,0.125],[0.25,0.0],[0.375,0.125],[0.375,0.0],[0.5,0.125],[0.5,0.0],[0.625,0.125],[0.625,0.0],[0.75,0.125],[0.75,0.0],[0.875,0.125],[0.875,0.0],[1.0,0.125],[2e-7,0.2499998],[0.1250002,0.2499998],[0.0637249,0.1249996],[2e-7,0.1249998],[0.1250012,0.2499989],[0.2500012,0.2499989],[0.1887258,0.1249988],[0.1250012,0.1249989],[0.2499995,0.2500005],[0.3749995,0.2500005],[0.3137239,0.1250005],[0.2499995,0.1250005],[0.3750003,0.2499996],[0.5000003,0.2499996],[0.4387248,0.1249996],[0.3750003,0.1249996],[0.5,0.2499999],[0.625,0.2499999],[0.5637245,0.1249999],[0.5,0.1249999],[0.6249999,0.2500003],[0.7499999,0.2500003],[0.6887244,0.1250003],[0.6249999,0.1250003],[0.7499972,0.2500061],[0.8749972,0.2500061],[0.8137217,0.1250059],[0.7499972,0.1250061],[0.8750046,0.2499957],[1.0000046,0.2499957],[0.9387289,0.124996],[0.8750046,0.1249957],[-9e-7,0.2499991],[0.1249991,0.2499991],[0.0637238,0.1249989],[-1e-6,0.1249991],[0.125,0.2499999],[0.25,0.2499999],[0.1887246,0.1249999],[0.125,0.1249999],[0.2499998,0.2500002],[0.3749998,0.2500002],[0.3137242,0.1250002],[0.2499998,0.1250002],[0.375,0.25],[0.5,0.25],[0.4387245,0.125],[0.4999999,0.2500008],[0.6249999,0.2500008],[0.5637241,0.1250005],[0.4999999,0.1250008],[0.6250003,0.2500008],[0.7500003,0.2500008],[0.6887249,0.125001],[0.6250003,0.1250008],[0.7499986,0.2499975],[0.8749986,0.2499975],[0.813723,0.1249973],[0.7499986,0.1249975],[0.8750002,0.25],[1.0000002,0.25],[0.9387245,0.1250004],[0.8750002,0.125],[-4e-7,0.375001],[0.1249996,0.375001],[0.086101,0.2500011],[-4e-7,0.250001],[0.1249992,0.3750015],[0.2499992,0.3750015],[0.2111003,0.2500016],[0.1249992,0.2500015],[0.250005,0.3749917],[0.375005,0.3749917],[0.3361069,0.2499917],[0.250005,0.2499917],[0.3749983,0.3750042],[0.4999983,0.3750042],[0.4610997,0.2500042],[0.3749983,0.2500042],[0.5000014,0.3749924],[0.6250014,0.3749924],[0.5861031,0.2499925],[0.5000014,0.2499924],[0.6249969,0.3750248],[0.7499969,0.3750248],[0.7110982,0.2500244],[0.6249969,0.2500248],[0.7499995,0.375002],[0.8749995,0.375002],[0.836101,0.250002],[0.7499995,0.250002],[0.8750009,0.3749988],[1.0000009,0.3749988],[0.9611024,0.2499988],[0.8750009,0.2499988],[0.0,0.3749988],[0.125,0.3749988],[0.0861014,0.2499989],[0.0,0.2499988],[0.1249971,0.3750053],[0.2499971,0.3750053],[0.2110986,0.2500054],[0.1249971,0.2500053],[0.2499998,0.3750002],[0.3749998,0.3750002],[0.3361014,0.2500002],[0.3749999,0.3750002],[0.4999999,0.3750002],[0.4611016,0.2500003],[0.3749999,0.2500002],[0.5000007,0.3749918],[0.6250007,0.3749917],[0.586102,0.2499915],[0.5000007,0.2499918],[0.6250001,0.375001],[0.7500001,0.375001],[0.7111017,0.2500011],[0.6250001,0.250001],[0.75,0.375],[0.875,0.375],[0.8361015,0.25],[0.75,0.25],[0.8749999,0.3749999],[0.9999999,0.3749999],[0.9611015,0.2499995],[0.8749999,0.2499999],[-1e-7,0.5000004],[0.1249999,0.5000004],[0.0982118,0.3750004],[-1e-7,0.3750004],[0.1250005,0.4999988],[0.2500005,0.4999988],[0.2232126,0.3749987],[0.1250005,0.3749988],[0.2499978,0.500002],[0.3749978,0.500002],[0.3482094,0.375002],[0.2499978,0.375002],[0.374998,0.5000033],[0.499998,0.5000033],[0.4732098,0.3750033],[0.374998,0.3750033],[0.5000016,0.4999863],[0.6250016,0.4999864],[0.5982135,0.3749864],[0.5000016,0.3749863],[0.6250012,0.4999839],[0.7500012,0.499984],[0.7232131,0.3749841],[0.6250012,0.3749839],[0.749997,0.5000188],[0.874997,0.5000187],[0.8482088,0.3750186],[0.7499969,0.3750188],[0.8749958,0.5000063],[0.9999958,0.5000062],[0.9732077,0.3750061],[0.8749958,0.3750063],[-6e-7,0.5000052],[0.1249994,0.5000052],[0.0982113,0.3750051],[-6e-7,0.3750052],[0.1249985,0.5000035],[0.2499985,0.5000035],[0.2232102,0.3750036],[0.1249985,0.3750035],[0.2500035,0.4999929],[0.3750035,0.4999929],[0.3482156,0.374993],[0.2500035,0.3749929],[0.3750012,0.4999962],[0.5000012,0.4999962],[0.4732126,0.3749959],[0.3750012,0.3749962],[0.4999997,0.5000046],[0.6249997,0.5000047],[0.5982117,0.3750048],[0.4999997,0.3750046],[0.6250004,0.5000088],[0.7500004,0.5000088],[0.7232124,0.375009],[0.6250004,0.3750088],[0.7499999,0.4999961],[0.8749999,0.4999961],[0.8482117,0.3749959],[0.7499999,0.3749961],[0.8749997,0.500001],[0.9999997,0.500001],[0.9732115,0.3750014],[0.8749997,0.375001],[-1.8e-6,0.6250085],[0.1249982,0.6250085],[0.1063021,0.5000083],[-1.8e-6,0.5000085],[0.1249994,0.6250016],[0.2499994,0.6250016],[0.2313032,0.5000016],[0.1249994,0.5000016],[0.2499986,0.6250045],[0.3749986,0.6250045],[0.3563023,0.5000045],[0.2499986,0.5000045],[0.3750039,0.62498],[0.5000039,0.62498],[0.4813078,0.49998],[0.3750039,0.49998],[0.4999949,0.6250572],[0.6249949,0.6250571],[0.6062984,0.500057],[0.4999949,0.5000572],[0.6250005,0.6249912],[0.7500005,0.6249912],[0.7313042,0.4999912],[0.6250005,0.4999912],[0.7500062,0.6249449],[0.8750062,0.6249452],[0.8563101,0.4999454],[0.7500063,0.4999449],[0.8749974,0.6250048],[0.9999974,0.6250047],[0.9813011,0.5000047],[0.8749973,0.5000048],[2.9e-6,0.6249813],[0.1250029,0.6249812],[0.1063067,0.4999812],[2.9e-6,0.4999813],[0.1250012,0.624997],[0.2500012,0.624997],[0.2313049,0.4999969],[0.1250012,0.499997],[0.2499976,0.6249981],[0.3749976,0.6249981],[0.3563013,0.499998],[0.2499976,0.499998],[0.3749992,0.6250036],[0.4999992,0.6250036],[0.4813031,0.5000037],[0.3749992,0.5000036],[0.5000019,0.624968],[0.6250019,0.624968],[0.6063054,0.4999676],[0.5000019,0.499968],[0.6250003,0.6250114],[0.7500003,0.6250114],[0.7313041,0.5000114],[0.6250003,0.5000114],[0.7500002,0.6250246],[0.8750001,0.6250248],[0.856304,0.5000252],[0.7500002,0.5000246],[0.8750006,0.6249978],[1.0000006,0.6249978],[0.9813043,0.4999977],[0.8750006,0.4999978],[-2.9e-6,0.750013],[0.1249971,0.750013],[0.1124942,0.6250129],[-2.9e-6,0.625013],[0.1249996,0.7500013],[0.2499996,0.7500014],[0.2374967,0.6250013],[0.1249996,0.6250013],[0.2499993,0.7500024],[0.3749993,0.7500024],[0.3624962,0.6250024],[0.2499993,0.6250024],[0.3749991,0.7500177],[0.4999991,0.7500177],[0.4874961,0.6250177],[0.3749991,0.6250177],[0.5000014,0.7499832],[0.6250014,0.7499833],[0.6124985,0.6249833],[0.5000014,0.6249832],[0.6249993,0.7500147],[0.7499993,0.7500146],[0.7374963,0.6250146],[0.6249993,0.6250147],[0.750001,0.7499869],[0.875001,0.749987],[0.8624981,0.624987],[0.750001,0.6249869],[0.8749972,0.7500094],[0.9999972,0.7500093],[0.9874941,0.6250092],[0.8749971,0.6250094],[-8.6e-6,0.7500417],[0.1249914,0.7500417],[0.1124883,0.6250418],[-8.5e-6,0.6250417],[0.1250028,0.7499924],[0.2500028,0.7499924],[0.2374999,0.6249924],[0.1250028,0.6249924],[0.25,0.7500001],[0.375,0.7500001],[0.3624968,0.6250001],[0.25,0.6250001],[0.3749979,0.7500113],[0.4999979,0.7500112],[0.4874951,0.6250114],[0.3749979,0.6250113],[0.499999,0.7500155],[0.624999,0.7500155],[0.6124961,0.6250156],[0.499999,0.6250155],[0.6249997,0.7499841],[0.7499997,0.7499841],[0.7374967,0.6249841],[0.6249997,0.6249841],[0.749999,0.749947],[0.8749991,0.7499467],[0.8624959,0.6249464],[0.749999,0.624947],[0.874998,0.7500257],[0.999998,0.750026],[0.987495,0.6250263],[0.874998,0.6250257],[-2.4e-6,0.8750021],[0.1249976,0.875002],[0.1177448,0.7500021],[-2.5e-6,0.7500021],[0.1249997,0.875],[0.2499997,0.8749999],[0.2427469,0.7500001],[0.1249997,0.75],[0.25,0.8750002],[0.375,0.8750002],[0.3677472,0.7500003],[0.2499997,0.7500004],[0.3750008,0.8749994],[0.5000008,0.8749995],[0.4927485,0.7499995],[0.3750009,0.7499994],[0.5000005,0.8749997],[0.6250005,0.8749999],[0.617748,0.75],[0.5000006,0.7499997],[0.6249992,0.8749999],[0.7499992,0.8749995],[0.7427465,0.7499995],[0.6249991,0.75],[0.7500006,0.8750018],[0.8750006,0.8750013],[0.8677478,0.7500013],[0.7500004,0.7500019],[0.8750028,0.8749959],[1.0000028,0.8749957],[0.9927501,0.7499957],[0.8750027,0.7499959],[-1.6e-6,0.875001],[0.1249984,0.8750011],[0.117746,0.7500011],[-1.6e-6,0.750001],[0.1249998,0.8750002],[0.2499998,0.8750003],[0.2427473,0.7500003],[0.1249998,0.7500002],[0.25,0.8749995],[0.375,0.8749995],[0.3677472,0.7499994],[0.25,0.7499995],[0.3749999,0.8750001],[0.4999999,0.8750001],[0.4927473,0.7500001],[0.3749999,0.7500001],[0.6250005,0.8749998],[0.6177479,0.7499999],[0.5000005,0.7499997],[0.6250009,0.8750001],[0.7500008,0.8750005],[0.7427484,0.7500006],[0.6250009,0.7500002],[0.7499995,0.8749976],[0.8749995,0.8749975],[0.8677469,0.7499975],[0.7499995,0.7499976],[0.8750041,0.8749874],[1.0000041,0.8749875],[0.9927516,0.7499873],[0.8750041,0.7499875],[1.7e-6,0.9999991],[0.1250017,0.9999992],[0.1226,0.8749991],[1.7e-6,0.874999],[0.1250002,1.0],[0.2500002,1.0000001],[0.2475984,0.875],[0.1250002,0.875],[0.2500001,0.9999998],[0.3750001,0.9999999],[0.3725984,0.8749997],[0.2500001,0.8749998],[0.374999,1.0000007],[0.499999,1.0000006],[0.4975969,0.8750007],[0.374999,0.8750007],[0.5000005,0.9999997],[0.6250005,0.9999998],[0.6225988,0.8749997],[0.5000005,0.8749996],[0.6250001,1.0],[0.7500001,0.9999999],[0.7475983,0.8749999],[0.6250001,0.8749999],[0.7499896,1.000003],[0.8749897,0.9999988],[0.8725869,0.8749989],[0.7499888,0.8750029],[0.8750064,0.9999965],[1.0000063,0.9999936],[0.9976037,0.8749936],[0.8750057,0.8749964],[-1.5e-6,1.0000005],[0.1249985,1.0000008],[0.1225968,0.8750007],[-1.5e-6,0.8750005],[0.1250004,0.9999994],[0.2500004,0.9999993],[0.2475983,0.8749992],[0.1250004,0.8749994],[0.25,0.9999997],[0.375,0.9999997],[0.3725983,0.8749997],[0.25,0.8749996],[0.3750001,1.0],[0.5000001,1.0],[0.4975982,0.8749999],[0.3750001,0.875],[0.4999999,0.9999994],[0.6249999,0.9999995],[0.6225981,0.8749996],[0.4999999,0.8749994],[0.6249992,1.000002],[0.7499992,1.000001],[0.7475971,0.8750008],[0.624999,0.8750019],[0.7500049,1.0000115],[0.8750049,1.0000135],[0.8726035,0.8750136],[0.7500053,0.8750115],[0.8749966,1.0000057],[0.9999966,1.0000055],[0.9975947,0.8750055],[0.8749965,0.8750057],[0.0024033,0.1249999],[0.1250016,0.1250001],[0.1250016,1e-7],[1.6e-6,-1e-7],[0.1274015,0.1249998],[0.2499995,0.1249998],[0.2499995,-2e-7],[0.1249995,-1e-7],[0.2524017,0.125],[0.3774018,0.1250001],[0.4999999,0.1250001],[0.4999999,1e-7],[0.3749999,2e-7],[0.5024026,0.1249992],[0.6250009,0.1249993],[0.6250009,-7e-7],[0.5000009,-8e-7],[0.6273963,0.1250067],[0.7499943,0.1250062],[0.7499942,6.2e-6],[0.6249942,6.8e-6],[0.7523956,0.1250256],[0.8749938,0.1250256],[0.8749937,0.0000256],[0.7499937,0.0000258],[0.8773955,0.124983],[0.9999933,0.1249791],[0.9999925,-0.0000209],[0.8749925,-0.0000174],[0.0024013,0.1250002],[0.1249996,0.1250005],[0.1249996,5e-7],[-4e-7,3e-7],[0.1274016,0.1250003],[0.2499998,0.1250004],[0.2499998,4e-7],[0.1249998,4e-7],[0.252402,0.1249999],[0.375,0.1249998],[0.375,-1e-7],[0.25,-1e-7],[0.3774019,0.1250004],[0.5000001,0.1250003],[0.5000001,4e-7],[0.3750001,4e-7],[0.5024014,0.1249992],[0.6249996,0.1249994],[0.6249996,-6e-7],[0.4999996,-7e-7],[0.6274017,0.1249999],[0.7499999,0.1249999],[0.7499999,-1e-7],[0.6249999,0.0],[0.7524019,0.1250003],[0.875,0.1250001],[0.875,1e-7],[0.75,3e-7],[0.8773992,0.1250006],[0.9999973,0.125],[0.9999972,0.0],[0.8749972,7e-7],[0.0072538,0.2500005],[0.1250013,0.2500008],[0.1250013,0.1250008],[1.3e-6,0.1250006],[0.1322524,0.2500001],[0.2499999,0.25],[0.2499999,0.125],[0.1249998,0.125],[0.2572525,0.25],[0.375,0.2499999],[0.25,0.1249999],[0.3822519,0.2500008],[0.4999991,0.2500009],[0.4999991,0.1250009],[0.3749991,0.125001],[0.5072533,0.249999],[0.6250008,0.249999],[0.6250008,0.124999],[0.5000008,0.1249989],[0.6322554,0.2499938],[0.7500029,0.2499938],[0.750003,0.1249939],[0.625003,0.1249936],[0.7572542,0.2499809],[0.8750016,0.2499806],[0.8750016,0.1249806],[0.7500016,0.1249807],[0.8822576,0.2500161],[1.0000052,0.2500178],[1.0000056,0.1250179],[0.8750055,0.1250167],[0.0072527,0.2499994],[0.1249999,0.2499992],[0.1249999,0.1249992],[-1e-7,0.1249993],[0.1322526,0.2499998],[0.2500001,0.2499998],[0.2500001,0.1249998],[0.1250001,0.1249998],[0.2572528,0.2500007],[0.375,0.2500006],[0.375,0.1250006],[0.25,0.1250007],[0.3822517,0.2499988],[0.4999994,0.249999],[0.4999994,0.124999],[0.3749994,0.1249988],[0.5072518,0.2499995],[0.6249993,0.2499997],[0.6249993,0.1249997],[0.4999993,0.1249995],[0.6322595,0.2499987],[0.7500067,0.2499976],[0.7500064,0.1249975],[0.6250065,0.1249988],[0.7572475,0.2500154],[0.874995,0.2500158],[0.8749951,0.1250159],[0.7499951,0.1250153],[0.8822528,0.2500005],[1.0000002,0.2500001],[1.0000001,0.1250001],[0.8750001,0.1250005],[0.012493,0.3749341],[0.1249898,0.3749338],[0.1249897,0.2499337],[-0.0000103,0.249934],[0.1375058,0.3750084],[0.250003,0.3750084],[0.250003,0.2500083],[0.125003,0.2500083],[0.2625026,0.3750077],[0.3749994,0.3750079],[0.3749995,0.2500078],[0.2499994,0.2500078],[0.3875045,0.3749858],[0.5000017,0.3749856],[0.5000017,0.2499855],[0.3750017,0.2499856],[0.5125007,0.3750377],[0.6249976,0.3750379],[0.6249976,0.2500378],[0.4999976,0.2500379],[0.6375024,0.3749677],[0.7499995,0.3749675],[0.7499996,0.2499674],[0.6249995,0.2499673],[0.762514,0.3750255],[0.875011,0.3750256],[0.8750109,0.2500256],[0.7500109,0.2500258],[0.8874968,0.3750464],[0.9999937,0.375046],[0.9999937,0.250046],[0.8749937,0.2500461],[0.0125064,0.3750578],[0.1250035,0.3750577],[0.1250032,0.2500573],[3.5e-6,0.2500579],[0.1375101,0.375048],[0.2500069,0.3750477],[0.2500069,0.2500476],[0.1250065,0.2500476],[0.2625036,0.3750317],[0.3750011,0.3750317],[0.375001,0.2500316],[0.250001,0.2500317],[0.3875105,0.3749474],[0.5000072,0.3749476],[0.5000072,0.2499475],[0.3750072,0.2499475],[0.5125012,0.3750159],[0.6249983,0.3750159],[0.6249983,0.2500158],[0.4999983,0.2500158],[0.6375052,0.3750465],[0.7500024,0.3750466],[0.7500024,0.2500466],[0.6250024,0.2500464],[0.7624886,0.3749701],[0.8749856,0.3749701],[0.8749856,0.24997],[0.7499856,0.2499703],[0.8874999,0.3750149],[0.9999969,0.375015],[0.9999969,0.2500149],[0.8749969,0.2500149],[0.0187023,0.500032],[0.1250062,0.5000321],[0.1250062,0.3750321],[6.2e-6,0.375032],[0.1436982,0.5000041],[0.250002,0.5000042],[0.250002,0.3750041],[0.125002,0.3750041],[0.2686957,0.4999999],[0.3749994,0.4999999],[0.3749994,0.3749999],[0.2499994,0.3749998],[0.3936969,0.5000002],[0.5000008,0.5],[0.5000008,0.375],[0.3750008,0.375],[0.5186965,0.5000094],[0.6250003,0.5000095],[0.6250002,0.3750095],[0.5000003,0.3750095],[0.6436951,0.4999727],[0.7499989,0.4999727],[0.749999,0.3749723],[0.6249989,0.3749726],[0.7686907,0.4999504],[0.8749945,0.4999507],[0.8749946,0.3749507],[0.7499945,0.37495],[0.893698,0.4999836],[1.0000018,0.4999837],[1.0000018,0.3749837],[0.8750018,0.3749837],[0.0186973,0.5000229],[0.1250009,0.5000226],[0.1250009,0.3750226],[9e-7,0.3750227],[0.1436941,0.4999769],[0.2499982,0.4999772],[0.2499982,0.3749771],[0.1249982,0.3749771],[0.2686953,0.4999987],[0.3749987,0.4999987],[0.3749987,0.3749986],[0.2499987,0.3749987],[0.3936936,0.5000388],[0.4999977,0.5000387],[0.4999977,0.3750387],[0.3749977,0.3750386],[0.5187,0.4999464],[0.6250035,0.4999465],[0.6250035,0.3749465],[0.5000035,0.3749466],[0.6436957,0.500001],[0.7499994,0.500001],[0.7499994,0.375001],[0.6249994,0.375001],[0.7687036,0.5000047],[0.8750074,0.5000045],[0.8750074,0.3750045],[0.7500074,0.3750044],[0.893695,0.5000156],[0.9999988,0.5000157],[0.9999988,0.3750156],[0.8749988,0.3750156],[0.0267858,0.6249912],[0.1249977,0.6249912],[0.1249976,0.4999913],[-2.4e-6,0.4999913],[0.1517836,0.6249841],[0.2499953,0.624984],[0.2499953,0.4999841],[0.1249953,0.4999841],[0.2767868,0.6249918],[0.3749985,0.6249918],[0.3749985,0.4999919],[0.2499985,0.4999919],[0.401786,0.6250005],[0.4999978,0.6250005],[0.4999979,0.5000006],[0.3749979,0.5000006],[0.5267888,0.6249911],[0.6250008,0.624991],[0.6250008,0.4999911],[0.5000008,0.4999911],[0.6517864,0.6249871],[0.7499983,0.624987],[0.7499983,0.499987],[0.6249984,0.499987],[0.7767878,0.6249942],[0.8749997,0.6249944],[0.8749997,0.4999944],[0.7499997,0.4999944],[0.901788,0.6249918],[0.9999999,0.6249921],[0.9999999,0.4999921],[0.8749999,0.4999921],[0.0267933,0.6250388],[0.1250048,0.6250383],[0.1250047,0.5000383],[4.7e-6,0.5000384],[0.1517918,0.6250024],[0.2500039,0.6250025],[0.2500039,0.5000026],[0.1250039,0.5000026],[0.2767865,0.6250018],[0.3749986,0.6250018],[0.3749986,0.5000019],[0.2499986,0.5000019],[0.4017946,0.6249676],[0.5000061,0.6249678],[0.5000061,0.4999679],[0.3750061,0.4999679],[0.5267857,0.6250177],[0.6249976,0.6250177],[0.6249976,0.5000177],[0.4999976,0.5000178],[0.6517909,0.6250222],[0.7500028,0.6250221],[0.7500028,0.5000222],[0.6250028,0.5000222],[0.7767897,0.6250078],[0.8750016,0.625008],[0.8750016,0.500008],[0.7500016,0.500008],[0.9017902,0.625013],[1.0000021,0.6250132],[1.0000021,0.5000132],[0.8750021,0.5000132],[0.0388976,0.7499972],[0.1249991,0.7499971],[0.1249991,0.6249971],[-9e-7,0.6249971],[0.1638973,0.7499955],[0.2499988,0.7499955],[0.2499988,0.6249954],[0.1249988,0.6249955],[0.2888961,0.7499997],[0.3749981,0.7499997],[0.3749981,0.6249996],[0.2499981,0.6249996],[0.4138967,0.7499997],[0.4999981,0.7499998],[0.4999981,0.6249998],[0.3749981,0.6249998],[0.5388978,0.7500035],[0.6249991,0.7500038],[0.6249991,0.6250037],[0.4999991,0.6250038],[0.6638982,0.7499985],[0.7499998,0.7499984],[0.7499998,0.6249984],[0.6249997,0.6249984],[0.7888982,0.7499986],[0.8749997,0.7499986],[0.8749997,0.6249986],[0.7499997,0.6249986],[0.9138986,0.7499972],[1.0,0.7499972],[1.0,0.6249972],[0.8750001,0.6249972],[0.038898,0.7499978],[0.1249997,0.749998],[0.1249998,0.6249979],[-3e-7,0.624998],[0.1638999,0.7500008],[0.250001,0.7500007],[0.250001,0.6250007],[0.125001,0.6250007],[0.2888994,0.7500074],[0.375001,0.7500073],[0.375001,0.6250073],[0.250001,0.6250073],[0.4138975,0.7500065],[0.4999992,0.7500064],[0.4999992,0.6250064],[0.3749992,0.6250064],[0.5388958,0.7500092],[0.6249974,0.7500091],[0.6249973,0.625009],[0.4999974,0.6250091],[0.6638987,0.7499882],[0.7500001,0.7499886],[0.7500001,0.6249886],[0.6250001,0.6249886],[0.7888984,0.7500006],[0.8749999,0.7500006],[0.8749999,0.6250006],[0.7499999,0.6250006],[0.913898,0.7499983],[0.9999995,0.7499983],[0.9999995,0.6249983],[0.8749995,0.6249983],[0.0612812,0.8750098],[0.1250061,0.87501],[0.1250061,0.75001],[6.1e-6,0.75001],[0.1862784,0.8750042],[0.250003,0.8750042],[0.250003,0.7500041],[0.125003,0.7500041],[0.3112748,0.8750062],[0.3749996,0.8750062],[0.3750003,0.750006],[0.2499996,0.7500061],[0.4362775,0.8750014],[0.5000011,0.8750018],[0.5000011,0.7500018],[0.3750017,0.7500015],[0.5612761,0.8750022],[0.6250008,0.8750019],[0.6250007,0.7500019],[0.5000007,0.7500019],[0.6862761,0.8750022],[0.7500006,0.8750019],[0.7500006,0.7500019],[0.6250005,0.7500019],[0.8112766,0.8749994],[0.8750011,0.8749996],[0.8750011,0.7499995],[0.7500011,0.7499995],[0.9362756,0.8749961],[1.0000002,0.8749962],[1.0000002,0.7499962],[0.8750002,0.7499962],[0.0612734,0.8749955],[0.1249976,0.8749954],[0.1249977,0.7499954],[-2.3e-6,0.7499954],[0.1862744,0.8749944],[0.2499989,0.8749945],[0.249999,0.7499944],[0.124999,0.7499944],[0.3112709,0.8749969],[0.3749953,0.8749968],[0.3749953,0.7499968],[0.2499953,0.7499968],[0.4362761,0.8749953],[0.5000006,0.8749953],[0.5000006,0.7499953],[0.3750006,0.7499952],[0.5612782,0.8749847],[0.6250023,0.8749851],[0.6250022,0.749985],[0.5000022,0.7499851],[0.6862727,0.8750028],[0.7499973,0.8750023],[0.7499973,0.7500023],[0.6249972,0.7500023],[0.8112737,0.8749987],[0.8749981,0.8749989],[0.8749981,0.7499989],[0.7499981,0.7499989],[0.9362704,0.8749923],[0.9999945,0.8749919],[0.9999945,0.7499919],[0.8749945,0.7499919],[0.125,1.0],[0.1249998,0.8750001],[-1e-7,0.875],[0.25,1.0],[0.2499999,0.8750001],[0.1249999,0.8750001],[0.375,1.0],[0.375,0.8750001],[0.25,0.8750001],[0.5,1.0],[0.499999,0.8750004],[0.3750001,0.8750001],[0.625,1.0],[0.6250001,0.875],[0.4999995,0.8750005],[0.75,1.0],[0.75,0.875],[0.875,1.0],[0.8749999,0.875],[1.0,1.0],[1.0000008,0.8750005],[0.1249999,0.875],[1.2e-6,0.8750004],[0.5000001,0.8750001],[0.6249994,0.8750005],[0.5000002,0.8750001],[0.7500001,0.875],[0.6249999,0.8750006],[0.9999999,0.875]]},"geometry-templates":{"templates":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[16,17,4,3]],[[17,18,5,4]],[[18,19,6,5]],[[19,20,7,6]],[[21,22,9,8]],[[22,23,10,9]],[[23,24,11,10]],[[24,25,12,11]],[[25,26,13,12]],[[26,27,14,13]],[[27,28,15,14]],[[28,29,0,15]],[[29,30,1,0]],[[30,31,2,1]],[[31,16,3,2]],[[19,18,17,16,31,30,29,28,27,26,25,24,23,22,21,20]],[[20,21,8,7]],[[32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]],[[48,49,36,35]],[[49,50,37,36]],[[50,51,38,37]],[[51,52,39,38]],[[52,53,40,39]],[[53,54,41,40]],[[54,55,42,41]],[[55,56,43,42]],[[56,57,44,43]],[[57,58,45,44]],[[58,59,46,45]],[[59,60,47,46]],[[60,61,32,47]],[[61,62,33,32]],[[62,63,34,33]],[[63,48,35,34]],[[51,50,49,48,63,62,61,60,59,58,57,56,55,54,53,52]]],"material":{"visual":{"values":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]}},"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[0,24,25,26,27]],[[0,28,29,30,31]],[[0,32,33,34,35]],[[0,36,37,38,39]],[[0,40,41,42,43]],[[0,44,45,46,47]],[[0,48,49,50,51]],[[0,52,53,54,55]],[[0,56,57,58,59]],[[0,60,61,62,63]],[[0,64,65,66,67]],[[0,68,69,70,71]],[[0,72,73,74,75]],[[null]],[[0,76,77,78,79]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]]]}}},{"type":"MultiSurface","lod":"3","boundaries":[[[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79]],[[80,81,68,67]],[[81,82,69,68]],[[82,83,70,69]],[[83,84,71,70]],[[84,85,72,71]],[[85,86,73,72]],[[86,87,74,73]],[[87,88,75,74]],[[88,89,76,75]],[[89,90,77,76]],[[90,91,78,77]],[[91,92,79,78]],[[92,93,64,79]],[[93,94,65,64]],[[94,95,66,65]],[[95,80,67,66]],[[83,82,81,80,95,94,93,92,91,90,89,88,87,86,85,84]],[[96,97,98]],[[96,98,99]],[[96,99,100]],[[96,100,101]],[[96,101,102]],[[96,102,103]],[[96,103,104]],[[96,104,105]],[[96,105,106]],[[96,106,107]],[[96,107,108]],[[96,108,109]],[[96,109,110]],[[96,110,111]],[[96,111,112]],[[96,112,97]],[[113,114,98,97]],[[114,115,99,98]],[[115,116,100,99]],[[116,117,101,100]],[[117,118,102,101]],[[118,119,103,102]],[[119,120,104,103]],[[120,121,105,104]],[[121,122,106,105]],[[122,123,107,106]],[[123,124,108,107]],[[124,125,109,108]],[[125,126,110,109]],[[126,127,111,110]],[[127,128,112,111]],[[128,113,97,112]],[[129,130,114,113]],[[130,131,115,114]],[[131,132,116,115]],[[132,133,117,116]],[[133,134,118,117]],[[134,135,119,118]],[[135,136,120,119]],[[136,137,121,120]],[[137,138,122,121]],[[138,139,123,122]],[[139,140,124,123]],[[140,141,125,124]],[[141,142,126,125]],[[142,143,127,126]],[[143,144,128,127]],[[144,129,113,128]],[[145,146,130,129]],[[146,147,131,130]],[[147,148,132,131]],[[148,149,133,132]],[[149,150,134,133]],[[150,151,135,134]],[[151,152,136,135]],[[152,153,137,136]],[[153,154,138,137]],[[154,155,139,138]],[[155,156,140,139]],[[156,157,141,140]],[[157,158,142,141]],[[158,159,143,142]],[[159,160,144,143]],[[160,145,129,144]],[[161,162,146,145]],[[162,163,147,146]],[[163,164,148,147]],[[164,165,149,148]],[[165,166,150,149]],[[166,167,151,150]],[[167,168,152,151]],[[168,169,153,152]],[[169,170,154,153]],[[170,171,155,154]],[[171,172,156,155]],[[172,173,157,156]],[[173,174,158,157]],[[174,175,159,158]],[[175,176,160,159]],[[176,161,145,160]],[[177,178,162,161]],[[178,179,163,162]],[[179,180,164,163]],[[180,181,165,164]],[[181,182,166,165]],[[182,183,167,166]],[[183,184,168,167]],[[184,185,169,168]],[[185,186,170,169]],[[186,187,171,170]],[[187,188,172,171]],[[188,189,173,172]],[[189,190,174,173]],[[190,191,175,174]],[[191,192,176,175]],[[192,177,161,176]],[[193,194,178,177]],[[194,195,179,178]],[[195,196,180,179]],[[196,197,181,180]],[[197,198,182,181]],[[198,199,183,182]],[[199,200,184,183]],[[200,201,185,184]],[[201,202,186,185]],[[202,203,187,186]],[[203,204,188,187]],[[204,205,189,188]],[[205,206,190,189]],[[206,207,191,190]],[[207,208,192,191]],[[208,193,177,192]],[[209,210,194,193]],[[210,211,195,194]],[[211,212,196,195]],[[212,213,197,196]],[[213,214,198,197]],[[214,215,199,198]],[[215,216,200,199]],[[216,217,201,200]],[[217,218,202,201]],[[218,219,203,202]],[[219,220,204,203]],[[220,221,205,204]],[[221,222,206,205]],[[222,223,207,206]],[[223,224,208,207]],[[224,209,193,208]],[[225,226,210,209]],[[226,227,211,210]],[[227,228,212,211]],[[228,229,213,212]],[[229,230,214,213]],[[230,231,215,214]],[[231,232,216,215]],[[232,233,217,216]],[[233,234,218,217]],[[234,235,219,218]],[[235,236,220,219]],[[236,237,221,220]],[[237,238,222,221]],[[238,239,223,222]],[[239,240,224,223]],[[240,225,209,224]],[[241,242,226,225]],[[242,243,227,226]],[[243,244,228,227]],[[244,245,229,228]],[[245,246,230,229]],[[246,247,231,230]],[[247,248,232,231]],[[248,249,233,232]],[[249,250,234,233]],[[250,251,235,234]],[[251,252,236,235]],[[252,253,237,236]],[[253,254,238,237]],[[254,255,239,238]],[[255,256,240,239]],[[256,241,225,240]],[[257,258,242,241]],[[258,259,243,242]],[[259,260,244,243]],[[260,261,245,244]],[[261,262,246,245]],[[262,263,247,246]],[[263,264,248,247]],[[264,265,249,248]],[[265,266,250,249]],[[266,267,251,250]],[[267,268,252,251]],[[268,269,253,252]],[[269,270,254,253]],[[270,271,255,254]],[[271,272,256,255]],[[272,257,241,256]],[[273,274,258,257]],[[274,275,259,258]],[[275,276,260,259]],[[276,277,261,260]],[[277,278,262,261]],[[278,279,263,262]],[[279,280,264,263]],[[280,281,265,264]],[[281,282,266,265]],[[282,283,267,266]],[[283,284,268,267]],[[284,285,269,268]],[[285,286,270,269]],[[286,287,271,270]],[[287,288,272,271]],[[288,273,257,272]],[[289,290,274,273]],[[290,291,275,274]],[[291,292,276,275]],[[292,293,277,276]],[[293,294,278,277]],[[294,295,279,278]],[[295,296,280,279]],[[296,297,281,280]],[[297,298,282,281]],[[298,299,283,282]],[[299,300,284,283]],[[300,301,285,284]],[[301,302,286,285]],[[302,303,287,286]],[[303,304,288,287]],[[304,289,273,288]],[[305,306,290,289]],[[306,307,291,290]],[[307,308,292,291]],[[308,309,293,292]],[[309,310,294,293]],[[310,311,295,294]],[[311,312,296,295]],[[312,313,297,296]],[[313,314,298,297]],[[314,315,299,298]],[[315,316,300,299]],[[316,317,301,300]],[[317,318,302,301]],[[318,319,303,302]],[[319,320,304,303]],[[320,305,289,304]],[[321,322,306,305]],[[322,323,307,306]],[[323,324,308,307]],[[324,325,309,308]],[[325,326,310,309]],[[326,327,311,310]],[[327,328,312,311]],[[328,329,313,312]],[[329,330,314,313]],[[330,331,315,314]],[[331,332,316,315]],[[332,333,317,316]],[[333,334,318,317]],[[334,335,319,318]],[[335,336,320,319]],[[336,321,305,320]],[[337,322,321]],[[337,323,322]],[[337,324,323]],[[337,325,324]],[[337,326,325]],[[337,327,326]],[[337,328,327]],[[337,329,328]],[[337,330,329]],[[337,331,330]],[[337,332,331]],[[337,333,332]],[[337,334,333]],[[337,335,334]],[[337,336,335]],[[337,321,336]]],"material":{"visual":{"value":2}},"texture":{"visual":{"values":[[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[1,80,81,82]],[[1,83,82,84]],[[1,85,84,86]],[[1,87,86,88]],[[1,89,88,90]],[[1,91,90,92]],[[1,93,92,94]],[[1,95,94,96]],[[1,80,81,82]],[[1,83,82,84]],[[1,85,84,86]],[[1,87,86,88]],[[1,89,88,90]],[[1,91,90,92]],[[1,93,92,94]],[[1,95,94,96]],[[1,97,98,99,100]],[[1,101,102,103,104]],[[1,105,106,107,108]],[[1,109,110,111,112]],[[1,113,114,115,116]],[[1,117,118,119,120]],[[1,121,122,123,124]],[[1,125,126,127,128]],[[1,129,130,131,132]],[[1,133,134,135,136]],[[1,137,138,139,140]],[[1,141,142,143,86]],[[1,144,145,146,147]],[[1,148,149,150,151]],[[1,152,153,154,155]],[[1,156,157,158,159]],[[1,160,161,162,163]],[[1,164,165,166,167]],[[1,168,169,170,171]],[[1,172,173,174,175]],[[1,176,177,178,179]],[[1,180,181,182,183]],[[1,184,185,186,187]],[[1,188,189,190,191]],[[1,192,193,194,195]],[[1,196,197,198,199]],[[1,200,201,202,137]],[[1,203,204,205,206]],[[1,207,208,209,210]],[[1,211,212,213,214]],[[1,215,216,217,218]],[[1,219,220,221,222]],[[1,223,224,225,226]],[[1,227,228,229,230]],[[1,231,232,233,234]],[[1,235,236,237,238]],[[1,239,240,241,242]],[[1,243,244,245,246]],[[1,247,248,249,250]],[[1,251,252,253,254]],[[1,255,256,257,258]],[[1,259,260,261,262]],[[1,263,264,265,266]],[[1,267,268,269,270]],[[1,271,272,273,274]],[[1,275,276,277,278]],[[1,279,280,281,282]],[[1,283,284,285,286]],[[1,287,288,289,290]],[[1,291,292,293,294]],[[1,295,296,297,298]],[[1,299,300,301,302]],[[1,303,304,305,306]],[[1,307,308,309,310]],[[1,311,312,313,314]],[[1,315,316,317,318]],[[1,319,320,321,322]],[[1,323,324,325,326]],[[1,327,328,329,330]],[[1,331,332,333,334]],[[1,335,336,337,338]],[[1,339,340,341,342]],[[1,343,344,345,346]],[[1,347,348,349,350]],[[1,351,352,353,354]],[[1,355,356,357,358]],[[1,359,360,361,362]],[[1,363,364,365,366]],[[1,367,368,369,370]],[[1,371,372,373,374]],[[1,375,376,377,378]],[[1,379,380,381,382]],[[1,383,384,385,386]],[[1,387,388,389,390]],[[1,391,392,393,394]],[[1,395,396,397,398]],[[1,399,400,401,402]],[[1,403,404,405,406]],[[1,407,408,409,410]],[[1,411,412,413,414]],[[1,415,416,417,418]],[[1,419,420,421,422]],[[1,423,424,425,426]],[[1,427,428,429,430]],[[1,431,432,433,434]],[[1,435,436,437,438]],[[1,439,440,441,442]],[[1,443,444,445,446]],[[1,447,448,449,450]],[[1,451,452,453,454]],[[1,455,456,457,458]],[[1,459,460,461,462]],[[1,431,463,464,465]],[[1,466,467,468,469]],[[1,470,471,472,473]],[[1,474,475,476,477]],[[1,478,479,480,481]],[[1,482,483,484,485]],[[1,486,487,488,489]],[[1,490,491,492,493]],[[1,494,495,496,497]],[[1,498,499,500,501]],[[1,502,503,504,505]],[[1,506,507,508,509]],[[1,510,511,512,513]],[[1,514,515,516,517]],[[1,518,519,520,521]],[[1,522,523,524,525]],[[1,526,527,528,529]],[[1,530,531,532,533]],[[1,534,535,536,537]],[[1,538,539,540,541]],[[1,542,543,544,545]],[[1,546,547,548,549]],[[1,550,86,87,85]],[[1,551,552,553,554]],[[1,555,556,557,558]],[[1,559,560,561,562]],[[1,563,564,565,566]],[[1,567,568,569,570]],[[1,571,572,573,574]],[[1,575,576,577,578]],[[1,579,580,581,582]],[[1,583,584,585,586]],[[1,587,588,589,590]],[[1,591,592,593,594]],[[1,595,596,597,598]],[[1,599,600,601,602]],[[1,603,604,605,606]],[[1,607,608,609,610]],[[1,611,612,86,613]],[[1,614,615,616,617]],[[1,618,619,620,621]],[[1,622,623,624,625]],[[1,626,627,628,629]],[[1,630,631,632,633]],[[1,634,635,636,637]],[[1,638,639,640,641]],[[1,642,643,644,645]],[[1,646,647,648,649]],[[1,650,651,652,653]],[[1,654,655,656,657]],[[1,658,659,660,661]],[[1,662,663,664,665]],[[1,666,667,668,669]],[[1,670,671,672,673]],[[1,674,675,676,677]],[[1,678,679,680,681]],[[1,682,683,684,685]],[[1,686,687,688,689]],[[1,690,691,692,693]],[[1,694,695,696,697]],[[1,698,699,700,701]],[[1,702,703,704,705]],[[1,706,707,708,709]],[[1,710,711,712,713]],[[1,714,715,716,717]],[[1,718,719,720,721]],[[1,722,723,724,725]],[[1,726,727,728,729]],[[1,730,731,732,733]],[[1,734,735,736,737]],[[1,738,739,740,741]],[[1,742,743,744,745]],[[1,746,747,748,749]],[[1,750,751,752,753]],[[1,754,755,756,757]],[[1,758,759,760,761]],[[1,762,763,764,765]],[[1,766,767,768,769]],[[1,770,771,772,773]],[[1,774,775,776,777]],[[1,778,779,780,781]],[[1,782,783,784,785]],[[1,786,787,788,789]],[[1,790,791,792,793]],[[1,794,795,796,797]],[[1,798,799,800,801]],[[1,802,803,804,805]],[[1,806,807,808,809]],[[1,810,811,812,813]],[[1,814,815,816,817]],[[1,818,819,820,821]],[[1,822,823,824,825]],[[1,826,827,828,829]],[[1,830,831,832,833]],[[1,834,835,836,837]],[[1,838,839,840,841]],[[1,842,843,844,845]],[[1,846,847,848,849]],[[1,850,851,852,853]],[[1,854,855,856,857]],[[1,858,859,860,861]],[[1,862,863,864,865]],[[1,866,867,868,869]],[[1,870,871,872,873]],[[1,874,875,876,877]],[[1,878,879,880,881]],[[1,882,883,884,885]],[[1,886,887,888,889]],[[1,890,891,892,893]],[[1,894,895,896,897]],[[1,898,899,900,901]],[[1,902,903,904,905]],[[1,906,907,908,909]],[[1,910,911,912,913]],[[1,914,915,916,917]],[[1,918,919,920,921]],[[1,922,923,924,925]],[[1,926,927,928,929]],[[1,930,931,932,933]],[[1,934,935,936,937]],[[1,938,939,940,941]],[[1,942,943,944,945]],[[1,946,947,948,949]],[[1,950,951,952,953]],[[1,954,955,956,957]],[[1,958,959,960,961]],[[1,962,963,964,965]],[[1,966,967,968,969]],[[1,970,971,972,973]],[[1,974,975,976,977]],[[1,978,979,980,981]],[[1,982,983,984,985]],[[1,986,987,988]],[[1,989,990,991]],[[1,992,993,994]],[[1,995,996,997]],[[1,998,999,1000]],[[1,1001,1002,999]],[[1,1003,1004,1002]],[[1,1005,1006,1004]],[[1,986,1007,1008]],[[1,989,990,991]],[[1,992,993,994]],[[1,995,1009,997]],[[1,998,1010,1011]],[[1,1001,1012,1013]],[[1,1003,1004,1012]],[[1,1005,1014,1004]]]}}},{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[16,17,4,3]],[[17,18,5,4]],[[18,19,6,5]],[[19,20,7,6]],[[21,22,9,8]],[[22,23,10,9]],[[23,24,11,10]],[[24,25,12,11]],[[25,26,13,12]],[[26,27,14,13]],[[27,28,15,14]],[[28,29,0,15]],[[29,30,1,0]],[[30,31,2,1]],[[31,16,3,2]],[[19,18,17,16,31,30,29,28,27,26,25,24,23,22,21,20]],[[20,21,8,7]],[[32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]],[[48,49,36,35]],[[49,50,37,36]],[[50,51,38,37]],[[51,52,39,38]],[[52,53,40,39]],[[53,54,41,40]],[[54,55,42,41]],[[55,56,43,42]],[[56,57,44,43]],[[57,58,45,44]],[[58,59,46,45]],[[59,60,47,46]],[[60,61,32,47]],[[61,62,33,32]],[[62,63,34,33]],[[63,48,35,34]],[[51,50,49,48,63,62,61,60,59,58,57,56,55,54,53,52]]],"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[0,24,25,26,27]],[[0,28,29,30,31]],[[0,32,33,34,35]],[[0,36,37,38,39]],[[0,40,41,42,43]],[[0,44,45,46,47]],[[0,48,49,50,51]],[[0,52,53,54,55]],[[0,56,57,58,59]],[[0,60,61,62,63]],[[0,64,65,66,67]],[[0,68,69,70,71]],[[0,72,73,74,75]],[[null]],[[0,76,77,78,79]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]]]}}}],"vertices-templates":[[0.112,0.121,0.502],[0.107,0.121,0.502],[0.103,0.119,0.502],[0.1,0.116,0.502],[0.099,0.112,0.502],[0.099,0.107,0.502],[0.101,0.103,0.502],[0.104,0.1,0.502],[0.108,0.099,0.502],[0.112,0.099,0.502],[0.116,0.101,0.502],[0.119,0.104,0.502],[0.121,0.108,0.502],[0.121,0.112,0.502],[0.119,0.116,0.502],[0.116,0.119,0.502],[0.016,0.17,0.13],[0.0,0.13,0.13],[0.001,0.086,0.13],[0.018,0.046,0.13],[0.049,0.016,0.13],[0.089,0.0,0.13],[0.133,0.001,0.13],[0.173,0.018,0.13],[0.203,0.049,0.13],[0.219,0.089,0.13],[0.219,0.133,0.13],[0.202,0.173,0.13],[0.17,0.203,0.13],[0.13,0.219,0.13],[0.086,0.219,0.13],[0.046,0.202,0.13],[0.112,0.124,0.186],[0.107,0.124,0.186],[0.101,0.122,0.186],[0.097,0.118,0.186],[0.095,0.112,0.186],[0.095,0.107,0.186],[0.097,0.101,0.186],[0.102,0.097,0.186],[0.107,0.095,0.186],[0.113,0.095,0.186],[0.118,0.097,0.186],[0.122,0.102,0.186],[0.124,0.107,0.186],[0.124,0.113,0.186],[0.122,0.118,0.186],[0.118,0.122,0.186],[0.091,0.122,0.0],[0.088,0.114,0.0],[0.088,0.105,0.0],[0.091,0.097,0.0],[0.098,0.091,0.0],[0.106,0.088,0.0],[0.114,0.088,0.0],[0.122,0.091,0.0],[0.128,0.098,0.0],[0.132,0.106,0.0],[0.132,0.114,0.0],[0.128,0.122,0.0],[0.122,0.128,0.0],[0.114,0.132,0.0],[0.105,0.132,0.0],[0.097,0.128,0.0],[0.115,0.127,0.188],[0.109,0.127,0.188],[0.104,0.125,0.188],[0.1,0.121,0.188],[0.098,0.115,0.188],[0.098,0.109,0.188],[0.1,0.104,0.188],[0.104,0.1,0.188],[0.11,0.098,0.188],[0.116,0.098,0.188],[0.121,0.1,0.188],[0.125,0.104,0.188],[0.127,0.11,0.188],[0.127,0.116,0.188],[0.125,0.121,0.188],[0.121,0.125,0.188],[0.094,0.125,0.0],[0.09,0.117,0.0],[0.09,0.108,0.0],[0.094,0.1,0.0],[0.1,0.094,0.0],[0.108,0.09,0.0],[0.117,0.09,0.0],[0.125,0.094,0.0],[0.131,0.1,0.0],[0.135,0.108,0.0],[0.135,0.117,0.0],[0.131,0.125,0.0],[0.125,0.131,0.0],[0.117,0.135,0.0],[0.108,0.135,0.0],[0.1,0.131,0.0],[0.004,0.144,0.301],[0.006,0.143,0.33],[0.008,0.151,0.327],[0.01,0.158,0.321],[0.012,0.162,0.312],[0.012,0.164,0.301],[0.012,0.162,0.29],[0.01,0.158,0.281],[0.008,0.151,0.274],[0.006,0.143,0.272],[0.004,0.135,0.274],[0.002,0.128,0.281],[0.0,0.123,0.29],[0.0,0.122,0.301],[0.0,0.123,0.312],[0.002,0.128,0.321],[0.004,0.135,0.327],[0.012,0.141,0.357],[0.017,0.157,0.353],[0.021,0.171,0.341],[0.023,0.18,0.322],[0.024,0.183,0.301],[0.023,0.18,0.279],[0.021,0.171,0.261],[0.017,0.157,0.249],[0.012,0.141,0.245],[0.008,0.125,0.249],[0.004,0.112,0.261],[0.001,0.103,0.279],[0.0,0.1,0.301],[0.001,0.103,0.322],[0.004,0.112,0.341],[0.008,0.125,0.353],[0.022,0.138,0.382],[0.029,0.161,0.376],[0.034,0.181,0.359],[0.038,0.194,0.332],[0.04,0.199,0.301],[0.038,0.194,0.27],[0.034,0.181,0.243],[0.029,0.161,0.226],[0.022,0.138,0.219],[0.016,0.115,0.226],[0.01,0.096,0.243],[0.006,0.083,0.27],[0.005,0.078,0.301],[0.006,0.083,0.332],[0.01,0.096,0.359],[0.016,0.115,0.376],[0.036,0.134,0.405],[0.044,0.164,0.397],[0.051,0.189,0.374],[0.056,0.205,0.341],[0.058,0.211,0.301],[0.056,0.205,0.261],[0.051,0.189,0.228],[0.044,0.164,0.205],[0.036,0.134,0.197],[0.027,0.105,0.205],[0.02,0.08,0.228],[0.015,0.064,0.261],[0.014,0.058,0.301],[0.015,0.064,0.341],[0.02,0.08,0.374],[0.027,0.105,0.397],[0.052,0.13,0.423],[0.062,0.164,0.414],[0.07,0.194,0.387],[0.076,0.213,0.348],[0.078,0.22,0.301],[0.076,0.213,0.254],[0.07,0.194,0.215],[0.062,0.164,0.188],[0.052,0.13,0.179],[0.042,0.095,0.188],[0.034,0.066,0.215],[0.028,0.046,0.254],[0.026,0.04,0.301],[0.028,0.046,0.348],[0.034,0.066,0.387],[0.042,0.095,0.414],[0.071,0.124,0.436],[0.082,0.163,0.426],[0.091,0.195,0.397],[0.097,0.217,0.353],[0.1,0.225,0.301],[0.097,0.217,0.249],[0.091,0.195,0.205],[0.082,0.163,0.176],[0.071,0.124,0.165],[0.06,0.086,0.176],[0.051,0.053,0.205],[0.044,0.032,0.249],[0.042,0.024,0.301],[0.044,0.032,0.353],[0.051,0.053,0.397],[0.06,0.086,0.426],[0.091,0.119,0.445],[0.103,0.159,0.434],[0.113,0.194,0.403],[0.119,0.217,0.356],[0.122,0.225,0.301],[0.119,0.217,0.246],[0.113,0.194,0.199],[0.103,0.159,0.168],[0.091,0.119,0.157],[0.08,0.078,0.168],[0.07,0.043,0.199],[0.063,0.02,0.246],[0.061,0.012,0.301],[0.063,0.02,0.356],[0.07,0.043,0.403],[0.08,0.078,0.434],[0.112,0.112,0.448],[0.124,0.154,0.436],[0.134,0.189,0.405],[0.141,0.213,0.357],[0.144,0.221,0.301],[0.141,0.213,0.245],[0.134,0.189,0.197],[0.124,0.154,0.165],[0.112,0.112,0.154],[0.101,0.071,0.165],[0.091,0.036,0.197],[0.084,0.012,0.245],[0.081,0.004,0.301],[0.084,0.012,0.357],[0.091,0.036,0.405],[0.101,0.071,0.436],[0.134,0.106,0.445],[0.145,0.147,0.434],[0.155,0.182,0.403],[0.162,0.205,0.356],[0.164,0.213,0.301],[0.162,0.205,0.246],[0.155,0.182,0.199],[0.145,0.147,0.168],[0.134,0.106,0.157],[0.122,0.066,0.168],[0.112,0.031,0.199],[0.106,0.008,0.246],[0.103,0.0,0.301],[0.106,0.008,0.356],[0.112,0.031,0.403],[0.122,0.066,0.434],[0.154,0.101,0.436],[0.165,0.139,0.426],[0.174,0.171,0.397],[0.18,0.193,0.353],[0.183,0.201,0.301],[0.18,0.193,0.249],[0.174,0.171,0.205],[0.165,0.139,0.176],[0.154,0.101,0.165],[0.143,0.062,0.176],[0.134,0.03,0.205],[0.127,0.008,0.249],[0.125,0.0,0.301],[0.127,0.008,0.353],[0.134,0.03,0.397],[0.143,0.062,0.426],[0.173,0.095,0.423],[0.183,0.13,0.414],[0.191,0.159,0.387],[0.197,0.179,0.348],[0.199,0.185,0.301],[0.197,0.179,0.254],[0.191,0.159,0.215],[0.183,0.13,0.188],[0.173,0.095,0.179],[0.163,0.061,0.188],[0.154,0.031,0.215],[0.149,0.012,0.254],[0.147,0.005,0.301],[0.149,0.012,0.348],[0.154,0.031,0.387],[0.163,0.061,0.414],[0.189,0.091,0.405],[0.198,0.12,0.397],[0.205,0.145,0.374],[0.209,0.161,0.341],[0.211,0.167,0.301],[0.209,0.161,0.261],[0.205,0.145,0.228],[0.198,0.12,0.205],[0.189,0.091,0.197],[0.181,0.061,0.205],[0.174,0.036,0.228],[0.169,0.02,0.261],[0.167,0.014,0.301],[0.169,0.02,0.341],[0.174,0.036,0.374],[0.181,0.061,0.397],[0.203,0.087,0.382],[0.209,0.11,0.376],[0.215,0.129,0.359],[0.219,0.142,0.332],[0.22,0.147,0.301],[0.219,0.142,0.27],[0.215,0.129,0.243],[0.209,0.11,0.226],[0.203,0.087,0.219],[0.196,0.064,0.226],[0.19,0.044,0.243],[0.187,0.031,0.27],[0.185,0.026,0.301],[0.187,0.031,0.332],[0.19,0.044,0.359],[0.196,0.064,0.376],[0.213,0.084,0.357],[0.217,0.1,0.353],[0.221,0.113,0.341],[0.224,0.122,0.322],[0.225,0.125,0.301],[0.224,0.122,0.279],[0.221,0.113,0.261],[0.217,0.1,0.249],[0.213,0.084,0.245],[0.208,0.068,0.249],[0.204,0.054,0.261],[0.202,0.045,0.279],[0.201,0.042,0.301],[0.202,0.045,0.322],[0.204,0.054,0.341],[0.208,0.068,0.353],[0.219,0.082,0.33],[0.221,0.09,0.327],[0.223,0.097,0.321],[0.224,0.102,0.312],[0.225,0.103,0.301],[0.224,0.102,0.29],[0.223,0.097,0.281],[0.221,0.09,0.274],[0.219,0.082,0.272],[0.217,0.074,0.274],[0.215,0.067,0.281],[0.213,0.062,0.29],[0.213,0.061,0.301],[0.213,0.062,0.312],[0.215,0.067,0.321],[0.217,0.074,0.327],[0.221,0.081,0.301]]},"extensions":{}} +{"type":"CityJSON","version":"2.0","transform":{"scale":[0.001,0.001,0.001],"translate":[0.56,0.64,7.579]},"CityObjects":{},"vertices":[],"metadata":{"geographicalExtent":[0.56,0.64,7.579,12.64,7.68,9.103]},"appearance":{"materials":[{"name":"UUID_e58d9d68-2e8c-4b81-bec1-1892e9d71c81","diffuseColor":[0.49609375,0.4296875,0.296875],"emissiveColor":[0.0,0.0,0.0],"specularColor":[1.0,1.0,1.0],"transparency":0.0},{"name":"UUID_f55b5612-d5f9-4ef8-842d-ca4849d90b59","diffuseColor":[0.49609375,0.4296875,0.296875],"emissiveColor":[0.0,0.0,0.0],"specularColor":[1.0,1.0,1.0],"transparency":0.0}],"textures":[{"type":"JPG","image":"appearances/Vegetation_Juniper2.jpg","wrapMode":"wrap","textureType":"unknown","borderColor":[0.0,0.0,0.0,1.0]},{"type":"PNG","image":"appearances/MaerZ-0.png","wrapMode":"wrap","textureType":"unknown","borderColor":[0.0,0.0,0.0,1.0]}],"vertices-texture":[[0.038837,0.0129458],[0.0428056,0.0188853],[0.0441993,0.0258914],[0.0428056,0.0328975],[0.038837,0.038837],[0.0328975,0.0428057],[0.0258914,0.0441993],[0.0188852,0.0428057],[0.0129458,0.038837],[0.0089771,0.0328976],[0.0075835,0.0258914],[0.0089771,0.0188853],[0.0129457,0.0129458],[0.0188852,0.0089772],[0.0258913,0.0075836],[0.0328975,0.0089771],[-0.0285743,0.1513162],[0.0428595,0.1513165],[0.0107114,0.7826142],[0.0035681,0.7826143],[-0.0428609,0.1513161],[0.028573,0.1513166],[-0.0035751,0.7826142],[-0.0107185,0.7826141],[-0.0560601,0.1527158],[0.0153736,0.152716],[-0.0167737,0.7840137],[-0.0239171,0.7840136],[-0.066162,0.1553019],[0.0052719,0.1553021],[-0.0268747,0.7865998],[-0.0340181,0.7865997],[-0.0716283,0.1623381],[-0.0001945,0.1623383],[-0.0323392,0.7936359],[-0.0394826,0.7936359],[-0.0661614,0.1657173],[0.0052724,0.165717],[-0.0268707,0.7970147],[-0.0340141,0.7970149],[-0.0560588,0.1683033],[0.015375,0.1683031],[-0.0167675,0.7996008],[-0.0239109,0.7996008],[-0.0428596,0.1697029],[0.0285742,0.1697026],[-0.003568,0.8010004],[-0.0107113,0.8010004],[-0.028573,0.1697029],[0.042861,0.1697025],[0.0107186,0.8010004],[0.0035752,0.8010004],[-0.0153737,0.1683032],[0.0560601,0.168303],[0.0239171,0.7996008],[0.0167737,0.7996008],[-0.0052716,0.1657171],[0.0661622,0.1657169],[0.0340182,0.7970146],[0.0268748,0.7970148],[0.0001952,0.1623382],[0.071629,0.1623384],[0.0394841,0.7936358],[0.0323407,0.7936359],[0.0001953,0.1586811],[0.0716291,0.158681],[0.0394827,0.7899788],[0.0323393,0.7899787],[-0.0052725,0.1553019],[0.0661613,0.1553022],[0.0340141,0.7865999],[0.0268707,0.7865999],[-0.0153749,0.1527159],[0.0560588,0.1527161],[0.023911,0.7840139],[0.0167676,0.7840138],[-0.0716295,0.158681],[-0.0001958,0.1586808],[-0.0323407,0.7899786],[-0.0394841,0.7899788],[0.0,0.0],[0.0,0.125],[0.125,0.125],[0.125,0.0],[0.25,0.125],[0.25,0.0],[0.375,0.125],[0.375,0.0],[0.5,0.125],[0.5,0.0],[0.625,0.125],[0.625,0.0],[0.75,0.125],[0.75,0.0],[0.875,0.125],[0.875,0.0],[1.0,0.125],[2e-7,0.2499998],[0.1250002,0.2499998],[0.0637249,0.1249996],[2e-7,0.1249998],[0.1250012,0.2499989],[0.2500012,0.2499989],[0.1887258,0.1249988],[0.1250012,0.1249989],[0.2499995,0.2500005],[0.3749995,0.2500005],[0.3137239,0.1250005],[0.2499995,0.1250005],[0.3750003,0.2499996],[0.5000003,0.2499996],[0.4387248,0.1249996],[0.3750003,0.1249996],[0.5,0.2499999],[0.625,0.2499999],[0.5637245,0.1249999],[0.5,0.1249999],[0.6249999,0.2500003],[0.7499999,0.2500003],[0.6887244,0.1250003],[0.6249999,0.1250003],[0.7499972,0.2500061],[0.8749972,0.2500061],[0.8137217,0.1250059],[0.7499972,0.1250061],[0.8750046,0.2499957],[1.0000046,0.2499957],[0.9387289,0.124996],[0.8750046,0.1249957],[-9e-7,0.2499991],[0.1249991,0.2499991],[0.0637238,0.1249989],[-1e-6,0.1249991],[0.125,0.2499999],[0.25,0.2499999],[0.1887246,0.1249999],[0.125,0.1249999],[0.2499998,0.2500002],[0.3749998,0.2500002],[0.3137242,0.1250002],[0.2499998,0.1250002],[0.375,0.25],[0.5,0.25],[0.4387245,0.125],[0.4999999,0.2500008],[0.6249999,0.2500008],[0.5637241,0.1250005],[0.4999999,0.1250008],[0.6250003,0.2500008],[0.7500003,0.2500008],[0.6887249,0.125001],[0.6250003,0.1250008],[0.7499986,0.2499975],[0.8749986,0.2499975],[0.813723,0.1249973],[0.7499986,0.1249975],[0.8750002,0.25],[1.0000002,0.25],[0.9387245,0.1250004],[0.8750002,0.125],[-4e-7,0.375001],[0.1249996,0.375001],[0.086101,0.2500011],[-4e-7,0.250001],[0.1249992,0.3750015],[0.2499992,0.3750015],[0.2111003,0.2500016],[0.1249992,0.2500015],[0.250005,0.3749917],[0.375005,0.3749917],[0.3361069,0.2499917],[0.250005,0.2499917],[0.3749983,0.3750042],[0.4999983,0.3750042],[0.4610997,0.2500042],[0.3749983,0.2500042],[0.5000014,0.3749924],[0.6250014,0.3749924],[0.5861031,0.2499925],[0.5000014,0.2499924],[0.6249969,0.3750248],[0.7499969,0.3750248],[0.7110982,0.2500244],[0.6249969,0.2500248],[0.7499995,0.375002],[0.8749995,0.375002],[0.836101,0.250002],[0.7499995,0.250002],[0.8750009,0.3749988],[1.0000009,0.3749988],[0.9611024,0.2499988],[0.8750009,0.2499988],[0.0,0.3749988],[0.125,0.3749988],[0.0861014,0.2499989],[0.0,0.2499988],[0.1249971,0.3750053],[0.2499971,0.3750053],[0.2110986,0.2500054],[0.1249971,0.2500053],[0.2499998,0.3750002],[0.3749998,0.3750002],[0.3361014,0.2500002],[0.3749999,0.3750002],[0.4999999,0.3750002],[0.4611016,0.2500003],[0.3749999,0.2500002],[0.5000007,0.3749918],[0.6250007,0.3749917],[0.586102,0.2499915],[0.5000007,0.2499918],[0.6250001,0.375001],[0.7500001,0.375001],[0.7111017,0.2500011],[0.6250001,0.250001],[0.75,0.375],[0.875,0.375],[0.8361015,0.25],[0.75,0.25],[0.8749999,0.3749999],[0.9999999,0.3749999],[0.9611015,0.2499995],[0.8749999,0.2499999],[-1e-7,0.5000004],[0.1249999,0.5000004],[0.0982118,0.3750004],[-1e-7,0.3750004],[0.1250005,0.4999988],[0.2500005,0.4999988],[0.2232126,0.3749987],[0.1250005,0.3749988],[0.2499978,0.500002],[0.3749978,0.500002],[0.3482094,0.375002],[0.2499978,0.375002],[0.374998,0.5000033],[0.499998,0.5000033],[0.4732098,0.3750033],[0.374998,0.3750033],[0.5000016,0.4999863],[0.6250016,0.4999864],[0.5982135,0.3749864],[0.5000016,0.3749863],[0.6250012,0.4999839],[0.7500012,0.499984],[0.7232131,0.3749841],[0.6250012,0.3749839],[0.749997,0.5000188],[0.874997,0.5000187],[0.8482088,0.3750186],[0.7499969,0.3750188],[0.8749958,0.5000063],[0.9999958,0.5000062],[0.9732077,0.3750061],[0.8749958,0.3750063],[-6e-7,0.5000052],[0.1249994,0.5000052],[0.0982113,0.3750051],[-6e-7,0.3750052],[0.1249985,0.5000035],[0.2499985,0.5000035],[0.2232102,0.3750036],[0.1249985,0.3750035],[0.2500035,0.4999929],[0.3750035,0.4999929],[0.3482156,0.374993],[0.2500035,0.3749929],[0.3750012,0.4999962],[0.5000012,0.4999962],[0.4732126,0.3749959],[0.3750012,0.3749962],[0.4999997,0.5000046],[0.6249997,0.5000047],[0.5982117,0.3750048],[0.4999997,0.3750046],[0.6250004,0.5000088],[0.7500004,0.5000088],[0.7232124,0.375009],[0.6250004,0.3750088],[0.7499999,0.4999961],[0.8749999,0.4999961],[0.8482117,0.3749959],[0.7499999,0.3749961],[0.8749997,0.500001],[0.9999997,0.500001],[0.9732115,0.3750014],[0.8749997,0.375001],[-1.8e-6,0.6250085],[0.1249982,0.6250085],[0.1063021,0.5000083],[-1.8e-6,0.5000085],[0.1249994,0.6250016],[0.2499994,0.6250016],[0.2313032,0.5000016],[0.1249994,0.5000016],[0.2499986,0.6250045],[0.3749986,0.6250045],[0.3563023,0.5000045],[0.2499986,0.5000045],[0.3750039,0.62498],[0.5000039,0.62498],[0.4813078,0.49998],[0.3750039,0.49998],[0.4999949,0.6250572],[0.6249949,0.6250571],[0.6062984,0.500057],[0.4999949,0.5000572],[0.6250005,0.6249912],[0.7500005,0.6249912],[0.7313042,0.4999912],[0.6250005,0.4999912],[0.7500062,0.6249449],[0.8750062,0.6249452],[0.8563101,0.4999454],[0.7500063,0.4999449],[0.8749974,0.6250048],[0.9999974,0.6250047],[0.9813011,0.5000047],[0.8749973,0.5000048],[2.9e-6,0.6249813],[0.1250029,0.6249812],[0.1063067,0.4999812],[2.9e-6,0.4999813],[0.1250012,0.624997],[0.2500012,0.624997],[0.2313049,0.4999969],[0.1250012,0.499997],[0.2499976,0.6249981],[0.3749976,0.6249981],[0.3563013,0.499998],[0.2499976,0.499998],[0.3749992,0.6250036],[0.4999992,0.6250036],[0.4813031,0.5000037],[0.3749992,0.5000036],[0.5000019,0.624968],[0.6250019,0.624968],[0.6063054,0.4999676],[0.5000019,0.499968],[0.6250003,0.6250114],[0.7500003,0.6250114],[0.7313041,0.5000114],[0.6250003,0.5000114],[0.7500002,0.6250246],[0.8750001,0.6250248],[0.856304,0.5000252],[0.7500002,0.5000246],[0.8750006,0.6249978],[1.0000006,0.6249978],[0.9813043,0.4999977],[0.8750006,0.4999978],[-2.9e-6,0.750013],[0.1249971,0.750013],[0.1124942,0.6250129],[-2.9e-6,0.625013],[0.1249996,0.7500013],[0.2499996,0.7500014],[0.2374967,0.6250013],[0.1249996,0.6250013],[0.2499993,0.7500024],[0.3749993,0.7500024],[0.3624962,0.6250024],[0.2499993,0.6250024],[0.3749991,0.7500177],[0.4999991,0.7500177],[0.4874961,0.6250177],[0.3749991,0.6250177],[0.5000014,0.7499832],[0.6250014,0.7499833],[0.6124985,0.6249833],[0.5000014,0.6249832],[0.6249993,0.7500147],[0.7499993,0.7500146],[0.7374963,0.6250146],[0.6249993,0.6250147],[0.750001,0.7499869],[0.875001,0.749987],[0.8624981,0.624987],[0.750001,0.6249869],[0.8749972,0.7500094],[0.9999972,0.7500093],[0.9874941,0.6250092],[0.8749971,0.6250094],[-8.6e-6,0.7500417],[0.1249914,0.7500417],[0.1124883,0.6250418],[-8.5e-6,0.6250417],[0.1250028,0.7499924],[0.2500028,0.7499924],[0.2374999,0.6249924],[0.1250028,0.6249924],[0.25,0.7500001],[0.375,0.7500001],[0.3624968,0.6250001],[0.25,0.6250001],[0.3749979,0.7500113],[0.4999979,0.7500112],[0.4874951,0.6250114],[0.3749979,0.6250113],[0.499999,0.7500155],[0.624999,0.7500155],[0.6124961,0.6250156],[0.499999,0.6250155],[0.6249997,0.7499841],[0.7499997,0.7499841],[0.7374967,0.6249841],[0.6249997,0.6249841],[0.749999,0.749947],[0.8749991,0.7499467],[0.8624959,0.6249464],[0.749999,0.624947],[0.874998,0.7500257],[0.999998,0.750026],[0.987495,0.6250263],[0.874998,0.6250257],[-2.4e-6,0.8750021],[0.1249976,0.875002],[0.1177448,0.7500021],[-2.5e-6,0.7500021],[0.1249997,0.875],[0.2499997,0.8749999],[0.2427469,0.7500001],[0.1249997,0.75],[0.25,0.8750002],[0.375,0.8750002],[0.3677472,0.7500003],[0.2499997,0.7500004],[0.3750008,0.8749994],[0.5000008,0.8749995],[0.4927485,0.7499995],[0.3750009,0.7499994],[0.5000005,0.8749997],[0.6250005,0.8749999],[0.617748,0.75],[0.5000006,0.7499997],[0.6249992,0.8749999],[0.7499992,0.8749995],[0.7427465,0.7499995],[0.6249991,0.75],[0.7500006,0.8750018],[0.8750006,0.8750013],[0.8677478,0.7500013],[0.7500004,0.7500019],[0.8750028,0.8749959],[1.0000028,0.8749957],[0.9927501,0.7499957],[0.8750027,0.7499959],[-1.6e-6,0.875001],[0.1249984,0.8750011],[0.117746,0.7500011],[-1.6e-6,0.750001],[0.1249998,0.8750002],[0.2499998,0.8750003],[0.2427473,0.7500003],[0.1249998,0.7500002],[0.25,0.8749995],[0.375,0.8749995],[0.3677472,0.7499994],[0.25,0.7499995],[0.3749999,0.8750001],[0.4999999,0.8750001],[0.4927473,0.7500001],[0.3749999,0.7500001],[0.6250005,0.8749998],[0.6177479,0.7499999],[0.5000005,0.7499997],[0.6250009,0.8750001],[0.7500008,0.8750005],[0.7427484,0.7500006],[0.6250009,0.7500002],[0.7499995,0.8749976],[0.8749995,0.8749975],[0.8677469,0.7499975],[0.7499995,0.7499976],[0.8750041,0.8749874],[1.0000041,0.8749875],[0.9927516,0.7499873],[0.8750041,0.7499875],[1.7e-6,0.9999991],[0.1250017,0.9999992],[0.1226,0.8749991],[1.7e-6,0.874999],[0.1250002,1.0],[0.2500002,1.0000001],[0.2475984,0.875],[0.1250002,0.875],[0.2500001,0.9999998],[0.3750001,0.9999999],[0.3725984,0.8749997],[0.2500001,0.8749998],[0.374999,1.0000007],[0.499999,1.0000006],[0.4975969,0.8750007],[0.374999,0.8750007],[0.5000005,0.9999997],[0.6250005,0.9999998],[0.6225988,0.8749997],[0.5000005,0.8749996],[0.6250001,1.0],[0.7500001,0.9999999],[0.7475983,0.8749999],[0.6250001,0.8749999],[0.7499896,1.000003],[0.8749897,0.9999988],[0.8725869,0.8749989],[0.7499888,0.8750029],[0.8750064,0.9999965],[1.0000063,0.9999936],[0.9976037,0.8749936],[0.8750057,0.8749964],[-1.5e-6,1.0000005],[0.1249985,1.0000008],[0.1225968,0.8750007],[-1.5e-6,0.8750005],[0.1250004,0.9999994],[0.2500004,0.9999993],[0.2475983,0.8749992],[0.1250004,0.8749994],[0.25,0.9999997],[0.375,0.9999997],[0.3725983,0.8749997],[0.25,0.8749996],[0.3750001,1.0],[0.5000001,1.0],[0.4975982,0.8749999],[0.3750001,0.875],[0.4999999,0.9999994],[0.6249999,0.9999995],[0.6225981,0.8749996],[0.4999999,0.8749994],[0.6249992,1.000002],[0.7499992,1.000001],[0.7475971,0.8750008],[0.624999,0.8750019],[0.7500049,1.0000115],[0.8750049,1.0000135],[0.8726035,0.8750136],[0.7500053,0.8750115],[0.8749966,1.0000057],[0.9999966,1.0000055],[0.9975947,0.8750055],[0.8749965,0.8750057],[0.0024033,0.1249999],[0.1250016,0.1250001],[0.1250016,1e-7],[1.6e-6,-1e-7],[0.1274015,0.1249998],[0.2499995,0.1249998],[0.2499995,-2e-7],[0.1249995,-1e-7],[0.2524017,0.125],[0.3774018,0.1250001],[0.4999999,0.1250001],[0.4999999,1e-7],[0.3749999,2e-7],[0.5024026,0.1249992],[0.6250009,0.1249993],[0.6250009,-7e-7],[0.5000009,-8e-7],[0.6273963,0.1250067],[0.7499943,0.1250062],[0.7499942,6.2e-6],[0.6249942,6.8e-6],[0.7523956,0.1250256],[0.8749938,0.1250256],[0.8749937,0.0000256],[0.7499937,0.0000258],[0.8773955,0.124983],[0.9999933,0.1249791],[0.9999925,-0.0000209],[0.8749925,-0.0000174],[0.0024013,0.1250002],[0.1249996,0.1250005],[0.1249996,5e-7],[-4e-7,3e-7],[0.1274016,0.1250003],[0.2499998,0.1250004],[0.2499998,4e-7],[0.1249998,4e-7],[0.252402,0.1249999],[0.375,0.1249998],[0.375,-1e-7],[0.25,-1e-7],[0.3774019,0.1250004],[0.5000001,0.1250003],[0.5000001,4e-7],[0.3750001,4e-7],[0.5024014,0.1249992],[0.6249996,0.1249994],[0.6249996,-6e-7],[0.4999996,-7e-7],[0.6274017,0.1249999],[0.7499999,0.1249999],[0.7499999,-1e-7],[0.6249999,0.0],[0.7524019,0.1250003],[0.875,0.1250001],[0.875,1e-7],[0.75,3e-7],[0.8773992,0.1250006],[0.9999973,0.125],[0.9999972,0.0],[0.8749972,7e-7],[0.0072538,0.2500005],[0.1250013,0.2500008],[0.1250013,0.1250008],[1.3e-6,0.1250006],[0.1322524,0.2500001],[0.2499999,0.25],[0.2499999,0.125],[0.1249998,0.125],[0.2572525,0.25],[0.375,0.2499999],[0.25,0.1249999],[0.3822519,0.2500008],[0.4999991,0.2500009],[0.4999991,0.1250009],[0.3749991,0.125001],[0.5072533,0.249999],[0.6250008,0.249999],[0.6250008,0.124999],[0.5000008,0.1249989],[0.6322554,0.2499938],[0.7500029,0.2499938],[0.750003,0.1249939],[0.625003,0.1249936],[0.7572542,0.2499809],[0.8750016,0.2499806],[0.8750016,0.1249806],[0.7500016,0.1249807],[0.8822576,0.2500161],[1.0000052,0.2500178],[1.0000056,0.1250179],[0.8750055,0.1250167],[0.0072527,0.2499994],[0.1249999,0.2499992],[0.1249999,0.1249992],[-1e-7,0.1249993],[0.1322526,0.2499998],[0.2500001,0.2499998],[0.2500001,0.1249998],[0.1250001,0.1249998],[0.2572528,0.2500007],[0.375,0.2500006],[0.375,0.1250006],[0.25,0.1250007],[0.3822517,0.2499988],[0.4999994,0.249999],[0.4999994,0.124999],[0.3749994,0.1249988],[0.5072518,0.2499995],[0.6249993,0.2499997],[0.6249993,0.1249997],[0.4999993,0.1249995],[0.6322595,0.2499987],[0.7500067,0.2499976],[0.7500064,0.1249975],[0.6250065,0.1249988],[0.7572475,0.2500154],[0.874995,0.2500158],[0.8749951,0.1250159],[0.7499951,0.1250153],[0.8822528,0.2500005],[1.0000002,0.2500001],[1.0000001,0.1250001],[0.8750001,0.1250005],[0.012493,0.3749341],[0.1249898,0.3749338],[0.1249897,0.2499337],[-0.0000103,0.249934],[0.1375058,0.3750084],[0.250003,0.3750084],[0.250003,0.2500083],[0.125003,0.2500083],[0.2625026,0.3750077],[0.3749994,0.3750079],[0.3749995,0.2500078],[0.2499994,0.2500078],[0.3875045,0.3749858],[0.5000017,0.3749856],[0.5000017,0.2499855],[0.3750017,0.2499856],[0.5125007,0.3750377],[0.6249976,0.3750379],[0.6249976,0.2500378],[0.4999976,0.2500379],[0.6375024,0.3749677],[0.7499995,0.3749675],[0.7499996,0.2499674],[0.6249995,0.2499673],[0.762514,0.3750255],[0.875011,0.3750256],[0.8750109,0.2500256],[0.7500109,0.2500258],[0.8874968,0.3750464],[0.9999937,0.375046],[0.9999937,0.250046],[0.8749937,0.2500461],[0.0125064,0.3750578],[0.1250035,0.3750577],[0.1250032,0.2500573],[3.5e-6,0.2500579],[0.1375101,0.375048],[0.2500069,0.3750477],[0.2500069,0.2500476],[0.1250065,0.2500476],[0.2625036,0.3750317],[0.3750011,0.3750317],[0.375001,0.2500316],[0.250001,0.2500317],[0.3875105,0.3749474],[0.5000072,0.3749476],[0.5000072,0.2499475],[0.3750072,0.2499475],[0.5125012,0.3750159],[0.6249983,0.3750159],[0.6249983,0.2500158],[0.4999983,0.2500158],[0.6375052,0.3750465],[0.7500024,0.3750466],[0.7500024,0.2500466],[0.6250024,0.2500464],[0.7624886,0.3749701],[0.8749856,0.3749701],[0.8749856,0.24997],[0.7499856,0.2499703],[0.8874999,0.3750149],[0.9999969,0.375015],[0.9999969,0.2500149],[0.8749969,0.2500149],[0.0187023,0.500032],[0.1250062,0.5000321],[0.1250062,0.3750321],[6.2e-6,0.375032],[0.1436982,0.5000041],[0.250002,0.5000042],[0.250002,0.3750041],[0.125002,0.3750041],[0.2686957,0.4999999],[0.3749994,0.4999999],[0.3749994,0.3749999],[0.2499994,0.3749998],[0.3936969,0.5000002],[0.5000008,0.5],[0.5000008,0.375],[0.3750008,0.375],[0.5186965,0.5000094],[0.6250003,0.5000095],[0.6250002,0.3750095],[0.5000003,0.3750095],[0.6436951,0.4999727],[0.7499989,0.4999727],[0.749999,0.3749723],[0.6249989,0.3749726],[0.7686907,0.4999504],[0.8749945,0.4999507],[0.8749946,0.3749507],[0.7499945,0.37495],[0.893698,0.4999836],[1.0000018,0.4999837],[1.0000018,0.3749837],[0.8750018,0.3749837],[0.0186973,0.5000229],[0.1250009,0.5000226],[0.1250009,0.3750226],[9e-7,0.3750227],[0.1436941,0.4999769],[0.2499982,0.4999772],[0.2499982,0.3749771],[0.1249982,0.3749771],[0.2686953,0.4999987],[0.3749987,0.4999987],[0.3749987,0.3749986],[0.2499987,0.3749987],[0.3936936,0.5000388],[0.4999977,0.5000387],[0.4999977,0.3750387],[0.3749977,0.3750386],[0.5187,0.4999464],[0.6250035,0.4999465],[0.6250035,0.3749465],[0.5000035,0.3749466],[0.6436957,0.500001],[0.7499994,0.500001],[0.7499994,0.375001],[0.6249994,0.375001],[0.7687036,0.5000047],[0.8750074,0.5000045],[0.8750074,0.3750045],[0.7500074,0.3750044],[0.893695,0.5000156],[0.9999988,0.5000157],[0.9999988,0.3750156],[0.8749988,0.3750156],[0.0267858,0.6249912],[0.1249977,0.6249912],[0.1249976,0.4999913],[-2.4e-6,0.4999913],[0.1517836,0.6249841],[0.2499953,0.624984],[0.2499953,0.4999841],[0.1249953,0.4999841],[0.2767868,0.6249918],[0.3749985,0.6249918],[0.3749985,0.4999919],[0.2499985,0.4999919],[0.401786,0.6250005],[0.4999978,0.6250005],[0.4999979,0.5000006],[0.3749979,0.5000006],[0.5267888,0.6249911],[0.6250008,0.624991],[0.6250008,0.4999911],[0.5000008,0.4999911],[0.6517864,0.6249871],[0.7499983,0.624987],[0.7499983,0.499987],[0.6249984,0.499987],[0.7767878,0.6249942],[0.8749997,0.6249944],[0.8749997,0.4999944],[0.7499997,0.4999944],[0.901788,0.6249918],[0.9999999,0.6249921],[0.9999999,0.4999921],[0.8749999,0.4999921],[0.0267933,0.6250388],[0.1250048,0.6250383],[0.1250047,0.5000383],[4.7e-6,0.5000384],[0.1517918,0.6250024],[0.2500039,0.6250025],[0.2500039,0.5000026],[0.1250039,0.5000026],[0.2767865,0.6250018],[0.3749986,0.6250018],[0.3749986,0.5000019],[0.2499986,0.5000019],[0.4017946,0.6249676],[0.5000061,0.6249678],[0.5000061,0.4999679],[0.3750061,0.4999679],[0.5267857,0.6250177],[0.6249976,0.6250177],[0.6249976,0.5000177],[0.4999976,0.5000178],[0.6517909,0.6250222],[0.7500028,0.6250221],[0.7500028,0.5000222],[0.6250028,0.5000222],[0.7767897,0.6250078],[0.8750016,0.625008],[0.8750016,0.500008],[0.7500016,0.500008],[0.9017902,0.625013],[1.0000021,0.6250132],[1.0000021,0.5000132],[0.8750021,0.5000132],[0.0388976,0.7499972],[0.1249991,0.7499971],[0.1249991,0.6249971],[-9e-7,0.6249971],[0.1638973,0.7499955],[0.2499988,0.7499955],[0.2499988,0.6249954],[0.1249988,0.6249955],[0.2888961,0.7499997],[0.3749981,0.7499997],[0.3749981,0.6249996],[0.2499981,0.6249996],[0.4138967,0.7499997],[0.4999981,0.7499998],[0.4999981,0.6249998],[0.3749981,0.6249998],[0.5388978,0.7500035],[0.6249991,0.7500038],[0.6249991,0.6250037],[0.4999991,0.6250038],[0.6638982,0.7499985],[0.7499998,0.7499984],[0.7499998,0.6249984],[0.6249997,0.6249984],[0.7888982,0.7499986],[0.8749997,0.7499986],[0.8749997,0.6249986],[0.7499997,0.6249986],[0.9138986,0.7499972],[1.0,0.7499972],[1.0,0.6249972],[0.8750001,0.6249972],[0.038898,0.7499978],[0.1249997,0.749998],[0.1249998,0.6249979],[-3e-7,0.624998],[0.1638999,0.7500008],[0.250001,0.7500007],[0.250001,0.6250007],[0.125001,0.6250007],[0.2888994,0.7500074],[0.375001,0.7500073],[0.375001,0.6250073],[0.250001,0.6250073],[0.4138975,0.7500065],[0.4999992,0.7500064],[0.4999992,0.6250064],[0.3749992,0.6250064],[0.5388958,0.7500092],[0.6249974,0.7500091],[0.6249973,0.625009],[0.4999974,0.6250091],[0.6638987,0.7499882],[0.7500001,0.7499886],[0.7500001,0.6249886],[0.6250001,0.6249886],[0.7888984,0.7500006],[0.8749999,0.7500006],[0.8749999,0.6250006],[0.7499999,0.6250006],[0.913898,0.7499983],[0.9999995,0.7499983],[0.9999995,0.6249983],[0.8749995,0.6249983],[0.0612812,0.8750098],[0.1250061,0.87501],[0.1250061,0.75001],[6.1e-6,0.75001],[0.1862784,0.8750042],[0.250003,0.8750042],[0.250003,0.7500041],[0.125003,0.7500041],[0.3112748,0.8750062],[0.3749996,0.8750062],[0.3750003,0.750006],[0.2499996,0.7500061],[0.4362775,0.8750014],[0.5000011,0.8750018],[0.5000011,0.7500018],[0.3750017,0.7500015],[0.5612761,0.8750022],[0.6250008,0.8750019],[0.6250007,0.7500019],[0.5000007,0.7500019],[0.6862761,0.8750022],[0.7500006,0.8750019],[0.7500006,0.7500019],[0.6250005,0.7500019],[0.8112766,0.8749994],[0.8750011,0.8749996],[0.8750011,0.7499995],[0.7500011,0.7499995],[0.9362756,0.8749961],[1.0000002,0.8749962],[1.0000002,0.7499962],[0.8750002,0.7499962],[0.0612734,0.8749955],[0.1249976,0.8749954],[0.1249977,0.7499954],[-2.3e-6,0.7499954],[0.1862744,0.8749944],[0.2499989,0.8749945],[0.249999,0.7499944],[0.124999,0.7499944],[0.3112709,0.8749969],[0.3749953,0.8749968],[0.3749953,0.7499968],[0.2499953,0.7499968],[0.4362761,0.8749953],[0.5000006,0.8749953],[0.5000006,0.7499953],[0.3750006,0.7499952],[0.5612782,0.8749847],[0.6250023,0.8749851],[0.6250022,0.749985],[0.5000022,0.7499851],[0.6862727,0.8750028],[0.7499973,0.8750023],[0.7499973,0.7500023],[0.6249972,0.7500023],[0.8112737,0.8749987],[0.8749981,0.8749989],[0.8749981,0.7499989],[0.7499981,0.7499989],[0.9362704,0.8749923],[0.9999945,0.8749919],[0.9999945,0.7499919],[0.8749945,0.7499919],[0.125,1.0],[0.1249998,0.8750001],[-1e-7,0.875],[0.25,1.0],[0.2499999,0.8750001],[0.1249999,0.8750001],[0.375,1.0],[0.375,0.8750001],[0.25,0.8750001],[0.5,1.0],[0.499999,0.8750004],[0.3750001,0.8750001],[0.625,1.0],[0.6250001,0.875],[0.4999995,0.8750005],[0.75,1.0],[0.75,0.875],[0.875,1.0],[0.8749999,0.875],[1.0,1.0],[1.0000008,0.8750005],[0.1249999,0.875],[1.2e-6,0.8750004],[0.5000001,0.8750001],[0.6249994,0.8750005],[0.5000002,0.8750001],[0.7500001,0.875],[0.6249999,0.8750006],[0.9999999,0.875]]},"geometry-templates":{"templates":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[16,17,4,3]],[[17,18,5,4]],[[18,19,6,5]],[[19,20,7,6]],[[21,22,9,8]],[[22,23,10,9]],[[23,24,11,10]],[[24,25,12,11]],[[25,26,13,12]],[[26,27,14,13]],[[27,28,15,14]],[[28,29,0,15]],[[29,30,1,0]],[[30,31,2,1]],[[31,16,3,2]],[[19,18,17,16,31,30,29,28,27,26,25,24,23,22,21,20]],[[20,21,8,7]],[[32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]],[[48,49,36,35]],[[49,50,37,36]],[[50,51,38,37]],[[51,52,39,38]],[[52,53,40,39]],[[53,54,41,40]],[[54,55,42,41]],[[55,56,43,42]],[[56,57,44,43]],[[57,58,45,44]],[[58,59,46,45]],[[59,60,47,46]],[[60,61,32,47]],[[61,62,33,32]],[[62,63,34,33]],[[63,48,35,34]],[[51,50,49,48,63,62,61,60,59,58,57,56,55,54,53,52]]],"material":{"visual":{"values":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[0,24,25,26,27]],[[0,28,29,30,31]],[[0,32,33,34,35]],[[0,36,37,38,39]],[[0,40,41,42,43]],[[0,44,45,46,47]],[[0,48,49,50,51]],[[0,52,53,54,55]],[[0,56,57,58,59]],[[0,60,61,62,63]],[[0,64,65,66,67]],[[0,68,69,70,71]],[[0,72,73,74,75]],[[null]],[[0,76,77,78,79]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]]]}}},{"type":"MultiSurface","lod":"3","boundaries":[[[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79]],[[80,81,68,67]],[[81,82,69,68]],[[82,83,70,69]],[[83,84,71,70]],[[84,85,72,71]],[[85,86,73,72]],[[86,87,74,73]],[[87,88,75,74]],[[88,89,76,75]],[[89,90,77,76]],[[90,91,78,77]],[[91,92,79,78]],[[92,93,64,79]],[[93,94,65,64]],[[94,95,66,65]],[[95,80,67,66]],[[83,82,81,80,95,94,93,92,91,90,89,88,87,86,85,84]],[[96,97,98]],[[96,98,99]],[[96,99,100]],[[96,100,101]],[[96,101,102]],[[96,102,103]],[[96,103,104]],[[96,104,105]],[[96,105,106]],[[96,106,107]],[[96,107,108]],[[96,108,109]],[[96,109,110]],[[96,110,111]],[[96,111,112]],[[96,112,97]],[[113,114,98,97]],[[114,115,99,98]],[[115,116,100,99]],[[116,117,101,100]],[[117,118,102,101]],[[118,119,103,102]],[[119,120,104,103]],[[120,121,105,104]],[[121,122,106,105]],[[122,123,107,106]],[[123,124,108,107]],[[124,125,109,108]],[[125,126,110,109]],[[126,127,111,110]],[[127,128,112,111]],[[128,113,97,112]],[[129,130,114,113]],[[130,131,115,114]],[[131,132,116,115]],[[132,133,117,116]],[[133,134,118,117]],[[134,135,119,118]],[[135,136,120,119]],[[136,137,121,120]],[[137,138,122,121]],[[138,139,123,122]],[[139,140,124,123]],[[140,141,125,124]],[[141,142,126,125]],[[142,143,127,126]],[[143,144,128,127]],[[144,129,113,128]],[[145,146,130,129]],[[146,147,131,130]],[[147,148,132,131]],[[148,149,133,132]],[[149,150,134,133]],[[150,151,135,134]],[[151,152,136,135]],[[152,153,137,136]],[[153,154,138,137]],[[154,155,139,138]],[[155,156,140,139]],[[156,157,141,140]],[[157,158,142,141]],[[158,159,143,142]],[[159,160,144,143]],[[160,145,129,144]],[[161,162,146,145]],[[162,163,147,146]],[[163,164,148,147]],[[164,165,149,148]],[[165,166,150,149]],[[166,167,151,150]],[[167,168,152,151]],[[168,169,153,152]],[[169,170,154,153]],[[170,171,155,154]],[[171,172,156,155]],[[172,173,157,156]],[[173,174,158,157]],[[174,175,159,158]],[[175,176,160,159]],[[176,161,145,160]],[[177,178,162,161]],[[178,179,163,162]],[[179,180,164,163]],[[180,181,165,164]],[[181,182,166,165]],[[182,183,167,166]],[[183,184,168,167]],[[184,185,169,168]],[[185,186,170,169]],[[186,187,171,170]],[[187,188,172,171]],[[188,189,173,172]],[[189,190,174,173]],[[190,191,175,174]],[[191,192,176,175]],[[192,177,161,176]],[[193,194,178,177]],[[194,195,179,178]],[[195,196,180,179]],[[196,197,181,180]],[[197,198,182,181]],[[198,199,183,182]],[[199,200,184,183]],[[200,201,185,184]],[[201,202,186,185]],[[202,203,187,186]],[[203,204,188,187]],[[204,205,189,188]],[[205,206,190,189]],[[206,207,191,190]],[[207,208,192,191]],[[208,193,177,192]],[[209,210,194,193]],[[210,211,195,194]],[[211,212,196,195]],[[212,213,197,196]],[[213,214,198,197]],[[214,215,199,198]],[[215,216,200,199]],[[216,217,201,200]],[[217,218,202,201]],[[218,219,203,202]],[[219,220,204,203]],[[220,221,205,204]],[[221,222,206,205]],[[222,223,207,206]],[[223,224,208,207]],[[224,209,193,208]],[[225,226,210,209]],[[226,227,211,210]],[[227,228,212,211]],[[228,229,213,212]],[[229,230,214,213]],[[230,231,215,214]],[[231,232,216,215]],[[232,233,217,216]],[[233,234,218,217]],[[234,235,219,218]],[[235,236,220,219]],[[236,237,221,220]],[[237,238,222,221]],[[238,239,223,222]],[[239,240,224,223]],[[240,225,209,224]],[[241,242,226,225]],[[242,243,227,226]],[[243,244,228,227]],[[244,245,229,228]],[[245,246,230,229]],[[246,247,231,230]],[[247,248,232,231]],[[248,249,233,232]],[[249,250,234,233]],[[250,251,235,234]],[[251,252,236,235]],[[252,253,237,236]],[[253,254,238,237]],[[254,255,239,238]],[[255,256,240,239]],[[256,241,225,240]],[[257,258,242,241]],[[258,259,243,242]],[[259,260,244,243]],[[260,261,245,244]],[[261,262,246,245]],[[262,263,247,246]],[[263,264,248,247]],[[264,265,249,248]],[[265,266,250,249]],[[266,267,251,250]],[[267,268,252,251]],[[268,269,253,252]],[[269,270,254,253]],[[270,271,255,254]],[[271,272,256,255]],[[272,257,241,256]],[[273,274,258,257]],[[274,275,259,258]],[[275,276,260,259]],[[276,277,261,260]],[[277,278,262,261]],[[278,279,263,262]],[[279,280,264,263]],[[280,281,265,264]],[[281,282,266,265]],[[282,283,267,266]],[[283,284,268,267]],[[284,285,269,268]],[[285,286,270,269]],[[286,287,271,270]],[[287,288,272,271]],[[288,273,257,272]],[[289,290,274,273]],[[290,291,275,274]],[[291,292,276,275]],[[292,293,277,276]],[[293,294,278,277]],[[294,295,279,278]],[[295,296,280,279]],[[296,297,281,280]],[[297,298,282,281]],[[298,299,283,282]],[[299,300,284,283]],[[300,301,285,284]],[[301,302,286,285]],[[302,303,287,286]],[[303,304,288,287]],[[304,289,273,288]],[[305,306,290,289]],[[306,307,291,290]],[[307,308,292,291]],[[308,309,293,292]],[[309,310,294,293]],[[310,311,295,294]],[[311,312,296,295]],[[312,313,297,296]],[[313,314,298,297]],[[314,315,299,298]],[[315,316,300,299]],[[316,317,301,300]],[[317,318,302,301]],[[318,319,303,302]],[[319,320,304,303]],[[320,305,289,304]],[[321,322,306,305]],[[322,323,307,306]],[[323,324,308,307]],[[324,325,309,308]],[[325,326,310,309]],[[326,327,311,310]],[[327,328,312,311]],[[328,329,313,312]],[[329,330,314,313]],[[330,331,315,314]],[[331,332,316,315]],[[332,333,317,316]],[[333,334,318,317]],[[334,335,319,318]],[[335,336,320,319]],[[336,321,305,320]],[[337,322,321]],[[337,323,322]],[[337,324,323]],[[337,325,324]],[[337,326,325]],[[337,327,326]],[[337,328,327]],[[337,329,328]],[[337,330,329]],[[337,331,330]],[[337,332,331]],[[337,333,332]],[[337,334,333]],[[337,335,334]],[[337,336,335]],[[337,321,336]]],"material":{"visual":{"value":1}},"texture":{"visual":{"values":[[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[1,80,81,82]],[[1,83,82,84]],[[1,85,84,86]],[[1,87,86,88]],[[1,89,88,90]],[[1,91,90,92]],[[1,93,92,94]],[[1,95,94,96]],[[1,80,81,82]],[[1,83,82,84]],[[1,85,84,86]],[[1,87,86,88]],[[1,89,88,90]],[[1,91,90,92]],[[1,93,92,94]],[[1,95,94,96]],[[1,97,98,99,100]],[[1,101,102,103,104]],[[1,105,106,107,108]],[[1,109,110,111,112]],[[1,113,114,115,116]],[[1,117,118,119,120]],[[1,121,122,123,124]],[[1,125,126,127,128]],[[1,129,130,131,132]],[[1,133,134,135,136]],[[1,137,138,139,140]],[[1,141,142,143,86]],[[1,144,145,146,147]],[[1,148,149,150,151]],[[1,152,153,154,155]],[[1,156,157,158,159]],[[1,160,161,162,163]],[[1,164,165,166,167]],[[1,168,169,170,171]],[[1,172,173,174,175]],[[1,176,177,178,179]],[[1,180,181,182,183]],[[1,184,185,186,187]],[[1,188,189,190,191]],[[1,192,193,194,195]],[[1,196,197,198,199]],[[1,200,201,202,137]],[[1,203,204,205,206]],[[1,207,208,209,210]],[[1,211,212,213,214]],[[1,215,216,217,218]],[[1,219,220,221,222]],[[1,223,224,225,226]],[[1,227,228,229,230]],[[1,231,232,233,234]],[[1,235,236,237,238]],[[1,239,240,241,242]],[[1,243,244,245,246]],[[1,247,248,249,250]],[[1,251,252,253,254]],[[1,255,256,257,258]],[[1,259,260,261,262]],[[1,263,264,265,266]],[[1,267,268,269,270]],[[1,271,272,273,274]],[[1,275,276,277,278]],[[1,279,280,281,282]],[[1,283,284,285,286]],[[1,287,288,289,290]],[[1,291,292,293,294]],[[1,295,296,297,298]],[[1,299,300,301,302]],[[1,303,304,305,306]],[[1,307,308,309,310]],[[1,311,312,313,314]],[[1,315,316,317,318]],[[1,319,320,321,322]],[[1,323,324,325,326]],[[1,327,328,329,330]],[[1,331,332,333,334]],[[1,335,336,337,338]],[[1,339,340,341,342]],[[1,343,344,345,346]],[[1,347,348,349,350]],[[1,351,352,353,354]],[[1,355,356,357,358]],[[1,359,360,361,362]],[[1,363,364,365,366]],[[1,367,368,369,370]],[[1,371,372,373,374]],[[1,375,376,377,378]],[[1,379,380,381,382]],[[1,383,384,385,386]],[[1,387,388,389,390]],[[1,391,392,393,394]],[[1,395,396,397,398]],[[1,399,400,401,402]],[[1,403,404,405,406]],[[1,407,408,409,410]],[[1,411,412,413,414]],[[1,415,416,417,418]],[[1,419,420,421,422]],[[1,423,424,425,426]],[[1,427,428,429,430]],[[1,431,432,433,434]],[[1,435,436,437,438]],[[1,439,440,441,442]],[[1,443,444,445,446]],[[1,447,448,449,450]],[[1,451,452,453,454]],[[1,455,456,457,458]],[[1,459,460,461,462]],[[1,431,463,464,465]],[[1,466,467,468,469]],[[1,470,471,472,473]],[[1,474,475,476,477]],[[1,478,479,480,481]],[[1,482,483,484,485]],[[1,486,487,488,489]],[[1,490,491,492,493]],[[1,494,495,496,497]],[[1,498,499,500,501]],[[1,502,503,504,505]],[[1,506,507,508,509]],[[1,510,511,512,513]],[[1,514,515,516,517]],[[1,518,519,520,521]],[[1,522,523,524,525]],[[1,526,527,528,529]],[[1,530,531,532,533]],[[1,534,535,536,537]],[[1,538,539,540,541]],[[1,542,543,544,545]],[[1,546,547,548,549]],[[1,550,86,87,85]],[[1,551,552,553,554]],[[1,555,556,557,558]],[[1,559,560,561,562]],[[1,563,564,565,566]],[[1,567,568,569,570]],[[1,571,572,573,574]],[[1,575,576,577,578]],[[1,579,580,581,582]],[[1,583,584,585,586]],[[1,587,588,589,590]],[[1,591,592,593,594]],[[1,595,596,597,598]],[[1,599,600,601,602]],[[1,603,604,605,606]],[[1,607,608,609,610]],[[1,611,612,86,613]],[[1,614,615,616,617]],[[1,618,619,620,621]],[[1,622,623,624,625]],[[1,626,627,628,629]],[[1,630,631,632,633]],[[1,634,635,636,637]],[[1,638,639,640,641]],[[1,642,643,644,645]],[[1,646,647,648,649]],[[1,650,651,652,653]],[[1,654,655,656,657]],[[1,658,659,660,661]],[[1,662,663,664,665]],[[1,666,667,668,669]],[[1,670,671,672,673]],[[1,674,675,676,677]],[[1,678,679,680,681]],[[1,682,683,684,685]],[[1,686,687,688,689]],[[1,690,691,692,693]],[[1,694,695,696,697]],[[1,698,699,700,701]],[[1,702,703,704,705]],[[1,706,707,708,709]],[[1,710,711,712,713]],[[1,714,715,716,717]],[[1,718,719,720,721]],[[1,722,723,724,725]],[[1,726,727,728,729]],[[1,730,731,732,733]],[[1,734,735,736,737]],[[1,738,739,740,741]],[[1,742,743,744,745]],[[1,746,747,748,749]],[[1,750,751,752,753]],[[1,754,755,756,757]],[[1,758,759,760,761]],[[1,762,763,764,765]],[[1,766,767,768,769]],[[1,770,771,772,773]],[[1,774,775,776,777]],[[1,778,779,780,781]],[[1,782,783,784,785]],[[1,786,787,788,789]],[[1,790,791,792,793]],[[1,794,795,796,797]],[[1,798,799,800,801]],[[1,802,803,804,805]],[[1,806,807,808,809]],[[1,810,811,812,813]],[[1,814,815,816,817]],[[1,818,819,820,821]],[[1,822,823,824,825]],[[1,826,827,828,829]],[[1,830,831,832,833]],[[1,834,835,836,837]],[[1,838,839,840,841]],[[1,842,843,844,845]],[[1,846,847,848,849]],[[1,850,851,852,853]],[[1,854,855,856,857]],[[1,858,859,860,861]],[[1,862,863,864,865]],[[1,866,867,868,869]],[[1,870,871,872,873]],[[1,874,875,876,877]],[[1,878,879,880,881]],[[1,882,883,884,885]],[[1,886,887,888,889]],[[1,890,891,892,893]],[[1,894,895,896,897]],[[1,898,899,900,901]],[[1,902,903,904,905]],[[1,906,907,908,909]],[[1,910,911,912,913]],[[1,914,915,916,917]],[[1,918,919,920,921]],[[1,922,923,924,925]],[[1,926,927,928,929]],[[1,930,931,932,933]],[[1,934,935,936,937]],[[1,938,939,940,941]],[[1,942,943,944,945]],[[1,946,947,948,949]],[[1,950,951,952,953]],[[1,954,955,956,957]],[[1,958,959,960,961]],[[1,962,963,964,965]],[[1,966,967,968,969]],[[1,970,971,972,973]],[[1,974,975,976,977]],[[1,978,979,980,981]],[[1,982,983,984,985]],[[1,986,987,988]],[[1,989,990,991]],[[1,992,993,994]],[[1,995,996,997]],[[1,998,999,1000]],[[1,1001,1002,999]],[[1,1003,1004,1002]],[[1,1005,1006,1004]],[[1,986,1007,1008]],[[1,989,990,991]],[[1,992,993,994]],[[1,995,1009,997]],[[1,998,1010,1011]],[[1,1001,1012,1013]],[[1,1003,1004,1012]],[[1,1005,1014,1004]]]}}},{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[16,17,4,3]],[[17,18,5,4]],[[18,19,6,5]],[[19,20,7,6]],[[21,22,9,8]],[[22,23,10,9]],[[23,24,11,10]],[[24,25,12,11]],[[25,26,13,12]],[[26,27,14,13]],[[27,28,15,14]],[[28,29,0,15]],[[29,30,1,0]],[[30,31,2,1]],[[31,16,3,2]],[[19,18,17,16,31,30,29,28,27,26,25,24,23,22,21,20]],[[20,21,8,7]],[[32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]],[[48,49,36,35]],[[49,50,37,36]],[[50,51,38,37]],[[51,52,39,38]],[[52,53,40,39]],[[53,54,41,40]],[[54,55,42,41]],[[55,56,43,42]],[[56,57,44,43]],[[57,58,45,44]],[[58,59,46,45]],[[59,60,47,46]],[[60,61,32,47]],[[61,62,33,32]],[[62,63,34,33]],[[63,48,35,34]],[[51,50,49,48,63,62,61,60,59,58,57,56,55,54,53,52]]],"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[0,24,25,26,27]],[[0,28,29,30,31]],[[0,32,33,34,35]],[[0,36,37,38,39]],[[0,40,41,42,43]],[[0,44,45,46,47]],[[0,48,49,50,51]],[[0,52,53,54,55]],[[0,56,57,58,59]],[[0,60,61,62,63]],[[0,64,65,66,67]],[[0,68,69,70,71]],[[0,72,73,74,75]],[[null]],[[0,76,77,78,79]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]],[[null]]]}}}],"vertices-templates":[[0.112,0.121,0.502],[0.107,0.121,0.502],[0.103,0.119,0.502],[0.1,0.116,0.502],[0.099,0.112,0.502],[0.099,0.107,0.502],[0.101,0.103,0.502],[0.104,0.1,0.502],[0.108,0.099,0.502],[0.112,0.099,0.502],[0.116,0.101,0.502],[0.119,0.104,0.502],[0.121,0.108,0.502],[0.121,0.112,0.502],[0.119,0.116,0.502],[0.116,0.119,0.502],[0.016,0.17,0.13],[0.0,0.13,0.13],[0.001,0.086,0.13],[0.018,0.046,0.13],[0.049,0.016,0.13],[0.089,0.0,0.13],[0.133,0.001,0.13],[0.173,0.018,0.13],[0.203,0.049,0.13],[0.219,0.089,0.13],[0.219,0.133,0.13],[0.202,0.173,0.13],[0.17,0.203,0.13],[0.13,0.219,0.13],[0.086,0.219,0.13],[0.046,0.202,0.13],[0.112,0.124,0.186],[0.107,0.124,0.186],[0.101,0.122,0.186],[0.097,0.118,0.186],[0.095,0.112,0.186],[0.095,0.107,0.186],[0.097,0.101,0.186],[0.102,0.097,0.186],[0.107,0.095,0.186],[0.113,0.095,0.186],[0.118,0.097,0.186],[0.122,0.102,0.186],[0.124,0.107,0.186],[0.124,0.113,0.186],[0.122,0.118,0.186],[0.118,0.122,0.186],[0.091,0.122,0.0],[0.088,0.114,0.0],[0.088,0.105,0.0],[0.091,0.097,0.0],[0.098,0.091,0.0],[0.106,0.088,0.0],[0.114,0.088,0.0],[0.122,0.091,0.0],[0.128,0.098,0.0],[0.132,0.106,0.0],[0.132,0.114,0.0],[0.128,0.122,0.0],[0.122,0.128,0.0],[0.114,0.132,0.0],[0.105,0.132,0.0],[0.097,0.128,0.0],[0.115,0.127,0.188],[0.109,0.127,0.188],[0.104,0.125,0.188],[0.1,0.121,0.188],[0.098,0.115,0.188],[0.098,0.109,0.188],[0.1,0.104,0.188],[0.104,0.1,0.188],[0.11,0.098,0.188],[0.116,0.098,0.188],[0.121,0.1,0.188],[0.125,0.104,0.188],[0.127,0.11,0.188],[0.127,0.116,0.188],[0.125,0.121,0.188],[0.121,0.125,0.188],[0.094,0.125,0.0],[0.09,0.117,0.0],[0.09,0.108,0.0],[0.094,0.1,0.0],[0.1,0.094,0.0],[0.108,0.09,0.0],[0.117,0.09,0.0],[0.125,0.094,0.0],[0.131,0.1,0.0],[0.135,0.108,0.0],[0.135,0.117,0.0],[0.131,0.125,0.0],[0.125,0.131,0.0],[0.117,0.135,0.0],[0.108,0.135,0.0],[0.1,0.131,0.0],[0.004,0.144,0.301],[0.006,0.143,0.33],[0.008,0.151,0.327],[0.01,0.158,0.321],[0.012,0.162,0.312],[0.012,0.164,0.301],[0.012,0.162,0.29],[0.01,0.158,0.281],[0.008,0.151,0.274],[0.006,0.143,0.272],[0.004,0.135,0.274],[0.002,0.128,0.281],[0.0,0.123,0.29],[0.0,0.122,0.301],[0.0,0.123,0.312],[0.002,0.128,0.321],[0.004,0.135,0.327],[0.012,0.141,0.357],[0.017,0.157,0.353],[0.021,0.171,0.341],[0.023,0.18,0.322],[0.024,0.183,0.301],[0.023,0.18,0.279],[0.021,0.171,0.261],[0.017,0.157,0.249],[0.012,0.141,0.245],[0.008,0.125,0.249],[0.004,0.112,0.261],[0.001,0.103,0.279],[0.0,0.1,0.301],[0.001,0.103,0.322],[0.004,0.112,0.341],[0.008,0.125,0.353],[0.022,0.138,0.382],[0.029,0.161,0.376],[0.034,0.181,0.359],[0.038,0.194,0.332],[0.04,0.199,0.301],[0.038,0.194,0.27],[0.034,0.181,0.243],[0.029,0.161,0.226],[0.022,0.138,0.219],[0.016,0.115,0.226],[0.01,0.096,0.243],[0.006,0.083,0.27],[0.005,0.078,0.301],[0.006,0.083,0.332],[0.01,0.096,0.359],[0.016,0.115,0.376],[0.036,0.134,0.405],[0.044,0.164,0.397],[0.051,0.189,0.374],[0.056,0.205,0.341],[0.058,0.211,0.301],[0.056,0.205,0.261],[0.051,0.189,0.228],[0.044,0.164,0.205],[0.036,0.134,0.197],[0.027,0.105,0.205],[0.02,0.08,0.228],[0.015,0.064,0.261],[0.014,0.058,0.301],[0.015,0.064,0.341],[0.02,0.08,0.374],[0.027,0.105,0.397],[0.052,0.13,0.423],[0.062,0.164,0.414],[0.07,0.194,0.387],[0.076,0.213,0.348],[0.078,0.22,0.301],[0.076,0.213,0.254],[0.07,0.194,0.215],[0.062,0.164,0.188],[0.052,0.13,0.179],[0.042,0.095,0.188],[0.034,0.066,0.215],[0.028,0.046,0.254],[0.026,0.04,0.301],[0.028,0.046,0.348],[0.034,0.066,0.387],[0.042,0.095,0.414],[0.071,0.124,0.436],[0.082,0.163,0.426],[0.091,0.195,0.397],[0.097,0.217,0.353],[0.1,0.225,0.301],[0.097,0.217,0.249],[0.091,0.195,0.205],[0.082,0.163,0.176],[0.071,0.124,0.165],[0.06,0.086,0.176],[0.051,0.053,0.205],[0.044,0.032,0.249],[0.042,0.024,0.301],[0.044,0.032,0.353],[0.051,0.053,0.397],[0.06,0.086,0.426],[0.091,0.119,0.445],[0.103,0.159,0.434],[0.113,0.194,0.403],[0.119,0.217,0.356],[0.122,0.225,0.301],[0.119,0.217,0.246],[0.113,0.194,0.199],[0.103,0.159,0.168],[0.091,0.119,0.157],[0.08,0.078,0.168],[0.07,0.043,0.199],[0.063,0.02,0.246],[0.061,0.012,0.301],[0.063,0.02,0.356],[0.07,0.043,0.403],[0.08,0.078,0.434],[0.112,0.112,0.448],[0.124,0.154,0.436],[0.134,0.189,0.405],[0.141,0.213,0.357],[0.144,0.221,0.301],[0.141,0.213,0.245],[0.134,0.189,0.197],[0.124,0.154,0.165],[0.112,0.112,0.154],[0.101,0.071,0.165],[0.091,0.036,0.197],[0.084,0.012,0.245],[0.081,0.004,0.301],[0.084,0.012,0.357],[0.091,0.036,0.405],[0.101,0.071,0.436],[0.134,0.106,0.445],[0.145,0.147,0.434],[0.155,0.182,0.403],[0.162,0.205,0.356],[0.164,0.213,0.301],[0.162,0.205,0.246],[0.155,0.182,0.199],[0.145,0.147,0.168],[0.134,0.106,0.157],[0.122,0.066,0.168],[0.112,0.031,0.199],[0.106,0.008,0.246],[0.103,0.0,0.301],[0.106,0.008,0.356],[0.112,0.031,0.403],[0.122,0.066,0.434],[0.154,0.101,0.436],[0.165,0.139,0.426],[0.174,0.171,0.397],[0.18,0.193,0.353],[0.183,0.201,0.301],[0.18,0.193,0.249],[0.174,0.171,0.205],[0.165,0.139,0.176],[0.154,0.101,0.165],[0.143,0.062,0.176],[0.134,0.03,0.205],[0.127,0.008,0.249],[0.125,0.0,0.301],[0.127,0.008,0.353],[0.134,0.03,0.397],[0.143,0.062,0.426],[0.173,0.095,0.423],[0.183,0.13,0.414],[0.191,0.159,0.387],[0.197,0.179,0.348],[0.199,0.185,0.301],[0.197,0.179,0.254],[0.191,0.159,0.215],[0.183,0.13,0.188],[0.173,0.095,0.179],[0.163,0.061,0.188],[0.154,0.031,0.215],[0.149,0.012,0.254],[0.147,0.005,0.301],[0.149,0.012,0.348],[0.154,0.031,0.387],[0.163,0.061,0.414],[0.189,0.091,0.405],[0.198,0.12,0.397],[0.205,0.145,0.374],[0.209,0.161,0.341],[0.211,0.167,0.301],[0.209,0.161,0.261],[0.205,0.145,0.228],[0.198,0.12,0.205],[0.189,0.091,0.197],[0.181,0.061,0.205],[0.174,0.036,0.228],[0.169,0.02,0.261],[0.167,0.014,0.301],[0.169,0.02,0.341],[0.174,0.036,0.374],[0.181,0.061,0.397],[0.203,0.087,0.382],[0.209,0.11,0.376],[0.215,0.129,0.359],[0.219,0.142,0.332],[0.22,0.147,0.301],[0.219,0.142,0.27],[0.215,0.129,0.243],[0.209,0.11,0.226],[0.203,0.087,0.219],[0.196,0.064,0.226],[0.19,0.044,0.243],[0.187,0.031,0.27],[0.185,0.026,0.301],[0.187,0.031,0.332],[0.19,0.044,0.359],[0.196,0.064,0.376],[0.213,0.084,0.357],[0.217,0.1,0.353],[0.221,0.113,0.341],[0.224,0.122,0.322],[0.225,0.125,0.301],[0.224,0.122,0.279],[0.221,0.113,0.261],[0.217,0.1,0.249],[0.213,0.084,0.245],[0.208,0.068,0.249],[0.204,0.054,0.261],[0.202,0.045,0.279],[0.201,0.042,0.301],[0.202,0.045,0.322],[0.204,0.054,0.341],[0.208,0.068,0.353],[0.219,0.082,0.33],[0.221,0.09,0.327],[0.223,0.097,0.321],[0.224,0.102,0.312],[0.225,0.103,0.301],[0.224,0.102,0.29],[0.223,0.097,0.281],[0.221,0.09,0.274],[0.219,0.082,0.272],[0.217,0.074,0.274],[0.215,0.067,0.281],[0.213,0.062,0.29],[0.213,0.061,0.301],[0.213,0.062,0.312],[0.215,0.067,0.321],[0.217,0.074,0.327],[0.221,0.081,0.301]]},"extensions":{}} {"type":"CityJSONFeature","id":"GMLID_0598627_75956_700","CityObjects":{"GMLID_0598627_75956_700":{"type":"GenericCityObject","geometry":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26]],[[27,28,29,30]],[[1,27,30]],[[31,32,28,27]],[[33,34,32,31]],[[31,0,26,33]],[[0,31,27,1]],[[35,36,34,33]],[[25,35,33,26]],[[24,37,35,25]],[[37,38,36,35]],[[39,40,38,37]],[[41,42,40,39]],[[43,44,42,41]],[[45,46,44,43]],[[47,48,46,45]],[[49,50,48,47]],[[51,52,50,49]],[[53,54,52,51]],[[55,56,54,53]],[[57,58,56,55]],[[59,60,58,57]],[[57,14,13,59]],[[55,15,14,57]],[[53,16,15,55]],[[51,17,16,53]],[[49,18,17,51]],[[47,19,18,49]],[[45,20,19,47]],[[43,21,20,45]],[[41,22,21,43]],[[23,39,37,24]],[[39,23,22,41]],[[61,1,30]],[[62,61,30,29]],[[63,64,60,59]],[[12,63,59,13]],[[65,66,64,63]],[[67,68,66,65]],[[69,70,68,67]],[[71,72,70,69]],[[73,74,72,71]],[[75,76,74,73]],[[77,78,76,75]],[[79,80,78,77]],[[81,82,80,79]],[[83,84,82,81]],[[84,83,61,62]],[[11,65,63,12]],[[10,67,65,11]],[[9,69,67,10]],[[8,71,69,9]],[[7,73,71,8]],[[6,75,73,7]],[[5,77,75,6]],[[4,79,77,5]],[[3,81,79,4]],[[2,83,81,3]],[[83,2,1,61]]],"texture":{"visual":{"values":[[[0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26]],[[0,27,28,29,30]],[[0,31,32,33]],[[0,34,35,36,37]],[[0,38,39,40,41]],[[0,42,43,44,45]],[[0,46,47,48,49]],[[0,50,51,52,53]],[[0,54,55,56,57]],[[0,58,59,60,61]],[[0,62,63,64,65]],[[0,66,67,68,69]],[[0,70,71,72,73]],[[0,74,75,76,77]],[[0,78,79,80,81]],[[0,82,83,84,85]],[[0,86,87,88,89]],[[0,90,91,92,93]],[[0,94,95,96,97]],[[0,98,99,100,101]],[[0,102,103,104,105]],[[0,106,107,108,109]],[[0,110,111,112,113]],[[0,114,115,116,117]],[[0,118,119,120,121]],[[0,122,123,124,125]],[[0,126,127,128,129]],[[0,130,131,132,133]],[[0,134,135,136,137]],[[0,138,139,140,141]],[[0,142,143,144,145]],[[0,146,147,148,149]],[[0,150,151,152,153]],[[0,154,155,156]],[[0,157,158,159,160]],[[0,161,162,163,164]],[[0,165,166,167,168]],[[0,169,170,171,172]],[[0,173,174,175,176]],[[0,177,178,179,180]],[[0,181,182,183,184]],[[0,185,186,187,188]],[[0,189,190,191,192]],[[0,193,194,195,196]],[[0,197,198,199,200]],[[0,201,202,203,204]],[[0,205,206,207,208]],[[0,209,210,211,212]],[[0,213,214,215,216]],[[0,217,218,219,220]],[[0,221,222,223,224]],[[0,225,226,227,228]],[[0,229,230,231,232]],[[0,233,234,235,236]],[[0,237,238,239,240]],[[0,241,242,243,244]],[[0,245,246,247,248]],[[0,249,250,251,252]],[[0,253,254,255,256]]]}}}]}},"vertices":[[4122,3975,419],[4121,3977,419],[4122,3978,419],[4125,3981,419],[4129,3983,419],[4132,3986,419],[4135,3988,419],[4140,3989,419],[4144,3991,419],[4148,3993,419],[4151,3994,419],[4157,3994,419],[4161,3994,419],[4201,3994,419],[4202,3988,419],[4202,3965,419],[4201,3959,419],[4161,3959,419],[4157,3959,419],[4151,3959,419],[4148,3961,419],[4144,3962,419],[4140,3964,419],[4135,3965,419],[4132,3967,419],[4129,3970,419],[4125,3972,419],[4116,3975,424],[4114,3973,434],[4111,3977,434],[4114,3977,424],[4119,3972,424],[4116,3970,434],[4122,3969,424],[4121,3965,434],[4125,3965,424],[4125,3964,434],[4130,3962,424],[4129,3961,434],[4134,3961,424],[4132,3959,434],[4138,3959,424],[4137,3957,434],[4143,3957,424],[4141,3954,434],[4147,3956,424],[4146,3954,434],[4151,3954,424],[4151,3953,434],[4156,3954,424],[4156,3951,434],[4161,3954,424],[4161,3951,434],[4204,3954,424],[4205,3951,434],[4208,3965,424],[4211,3965,434],[4208,3988,424],[4211,3989,434],[4204,3999,424],[4205,4002,434],[4116,3978,424],[4114,3981,434],[4161,3999,424],[4161,4002,434],[4156,3999,424],[4156,4002,434],[4151,3999,424],[4151,4001,434],[4147,3998,424],[4146,3999,434],[4143,3996,424],[4141,3999,434],[4138,3994,424],[4137,3996,434],[4134,3993,424],[4132,3994,434],[4130,3991,424],[4129,3993,434],[4125,3988,424],[4125,3989,434],[4122,3985,424],[4121,3988,434],[4119,3981,424],[4116,3983,434]],"appearance":{"materials":[],"textures":[{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Wood_Cherry_Original.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"}],"vertices-texture":[[-0.0064096,0.0126916],[-0.0054856,0.0136702],[-0.0064096,0.0146487],[-0.0081251,0.0162031],[-0.0099563,0.0176194],[-0.0118921,0.0188889],[-0.0139208,0.020004],[-0.0160301,0.0209579],[-0.0182072,0.0217449],[-0.0204389,0.02236],[-0.0227117,0.0227998],[-0.0250118,0.0230613],[-0.0274145,0.0231463],[-0.0491072,0.0231463],[-0.0504867,0.0196976],[-0.0504867,0.0076428],[-0.0491072,0.004194],[-0.0274145,0.004194],[-0.0250118,0.004279],[-0.0227117,0.0045406],[-0.0204389,0.0049803],[-0.0182072,0.0055955],[-0.0160301,0.0063824],[-0.0139208,0.0073363],[-0.0118921,0.0084514],[-0.0099563,0.0097209],[-0.0081251,0.0111372],[-0.008056,-0.0105787],[-0.0079976,-0.0049084],[-0.0106393,-0.0049084],[-0.0094285,-0.0105787],[-0.0071948,-0.0168297],[-0.008056,-0.0129386],[-0.0094285,-0.0129386],[-0.0047224,-0.0107364],[-0.0046639,-0.0050662],[-0.0073056,-0.0050662],[-0.0072472,-0.0107364],[-0.0013524,-0.0108251],[-0.001294,-0.0051548],[-0.0039357,-0.0051548],[-0.0038772,-0.0108251],[-0.0038772,-0.0135736],[-0.0037723,-0.0174113],[-0.0014573,-0.0174113],[-0.0013524,-0.0135736],[-0.0048273,-0.0171763],[-0.0047224,-0.0133385],[-0.0072472,-0.0133385],[-0.0061731,-0.0171763],[0.0020334,-0.0108441],[0.0020918,-0.0051738],[-0.0005499,-0.0051738],[-0.0004914,-0.0108441],[0.0019285,-0.0174618],[0.0020334,-0.013624],[-0.0004914,-0.013624],[-0.0003865,-0.0174618],[0.0053096,-0.0173272],[0.0054145,-0.0134894],[0.0028897,-0.0134894],[0.0029946,-0.0173272],[0.0054145,-0.0107934],[0.005473,-0.0051231],[0.0028312,-0.0051231],[0.0028897,-0.0107934],[0.0087704,-0.0106731],[0.0088289,-0.0050029],[0.0061872,-0.0050029],[0.0062457,-0.0106731],[0.0120809,-0.0104842],[0.0121394,-0.0048139],[0.0094976,-0.0048139],[0.0095561,-0.0104842],[0.0153258,-0.0102277],[0.0153842,-0.0045574],[0.0127425,-0.0045574],[0.012801,-0.0102277],[0.0184854,-0.0099051],[0.0185439,-0.0042348],[0.0159022,-0.0042348],[0.0159606,-0.0099051],[0.0215407,-0.0095184],[0.0215992,-0.0038482],[0.0189575,-0.0038482],[0.0190159,-0.0095184],[0.0244731,-0.0090701],[0.0245315,-0.0033998],[0.0218898,-0.0033998],[0.0219483,-0.0090701],[0.0272967,-0.0085627],[0.0273232,-0.0028924],[0.0246815,-0.0028924],[0.02474,-0.0085627],[0.0509306,-0.0083068],[0.0519466,-0.0026365],[0.0273403,-0.0026365],[0.0273669,-0.0083068],[0.0263651,0.0044587],[0.0266541,0.0101289],[0.0192925,0.0101289],[0.0203086,0.0044587],[0.0202162,0.0061656],[0.0205052,0.0118359],[0.0068351,0.0118359],[0.0071241,0.0061656],[0.0050763,0.0071461],[0.0060924,0.0128164],[-0.0012692,0.0128164],[-0.0009802,0.0071461],[-0.0009802,0.0340767],[-0.0004616,0.0302389],[0.0032529,0.0302389],[0.0050763,0.0340767],[0.0071241,0.0314769],[0.0076428,0.0276392],[0.0196976,0.0276392],[0.0202162,0.0314769],[0.0203086,0.0269509],[0.022132,0.0231131],[0.0258464,0.0231131],[0.0263651,0.0269509],[0.0273669,-0.0068964],[0.0274145,-0.0107342],[0.0491072,-0.0107342],[0.0509306,-0.0068964],[0.02474,-0.0075748],[0.0248449,-0.0114126],[0.0272491,-0.0114126],[0.0272967,-0.0075748],[0.0219483,-0.0089201],[0.0220532,-0.0127579],[0.0243682,-0.0127579],[0.0244731,-0.0089201],[0.0190159,-0.010109],[0.0191208,-0.0139468],[0.0214358,-0.0139468],[0.0215407,-0.010109],[0.0159606,-0.0111342],[0.0160656,-0.0149719],[0.0183805,-0.0149719],[0.0184854,-0.0111342],[0.012801,-0.0119895],[0.0129059,-0.0158272],[0.0152209,-0.0158272],[0.0153258,-0.0119895],[0.0086655,-0.0170084],[0.0087704,-0.0131706],[0.0062457,-0.0131706],[0.0063506,-0.0170084],[0.0095561,-0.0126697],[0.009661,-0.0165074],[0.011976,-0.0165074],[0.0120809,-0.0126697],[-0.0132225,-0.0007235],[-0.0140838,-0.0046145],[-0.0118501,-0.0007235],[-0.013281,-0.0003647],[-0.0132225,-0.006035],[-0.0118501,-0.006035],[-0.0106393,-0.0003647],[-0.0273669,-0.0010706],[-0.0273403,0.0045997],[-0.0519466,0.0045997],[-0.0509306,-0.0010706],[-0.0274145,0.0084525],[-0.0273669,0.0122903],[-0.0509306,0.0122903],[-0.0491072,0.0084525],[-0.0257067,-0.001331],[-0.0256482,0.0043393],[-0.0282899,0.0043393],[-0.0282634,-0.001331],[-0.0250376,-0.0018802],[-0.0249791,0.0037901],[-0.0276208,0.0037901],[-0.0275623,-0.0018802],[-0.024209,-0.002414],[-0.0241506,0.0032563],[-0.0267923,0.0032563],[-0.0267338,-0.002414],[-0.0232261,-0.002929],[-0.0231677,0.0027412],[-0.0258094,0.0027412],[-0.0257509,-0.002929],[-0.0220948,-0.0034223],[-0.0220364,0.0022479],[-0.0246781,0.0022479],[-0.0246196,-0.0034223],[-0.0208219,-0.0038909],[-0.0207635,0.0017794],[-0.0234052,0.0017794],[-0.0233467,-0.0038909],[-0.0194152,-0.0043317],[-0.0193568,0.0013385],[-0.0219985,0.0013385],[-0.02194,-0.0043317],[-0.0178832,-0.0047423],[-0.0178247,0.000928],[-0.0204664,0.000928],[-0.020408,-0.0047423],[-0.0162351,-0.0051201],[-0.0161767,0.0005502],[-0.0188184,0.0005502],[-0.0187599,-0.0051201],[-0.014481,-0.0054628],[-0.0144225,0.0002074],[-0.0170643,0.0002074],[-0.0170058,-0.0054628],[-0.0152147,-0.0000981],[-0.0151563,-0.0057684],[-0.0126315,-0.0057684],[-0.012573,-0.0000981],[-0.0258116,0.0077621],[-0.0257067,0.0115999],[-0.0282634,0.0115999],[-0.0282158,0.0077621],[-0.0251425,0.0063059],[-0.0250376,0.0101437],[-0.0275623,0.0101437],[-0.0274574,0.0063059],[-0.0243139,0.0048906],[-0.024209,0.0087284],[-0.0267338,0.0087284],[-0.0266289,0.0048906],[-0.023331,0.0035248],[-0.0232261,0.0073626],[-0.0257509,0.0073626],[-0.025646,0.0035248],[-0.0221997,0.0022169],[-0.0220948,0.0060547],[-0.0246196,0.0060547],[-0.0245147,0.0022169],[-0.0209269,0.0009746],[-0.0208219,0.0048124],[-0.0233467,0.0048124],[-0.0232418,0.0009746],[-0.0195201,-0.0001943],[-0.0194152,0.0036434],[-0.02194,0.0036434],[-0.0218351,-0.0001943],[-0.0179881,-0.001283],[-0.0178832,0.0025548],[-0.020408,0.0025548],[-0.0203031,-0.001283],[-0.01634,-0.0022847],[-0.0162351,0.0015531],[-0.0187599,0.0015531],[-0.018655,-0.0022847],[-0.0145859,-0.0031934],[-0.014481,0.0006443],[-0.0170058,0.0006443],[-0.0169009,-0.0031934],[-0.0151563,-0.0001659],[-0.0150514,-0.0040036],[-0.0137055,-0.0040036],[-0.0126315,-0.0001659]]}} {"type":"CityJSONFeature","id":"GMLID_BUI100628_817_8083","CityObjects":{"GMLID_BUI100628_817_8083":{"type":"Bridge","geometry":[{"type":"MultiSurface","lod":"3","boundaries":[[[0,1,2,3]],[[4,5,1,0]],[[6,7,5,4]],[[3,2,7,6]],[[1,5,7,2]],[[4,0,3,6]],[[8,9,10,11]],[[12,13,14,15]],[[11,10,12,15]],[[10,9,13,12]],[[9,8,14,13]],[[8,11,15,14]],[[6,4,16,17]],[[18,19,20,21]],[[17,16,18,21]],[[16,4,19,18]],[[4,6,20,19]],[[6,17,21,20]],[[22,23,24,25]],[[26,27,28,29]],[[25,24,26,29]],[[24,23,27,26]],[[23,22,28,27]],[[22,25,29,28]],[[13,30,31,14]],[[32,33,30,13]],[[34,35,33,32]],[[14,31,35,34]],[[30,33,35,31]],[[32,13,14,34]]],"texture":{"visual":{"values":[[[0,0,1,2,3]],[[0,4,5,6,7]],[[0,8,9,10,11]],[[0,12,13,14,15]],[[0,16,17,18,19]],[[0,20,21,22,23]],[[1,24,25,26,27]],[[1,28,29,30,31]],[[1,32,33,34,35]],[[1,36,37,38,39]],[[1,40,41,42,43]],[[1,44,45,46,47]],[[1,48,49,50,51]],[[1,52,53,54,55]],[[1,56,57,58,59]],[[1,60,61,62,63]],[[1,64,65,54,66]],[[1,67,68,69,54]],[[1,70,71,72,73]],[[1,74,75,54,76]],[[1,77,78,79,80]],[[1,81,82,83,84]],[[1,85,86,54,87]],[[1,88,89,90,54]],[[0,91,92,93,94]],[[0,95,96,97,98]],[[0,99,100,101,102]],[[0,103,104,105,106]],[[0,107,108,109,110]],[[0,111,112,113,114]]]}}}]}},"vertices":[[6016,5499,1032],[6016,5499,1052],[5064,6048,1069],[5063,6049,1049],[6045,5551,1032],[6047,5551,1052],[5093,6101,1049],[5093,6100,1069],[4959,5867,1051],[5911,5317,1033],[5912,5317,1103],[4959,5866,1120],[5917,5325,1103],[5916,5326,1033],[4964,5875,1051],[4965,5875,1120],[6047,5551,1101],[5093,6100,1120],[6052,5559,1101],[6051,5560,1032],[5098,6109,1049],[5099,6109,1120],[4983,5910,1030],[5936,5361,1013],[5936,5361,1033],[4984,5910,1051],[6026,5517,1032],[6026,5517,1011],[5073,6066,1030],[5073,6066,1049],[5917,5326,1053],[4964,5875,1071],[5946,5378,1032],[5946,5378,1052],[4994,5927,1051],[4994,5927,1071]],"appearance":{"materials":[],"textures":[{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Bruecke-Schotter.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"},{"borderColor":[0.0,0.0,0.0,1.0],"image":"appearances/Bruecke-Beton.jpg","textureType":"unknown","type":"JPG","wrapMode":"wrap"}],"vertices-texture":[[2.3283218,0.8466667],[2.3283218,0.7620004],[-2.3283218,0.7620004],[-2.3283218,0.8466667],[0.5503364,0.8466821],[0.5503368,0.762016],[0.2963381,0.7620159],[0.2963381,0.8466822],[-2.32832,0.8465672],[-2.32832,0.761901],[2.3283238,0.7619011],[2.3283236,0.8465672],[0.2963319,0.8466667],[0.2963319,0.7620004],[0.5503306,0.7620004],[0.5503306,0.8466667],[2.3283218,0.5503348],[2.3283218,0.2963361],[-2.3283218,0.2963361],[-2.3283218,0.5503348],[2.3283276,0.2963172],[2.3283275,0.5503155],[-2.3283161,0.5503103],[-2.3283158,0.2963116],[-4e-7,8e-7],[9.166648,8e-7],[9.1666487,0.2916659],[1.6e-6,0.2916667],[-9.1666473,0.2916635],[-9.1666493,-2.9e-6],[-1.3e-6,-3e-6],[-6e-7,0.2916641],[2.3333208,1e-6],[2.3333193,4.5833245],[2.2499856,4.5833237],[2.2499863,3e-7],[-2.3333603,0.2916602],[-2.33336,-4.9e-6],[-2.2500279,-5.5e-6],[-2.2500267,0.2916608],[-2.3333277,4.5833242],[-2.3333291,0.0],[-2.2499961,7e-7],[-2.2499956,4.5833247],[2.3333291,0.0],[2.3333296,0.2916658],[2.249995,0.2916664],[2.2499961,-7e-7],[1.6e-6,-7e-7],[9.1666497,-6e-7],[9.1666477,0.2916657],[6e-7,0.2916665],[-9.1666487,0.2916652],[-9.1666484,0.0],[0.0,0.0],[-2e-6,0.2916658],[0.0833419,3e-7],[0.0833404,4.5833238],[8.3e-6,4.5833243],[9e-6,1e-6],[-0.0833447,0.2916594],[-0.083346,-6.9e-6],[-0.0000114,-6.3e-6],[-0.0000126,0.2916589],[-0.0833332,4.5833248],[-0.0833337,8e-7],[1.4e-6,4.5833242],[0.0833337,-7e-7],[0.0833335,0.2916665],[5e-7,0.2916658],[9e-7,-8e-7],[9.1666493,-9e-7],[9.1666488,0.0833325],[4e-7,0.0833326],[-9.1666505,0.0833321],[-9.166648,0.0],[-2.2e-6,0.0833322],[1.4999986,-3e-7],[1.5000043,4.5833239],[7.4e-6,4.5833253],[1.7e-6,1.1e-6],[-1.4999969,0.0833605],[-1.4999971,0.0000271],[5e-7,0.0000272],[0.0,0.0833593],[-1.5000042,4.5833242],[-1.4999985,0.0],[-6.6e-6,4.583324],[1.4999985,0.0],[1.4999983,0.0833334],[1.4e-6,0.0833322],[2.3283199,0.8465701],[2.3283201,0.761904],[-2.3283236,0.761904],[-2.3283236,0.8465702],[-0.2963256,0.8466821],[-0.2963256,0.7620159],[-0.5503239,0.762016],[-0.5503243,0.8466821],[-2.3283217,0.8466546],[-2.3283215,0.7619885],[2.3283221,0.7619885],[2.3283221,0.8466547],[-0.5503223,0.8466469],[-0.5503227,0.7619806],[-0.296324,0.7619806],[-0.296324,0.8466468],[2.3283264,1.3969773],[2.3283265,1.142979],[-2.3283171,1.1429739],[-2.3283174,1.3969726],[2.3283269,1.1429791],[2.3283266,1.3969779],[-2.3283169,1.3969723],[-2.3283168,1.142974]]}} {"type":"CityJSONFeature","id":"UUID_f488e8ce-b953-4b35-a3fe-a394fb203868","CityObjects":{"GMLID_SO0200811_525_4986":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[0],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0158953_776_12462":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[2],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0260143_1214_1315":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[10],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0443764_1492_9667":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[13],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0124800_3522_13577":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[12],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0400934_2926_3381":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[11],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO015374_872_14131":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[1],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"UUID_f488e8ce-b953-4b35-a3fe-a394fb203868":{"type":"CityObjectGroup","children":["GMLID_SO0200811_525_4986","GMLID_SO015374_872_14131","GMLID_SO0158953_776_12462","GMLID_SO0321978_1466_7441","GMLID_SO0286258_965_2893","GMLID_SO0410128_214_10515","GMLID_SO0283142_1335_8999","GMLID_SO086970_1053_8814","GMLID_SO0107241_3793_12555","GMLID_SO081792_2341_11961","GMLID_SO0260143_1214_1315","GMLID_SO0400934_2926_3381","GMLID_SO0124800_3522_13577","GMLID_SO0443764_1492_9667"]},"GMLID_SO081792_2341_11961":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[9],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0286258_965_2893":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[4],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0283142_1335_8999":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[6],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0321978_1466_7441":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[3],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0107241_3793_12555":{"type":"SolitaryVegetationObject","attributes":{"class":"1070","species":"1800"},"geometry":[{"type":"GeometryInstance","boundaries":[8],"template":1,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO0410128_214_10515":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[5],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]},"GMLID_SO086970_1053_8814":{"type":"SolitaryVegetationObject","attributes":{"class":"1060","species":"1640"},"geometry":[{"type":"GeometryInstance","boundaries":[7],"template":0,"transformationMatrix":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0]}],"parents":["UUID_f488e8ce-b953-4b35-a3fe-a394fb203868"]}},"vertices":[[560,6240,1271],[134,6188,1370],[754,6706,1370],[240,6400,1373],[1065,6523,1221],[1325,6632,1221],[141,5520,1221],[90,5164,1221],[486,5879,1219],[478,6489,1370],[1654,6712,1221],[837,6480,1261],[159,6800,1521],[744,6163,1219]],"appearance":{"materials":[],"textures":[],"vertices-texture":[]}} diff --git a/tests/validate.rs b/tests/validate.rs index a2f8492..5120695 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -380,25 +380,6 @@ fn every_fixture_and_everything_made_from_it_validates() { let docs: Vec = if name.ends_with(".jsonl") { let mut lines = text.lines(); let header = CityJSON::from_str(lines.next().unwrap()).unwrap(); - if name.ends_with("geom_temp.city.jsonl") { - //-- this header was written by a `cat` that sliced the - //-- appearance to the templates' materials but left the - //-- templates' indices unrenumbered (fixed since): template - //-- 1 points past the two materials left. It is reported, - //-- and nothing built on it can validate. - let e = header.validate().unwrap_err(); - assert!( - e.to_string() - .contains("Material index 2 is out of range (there are 2)"), - "{e}" - ); - for l in lines { - let f = CityJSONFeature::from_str(l).unwrap(); - f.validate() - .unwrap_or_else(|e| panic!("{name} feature {}: {e}", f.id)); - } - continue; - } header .validate() .unwrap_or_else(|e| panic!("{name} header: {e}")); From 1502a28788bff3f0ff7e46a3cb81a800a89d8967 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 18:58:09 +0200 Subject: [PATCH 52/65] feat: Display for CityJSONVersion, spelled as in JSON ("1.1", "2.0") --- src/cityjson.rs | 10 ++++++++++ tests/typed_strings.rs | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/cityjson.rs b/src/cityjson.rs index 2230810..df08c41 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -558,6 +558,16 @@ pub enum CityJSONVersion { V2_0, } +impl std::fmt::Display for CityJSONVersion { + /// The version as the `version` member spells it, eg `2.0`. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + CityJSONVersion::V1_1 => "1.1", + CityJSONVersion::V2_0 => "2.0", + }) + } +} + /// One entry of the `extensions` member (§ 8): where the Extension's schema /// lives and which version of it the document uses. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] diff --git a/tests/typed_strings.rs b/tests/typed_strings.rs index a0bd2a8..047d8fb 100644 --- a/tests/typed_strings.rs +++ b/tests/typed_strings.rs @@ -155,3 +155,13 @@ fn a_texture_ring_is_untextured_or_a_texture_with_uvs() { ); } } + +#[test] +fn a_version_displays_as_it_is_spelled_in_json() { + assert_eq!(CityJSONVersion::V1_1.to_string(), "1.1"); + assert_eq!(CityJSONVersion::V2_0.to_string(), "2.0"); + assert_eq!( + serde_json::to_value(CityJSONVersion::V2_0).unwrap(), + json!(CityJSONVersion::V2_0.to_string()) + ); +} From 2b635a12196d1c5264f7125f22165fdac8f68e96 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 19:00:04 +0200 Subject: [PATCH 53/65] feat: warnings for rule 3 and for feature members collect drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CityJSON::warnings()/CityJSONFeature::warnings() report a core City Object with an Extension City Object as a child (spec § 8.7 rule 3, ExtensionChildOfCoreObject). It is a warning rather than an error because the Noise Extension published with the spec does exactly this (tests/data/noise_extension.city.jsonl validates, with one warning per segment). CityObjectGroup members are exempt. - CityJSONFeature lines keep accepting root members the schema does not name (cityjsonfeature.schema.json 2.0.2 has additionalProperties: true). Collector drops them, as there is nowhere to keep them per feature in a document, and reports each as a DroppedMember in Collector::warnings(). - cjseq validate prints warnings and still exits 0 when there are no errors; cjseq collect prints its warnings to stderr. - CHANGELOG and README updated. --- CHANGELOG.md | 4 +++ README.md | 2 ++ src/cityjson.rs | 16 ++++++++++- src/error.rs | 18 +++++++++++++ src/main.rs | 25 +++++++++++------ src/validate.rs | 47 ++++++++++++++++++++++++++++++++ tests/cli.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++++ tests/seq.rs | 23 ++++++++++++++++ tests/validate.rs | 56 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 tests/cli.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f764899..f50db69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ - wasm-bindgen bindings are behind an off-by-default `wasm` cargo feature, so library consumers building for wasm32 no longer inherit them. ### Added - `validate()`/`errors()` on `CityJSON` and `CityJSONFeature`: every vertex, template, semantic surface, material, texture and texture-vertex index is in range, value arrays match the boundaries they describe, the parent/child hierarchy agrees with itself, and a feature carries no document-only members. The CLI has a `validate` subcommand. +- `validate()` also checks the schemas' Extension name patterns: `+` then a capital letter for a City Object type, `+` then word characters for a semantic surface type, and `X.Y` or `X.Y.Z` for an Extension's version (`CjseqError::Pattern`). +- `warnings()` on `CityJSON` and `CityJSONFeature` reports what the spec advises against but a valid file may still do: a core City Object with an Extension child (§ 8.7 rule 3), which the Noise Extension example published with the spec does. `cjseq validate` prints warnings without failing. +- `Collector::warnings()`: CityJSONFeature lines may carry root members the schema does not name, and collecting drops them, one `DroppedMember` warning each; `cjseq collect` prints them to stderr. +- `Display` for `CityJSONVersion` (`1.1`, `2.0`). - Features keep every descendant of their top-level City Object, not only its children. - `CityObject::new`, `children_roles`, `Geometry::surfaces`, `PartialEq` on the public types. - Round-trip, validation and sequencing tests over real CityJSONSeq fixtures; CI runs fmt, clippy with warnings denied, the tests and the wasm build. diff --git a/README.md b/README.md index 1a01ff2..9c7ba76 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ cat myfile.city.jsonl | cjseq filter --bbox 85007 446179 85168 446290 > mysubset cjseq validate myfile.city.jsonl ``` +It also prints warnings, without failing, for what the spec advises against but a valid file may still do: a core City Object with an Extension City Object as a child (§ 8.7 rule 3). + It complements, and does not replace, schema validation with [cjval](https://github.com/cityjson/cjval). ## Input constraints diff --git a/src/cityjson.rs b/src/cityjson.rs index df08c41..3baf34e 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -368,6 +368,7 @@ pub struct Collector { /// document's palettes. materials: HashMap, textures: HashMap, + warnings: Vec, } impl Collector { @@ -390,6 +391,7 @@ impl Collector { correction: None, materials, textures, + warnings: Vec::new(), } } @@ -402,7 +404,8 @@ impl Collector { /// Add one feature: its City Objects, its vertices (re-expressed if a /// later header changed the transform) and its appearance, merged into - /// the document's palettes. + /// the document's palettes. Root members the feature schema allows but + /// does not name are dropped, each with a [`Collector::warnings`] entry. pub fn add(&mut self, mut cjf: CityJSONFeature) -> Result<()> { if !cjf.city_objects.contains_key(&cjf.id) { return Err(CjseqError::DanglingReference { @@ -410,6 +413,12 @@ impl Collector { to: cjf.id, }); } + for member in cjf.other.keys() { + self.warnings.push(CjseqError::DroppedMember { + feature: cjf.id.clone(), + member: member.clone(), + }); + } let doc = &mut self.doc; let mut m_oldnew: HashMap = HashMap::new(); let mut t_oldnew: HashMap = HashMap::new(); @@ -469,6 +478,11 @@ impl Collector { Ok(()) } + /// What collecting has had to drop so far. + pub fn warnings(&self) -> &[CjseqError] { + &self.warnings + } + /// The collected document, with duplicate vertices merged, the transform /// moved to the vertices' minimum corner and the geographical extent /// recomputed. diff --git a/src/error.rs b/src/error.rs index bca64f5..1542ca7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,6 +40,14 @@ pub enum CjseqError { expected: usize, found: usize, }, + /// A core City Object has an Extension City Object as a child, which + /// § 8.7 rule 3 says it should not. Reported by `warnings()`, not + /// `errors()`: the Noise Extension published with the spec does it. + ExtensionChildOfCoreObject { parent: String, child: String }, + /// Collecting dropped a root member of a CityJSONFeature: the feature + /// schema allows any, but a CityJSON document has nowhere to keep them + /// per feature. Reported by [`crate::Collector::warnings`]. + DroppedMember { feature: String, member: String }, /// A name or version does not match the pattern the schema gives it. Pattern { /// What the value is, eg `"Extension City Object type"`. @@ -110,6 +118,14 @@ impl fmt::Display for CjseqError { expected, found, } => write!(f, "{found} {what} where the boundaries call for {expected}"), + CjseqError::ExtensionChildOfCoreObject { parent, child } => write!( + f, + "core City Object {parent:?} has the Extension City Object {child:?} as a child (§ 8.7 rule 3)" + ), + CjseqError::DroppedMember { feature, member } => write!( + f, + "dropped the root member {member:?} of CityJSONFeature {feature:?}" + ), CjseqError::Pattern { what, value, @@ -146,6 +162,8 @@ impl std::error::Error for CjseqError { | CjseqError::ValueCount { .. } | CjseqError::MisplacedMember { .. } | CjseqError::Pattern { .. } + | CjseqError::ExtensionChildOfCoreObject { .. } + | CjseqError::DroppedMember { .. } | CjseqError::IndexOutOfRange { .. } => None, } } diff --git a/src/main.rs b/src/main.rs index 0bb1bb0..a180ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,6 +167,9 @@ fn collect(files: &[String]) -> Result<()> { read(&mut BufReader::new(File::open(file)?))?; } } + for w in collector.iter().flat_map(Collector::warnings) { + eprintln!("warning: {w}"); + } let cj = collector.map(Collector::finish).unwrap_or_default(); let mut out = BufWriter::new(io::stdout().lock()); serde_json::to_writer(&mut out, &cj)?; @@ -238,30 +241,36 @@ fn filter(keep: &Filter, exclude: bool) -> Result<()> { Ok(()) } -/// Print every problem in a CityJSON document or a CityJSONSeq; fail if -/// there is one. +/// Print every problem in a CityJSON document or a CityJSONSeq, and every +/// warning; fail if there is a problem. fn validate(input: &str) -> Result<()> { let mut problems: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); match input.parse::() { Ok(cj) => { problems.extend(cj.errors().iter().map(ToString::to_string)); + warnings.extend(cj.warnings().iter().map(ToString::to_string)); } _ => { for line in SeqReader::new(input.as_bytes()) { match line { Err(e) => problems.push(e.to_string()), Ok(Line::Header(h)) => { - problems.extend(h.errors().iter().map(|e| format!("header: {e}"))) + problems.extend(h.errors().iter().map(|e| format!("header: {e}"))); + warnings.extend(h.warnings().iter().map(|e| format!("header: {e}"))); + } + Ok(Line::Feature(f)) => { + let at = |e: &CjseqError| format!("feature {:?}: {e}", f.id); + problems.extend(f.errors().iter().map(at)); + warnings.extend(f.warnings().iter().map(at)); } - Ok(Line::Feature(f)) => problems.extend( - f.errors() - .iter() - .map(|e| format!("feature {:?}: {e}", f.id)), - ), } } } } + for w in &warnings { + println!("warning: {w}"); + } for p in &problems { println!("{p}"); } diff --git a/src/validate.rs b/src/validate.rs index 12c003b..763647f 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -348,6 +348,42 @@ fn hierarchy(cos: &IndexMap, errs: &mut Vec) { } } +/// § 8.7 rule 3: a core City Object should not have an Extension child. +/// A CityObjectGroup's `children` are its members, not a hierarchy, so the +/// rule does not apply to it. Each pair is reported once, whichever side +/// names the other. +fn extension_children(cos: &IndexMap) -> Vec { + let core = |co: &CityObject| { + matches!(co.thetype, CityObjectType::Known(_)) + && co.thetype != CityObjectType::CityObjectGroup + }; + let ext = |co: &CityObject| matches!(co.thetype, CityObjectType::Extension(_)); + let mut pairs: indexmap::IndexSet<(&str, &str)> = indexmap::IndexSet::new(); + for (id, co) in cos { + if core(co) { + for child in co.children.iter().flatten() { + if cos.get(child).is_some_and(ext) { + pairs.insert((id, child)); + } + } + } + if ext(co) { + for parent in co.parents.iter().flatten() { + if cos.get(parent).is_some_and(core) { + pairs.insert((parent, id)); + } + } + } + } + pairs + .into_iter() + .map(|(parent, child)| CjseqError::ExtensionChildOfCoreObject { + parent: parent.to_string(), + child: child.to_string(), + }) + .collect() +} + fn city_objects(cos: &IndexMap, l: &Lengths, errs: &mut Vec) { hierarchy(cos, errs); for (id, co) in cos { @@ -414,6 +450,12 @@ impl CityJSON { pub fn validate(&self) -> Result<()> { first(self.errors()) } + + /// What the spec advises against but a valid document may still do: + /// a core City Object with an Extension child (§ 8.7 rule 3). + pub fn warnings(&self) -> Vec { + extension_children(&self.city_objects) + } } impl CityJSONFeature { @@ -443,4 +485,9 @@ impl CityJSONFeature { pub fn validate(&self) -> Result<()> { first(self.errors()) } + + /// As [`CityJSON::warnings`]. + pub fn warnings(&self) -> Vec { + extension_children(&self.city_objects) + } } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..6a0486c --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,69 @@ +//! The `cjseq` binary, run as a user would. + +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +fn cjseq(args: &[&str], stdin: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_cjseq")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +fn text(b: &[u8]) -> String { + String::from_utf8_lossy(b).into_owned() +} + +#[test] +fn validate_prints_warnings_without_failing() { + let out = cjseq(&["validate", "tests/data/noise_extension.city.jsonl"], ""); + let stdout = text(&out.stdout); + assert!(out.status.success(), "{stdout}{}", text(&out.stderr)); + assert_eq!( + stdout.matches("warning:").count(), + 2, + "one warning per +NoiseCityFurnitureSegment under a CityFurniture:\n{stdout}" + ); + assert!(stdout.contains("a_noisy_bench"), "{stdout}"); + assert!(stdout.trim_end().ends_with("valid"), "{stdout}"); +} + +#[test] +fn validate_still_fails_on_an_error() { + let doc = r#"{"type":"CityJSON","version":"2.0","transform":{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]},"CityObjects":{"b":{"type":"Building","children":["x"]}},"vertices":[]}"#; + let out = cjseq(&["validate"], doc); + assert!(!out.status.success()); + assert!(text(&out.stdout).contains("\"x\""), "{}", text(&out.stdout)); +} + +#[test] +fn collect_warns_about_each_feature_member_it_drops() { + let seq = concat!( + r#"{"type":"CityJSON","version":"2.0","transform":{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]},"CityObjects":{},"vertices":[]}"#, + "\n", + r#"{"type":"CityJSONFeature","id":"a","CityObjects":{"a":{"type":"Building"}},"vertices":[],"+provenance":{"run":7}}"#, + "\n", + r#"{"type":"CityJSONFeature","id":"b","CityObjects":{"b":{"type":"Building"}},"vertices":[]}"#, + "\n" + ); + let out = cjseq(&["collect"], seq); + assert!(out.status.success(), "{}", text(&out.stderr)); + let stderr = text(&out.stderr); + assert_eq!(stderr.matches("warning:").count(), 1, "{stderr}"); + assert!( + stderr.contains("+provenance") && stderr.contains("\"a\""), + "{stderr}" + ); + //-- the output itself is unchanged: the member is not in it + assert!(!text(&out.stdout).contains("+provenance")); +} diff --git a/tests/seq.rs b/tests/seq.rs index 8e95f35..502630f 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -228,3 +228,26 @@ fn collecting_merges_identical_palette_entries() { let m = |id: &str| v["CityObjects"][id]["geometry"][0]["material"]["m"]["value"].clone(); assert_eq!((m("a"), m("b"), m("c")), (json!(0), json!(1), json!(1))); } + +/// A CityJSONFeature may carry root members the schema does not name +/// (`additionalProperties: true`), but a CityJSON document has nowhere to +/// put them per feature: collecting drops each and says so. +#[test] +fn collecting_reports_each_feature_root_member_it_drops() { + let (header, _) = read("tests/data/small.city.jsonl"); + let mut c = Collector::new(header); + let f = CityJSONFeature::from_str( + r#"{"type":"CityJSONFeature","id":"a","CityObjects":{"a":{"type":"Building"}},"vertices":[],"+provenance":{"run":7},"+note":"x"}"#, + ) + .unwrap(); + assert_eq!(f.other.len(), 2, "the feature itself keeps them"); + c.add(f).unwrap(); + let w = c.warnings(); + assert_eq!(w.len(), 2, "{w:?}"); + assert!( + matches!(&w[0], CjseqError::DroppedMember { feature, member } if feature == "a" && member == "+provenance") + ); + assert!(matches!(&w[1], CjseqError::DroppedMember { member, .. } if member == "+note")); + let doc = c.finish(); + assert!(!doc.other.contains_key("+provenance")); +} diff --git a/tests/validate.rs b/tests/validate.rs index 5120695..15700b6 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -496,3 +496,59 @@ fn extension_names_and_versions_follow_the_schema_patterns() { ); } } + +/// § 8.7 rule 3: a core City Object should not have an Extension City +/// Object as a child. The Noise Extension published with the spec does +/// exactly that, so it is a warning, not an error: `validate()` passes and +/// `warnings()` reports it. +#[test] +fn an_extension_child_under_a_core_city_object_is_a_warning() { + let cj = doc( + json!({"b": {"type": "Building", "children": ["x"]}, + "x": {"type": "+Balcony", "parents": ["b"]}}), + json!([]), + json!({}), + ); + cj.validate().unwrap(); + let w = cj.warnings(); + assert_eq!(w.len(), 1, "{w:?}"); + assert!( + matches!(&w[0], CjseqError::ExtensionChildOfCoreObject { parent, child } if parent == "b" && child == "x"), + "{w:?}" + ); + //-- an Extension parent may have either kind of child + let ok = doc( + json!({"f": {"type": "+FancyBuilding", "children": ["x", "p"]}, + "x": {"type": "+Balcony", "parents": ["f"]}, + "p": {"type": "BuildingPart", "parents": ["f"]}}), + json!([]), + json!({}), + ); + assert!(ok.warnings().is_empty()); + //-- a CityObjectGroup's children are its members, not a hierarchy + let group = doc( + json!({"g": {"type": "CityObjectGroup", "children": ["x"]}, + "x": {"type": "+Balcony", "parents": ["g"]}}), + json!([]), + json!({}), + ); + assert!(group.warnings().is_empty()); +} + +#[test] +fn the_noise_extension_example_is_valid_with_a_warning_per_segment() { + let text = std::fs::read_to_string("tests/data/noise_extension.city.jsonl").unwrap(); + let mut lines = text.lines(); + let header = CityJSON::from_str(lines.next().unwrap()).unwrap(); + let mut c = cjseq::Collector::new(header); + for l in lines { + let f = CityJSONFeature::from_str(l).unwrap(); + f.validate().unwrap(); + c.add(f).unwrap(); + } + let cj = c.finish(); + cj.validate().unwrap(); + let w = cj.warnings(); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w.iter().all(|e| matches!(e, CjseqError::ExtensionChildOfCoreObject { parent, .. } if parent == "a_noisy_bench"))); +} From 15720a0b735b56268d5d60b0ac74956fc22981a9 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 19:56:03 +0200 Subject: [PATCH 54/65] test: collect(cat(doc)) == doc, with every index resolved tests/cat_collect.rs slices a document into a CityJSONSeq and collects it back, then compares the two with every vertex resolved to its coordinates on the original grid and every material, texture, texture vertex and template index resolved to what it points at, so vertex and palette renumbering do not count as differences but anything lost or pointing elsewhere does. It runs over both document fixtures, every sequence fixture (collected first), and one document that uses an address with a location, default themes, materials, textures, a geometry template with its own appearance, a CityObjectGroup, an Extension City Object with a child and root, attribute and semantic extensions. That last case fails today (the address location is not renumbered) and is #[ignore]d until the fixes that follow. --- tests/cat_collect.rs | 323 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 tests/cat_collect.rs diff --git a/tests/cat_collect.rs b/tests/cat_collect.rs new file mode 100644 index 0000000..aba85bc --- /dev/null +++ b/tests/cat_collect.rs @@ -0,0 +1,323 @@ +//! `collect(cat(doc)) == doc`, at the source level. +//! +//! `cat` renumbers each feature's vertices and palettes, and `collect` +//! merges them and moves the transform, so the two documents are compared +//! with every index resolved to what it points at: a vertex to its +//! coordinates on the original document's grid, a material, texture or +//! texture vertex to the entry itself, a GeometryInstance's template to the +//! template. Everything else is compared as it is. + +use cjseq::{CityJSON, Collector, SortingStrategy}; +use serde_json::{json, Map, Value}; +use std::str::FromStr; + +fn cat_then_collect(doc: &CityJSON) -> CityJSON { + let mut c = Collector::new(doc.header().unwrap()); + for f in doc.features(SortingStrategy::AsIs) { + c.add(f.unwrap()).unwrap(); + } + assert!(c.warnings().is_empty(), "{:?}", c.warnings()); + c.finish() +} + +/// What each kind of index resolves to in one document. +struct Palettes<'a> { + vertices: Vec<[i64; 3]>, + materials: &'a [Value], + textures: &'a [Value], + uvs: &'a [Value], + templates: &'a [Value], + template_vertices: &'a [Value], +} + +const NONE: &[Value] = &[]; + +fn palettes<'a>(doc: &'a Value, grid: &Value) -> Palettes<'a> { + let t = &doc["transform"]; + let g = |k: &str, i: usize| grid[k][i].as_f64().unwrap(); + let vertices = doc["vertices"] + .as_array() + .unwrap() + .iter() + .map(|v| { + [0, 1, 2].map(|i| { + let real = v[i].as_f64().unwrap() * t["scale"][i].as_f64().unwrap() + + t["translate"][i].as_f64().unwrap(); + ((real - g("translate", i)) / g("scale", i)).round() as i64 + }) + }) + .collect(); + let arr = |v: &'a Value| v.as_array().map_or(NONE, |a| &a[..]); + let app = &doc["appearance"]; + Palettes { + vertices, + materials: arr(&app["materials"]), + textures: arr(&app["textures"]), + uvs: arr(&app["vertices-texture"]), + templates: arr(&doc["geometry-templates"]["templates"]), + template_vertices: arr(&doc["geometry-templates"]["vertices-templates"]), + } +} + +/// Replace every vertex index in `b` (boundaries of any depth). +fn resolve_boundaries(b: &Value, vertex: &dyn Fn(usize) -> Value) -> Value { + match b { + Value::Array(a) => Value::Array(a.iter().map(|x| resolve_boundaries(x, vertex)).collect()), + Value::Number(n) => vertex(n.as_u64().unwrap() as usize), + other => other.clone(), + } +} + +fn resolve_material_values(v: &Value, p: &Palettes) -> Value { + match v { + Value::Array(a) => Value::Array(a.iter().map(|x| resolve_material_values(x, p)).collect()), + Value::Number(n) => p.materials[n.as_u64().unwrap() as usize].clone(), + other => other.clone(), + } +} + +fn resolve_texture_values(v: &Value, p: &Palettes) -> Value { + match v { + Value::Array(a) if a.first().is_some_and(|x| !x.is_array()) => { + //-- a ring: [texture, uv, uv, ...] or [null] + match a[0].as_u64() { + None => Value::Array(vec![Value::Null]), + Some(t) => { + let mut out = vec![p.textures[t as usize].clone()]; + out.extend( + a[1..] + .iter() + .map(|uv| p.uvs[uv.as_u64().unwrap() as usize].clone()), + ); + Value::Array(out) + } + } + } + Value::Array(a) => Value::Array(a.iter().map(|x| resolve_texture_values(x, p)).collect()), + other => other.clone(), + } +} + +fn resolve_geometry(g: &Value, p: &Palettes, vertex: &dyn Fn(usize) -> Value) -> Value { + let mut g = g.as_object().unwrap().clone(); + if let Some(b) = g.get("boundaries") { + g.insert("boundaries".into(), resolve_boundaries(b, vertex)); + } + if let Some(Value::Object(themes)) = g.get_mut("material") { + for m in themes.values_mut() { + let m = m.as_object_mut().unwrap(); + for k in ["value", "values"] { + if let Some(v) = m.get(k) { + let r = resolve_material_values(v, p); + m.insert(k.into(), r); + } + } + } + } + if let Some(Value::Object(themes)) = g.get_mut("texture") { + for t in themes.values_mut() { + if let Some(v) = t.get("values") { + let r = resolve_texture_values(v, p); + t["values"] = r; + } + } + } + if let Some(t) = g.get("template").and_then(Value::as_u64) { + let tv = |i: usize| p.template_vertices[i].clone(); + g.insert( + "template".into(), + resolve_geometry(&p.templates[t as usize], p, &tv), + ); + } + Value::Object(g) +} + +/// `doc` with every index resolved, its palettes and vertices gone, and the +/// geographical extent (which `collect` recomputes) left out. +fn resolved(doc: &CityJSON, grid: &Value) -> Value { + let v = serde_json::to_value(doc).unwrap(); + let p = palettes(&v, grid); + let vertex = |i: usize| json!(p.vertices[i]); + let mut out = v.as_object().unwrap().clone(); + for k in ["vertices", "transform", "geometry-templates"] { + out.remove(k); + } + if let Some(Value::Object(app)) = out.get_mut("appearance") { + for k in ["materials", "textures", "vertices-texture"] { + app.remove(k); + } + if app.is_empty() { + out.remove("appearance"); + } + } + if let Some(Value::Object(m)) = out.get_mut("metadata") { + m.remove("geographicalExtent"); + } + let mut cos = Map::new(); + for (id, co) in v["CityObjects"].as_object().unwrap() { + let mut co = co.as_object().unwrap().clone(); + if let Some(Value::Array(gs)) = co.get("geometry") { + let gs = gs + .iter() + .map(|g| resolve_geometry(g, &p, &vertex)) + .collect(); + co.insert("geometry".into(), Value::Array(gs)); + } + if let Some(Value::Array(addrs)) = co.get("address") { + let addrs = addrs + .iter() + .map(|a| { + let mut a = a.as_object().unwrap().clone(); + if let Some(l) = a.get("location") { + let r = resolve_geometry(l, &p, &vertex); + a.insert("location".into(), r); + } + Value::Object(a) + }) + .collect(); + co.insert("address".into(), Value::Array(addrs)); + } + cos.insert(id.clone(), Value::Object(co)); + } + out.insert("CityObjects".into(), Value::Object(cos)); + Value::Object(out) +} + +fn assert_round_trips(doc: &CityJSON, what: &str) { + doc.validate() + .unwrap_or_else(|e| panic!("{what} must be valid: {e}")); + let back = cat_then_collect(doc); + back.validate() + .unwrap_or_else(|e| panic!("{what} collected: {e}")); + let grid = serde_json::to_value(&doc.transform).unwrap(); + let (a, b) = (resolved(doc, &grid), resolved(&back, &grid)); + if a != b { + let (a, b) = (a.as_object().unwrap(), b.as_object().unwrap()); + for k in a.keys().chain(b.keys()) { + if a.get(k) != b.get(k) { + if k == "CityObjects" { + for (id, co) in a[k].as_object().unwrap() { + let other = b[k] + .get(id) + .unwrap_or_else(|| panic!("{what}: {id} is missing")); + for (m, v) in co.as_object().unwrap() { + assert_eq!( + Some(v), + other.get(m), + "{what}: City Object {id}, member {m}" + ); + } + assert_eq!(co, other, "{what}: City Object {id}"); + } + } + assert_eq!(a.get(k), b.get(k), "{what}: member {k}"); + } + } + } +} + +/// One document exercising what a sequence must carry: an address with a +/// location, default themes, materials, textures, a geometry template with +/// its own appearance, a CityObjectGroup, an Extension City Object with a +/// child, and root, attribute and semantic-surface extensions. +fn everything() -> CityJSON { + let tm = + json!([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]); + CityJSON::from_str(&json!({ + "type": "CityJSON", "version": "2.0", + "transform": {"scale": [0.01, 0.01, 0.01], "translate": [100.0, 200.0, 0.0]}, + "metadata": {"title": "everything", "geographicalExtent": [100.0, 200.0, 0.0, 100.09, 200.09, 0.0]}, + "extensions": {"Noise": {"url": "https://x.org/noise.json", "version": "2.0"}}, + "+census": {"percent_men": 49.5}, + "CityObjects": { + "b1": { + "type": "Building", + "attributes": {"+colour": [1, 2, 3, 4]}, + "address": [{"country": "NL", "location": {"type": "MultiPoint", "lod": "1", "boundaries": [8]}}], + "children": ["b1p"], + "geometry": [{ + "type": "MultiSurface", "lod": "2", + "boundaries": [[[0, 1, 2]], [[0, 2, 3]]], + "semantics": {"surfaces": [{"type": "RoofSurface"}, {"type": "+ThermalSurface", "azimuth": 3.0}], "values": [0, 1]}, + "material": {"irradiation": {"values": [1, null]}, "visual": {"value": 0}}, + "texture": {"winter": {"values": [[[1, 0, 1, 2]], [[null]]]}} + }] + }, + "b1p": { + "type": "BuildingPart", "parents": ["b1"], + "geometry": [{"type": "MultiSurface", "lod": "2", "boundaries": [[[4, 5, 6]]], + "material": {"visual": {"value": 2}}, + "texture": {"winter": {"values": [[[0, 3, 4, 5]]]}}}] + }, + "b2": { + "type": "Building", "parents": ["g"], + "geometry": [{"type": "Solid", "lod": "1", "boundaries": [[[[0, 1, 2]], [[2, 3, 7]]]], + "material": {"visual": {"values": [[0, 2]]}}}] + }, + "tree": { + "type": "SolitaryVegetationObject", "parents": ["g"], + "geometry": [{"type": "GeometryInstance", "template": 0, "boundaries": [9], "transformationMatrix": tm}] + }, + "g": {"type": "CityObjectGroup", "children": ["b2", "tree"], "children_roles": ["a", null]}, + "n": {"type": "+NoiseBuilding", "children": ["np"], "+lden": 55, + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [1, 3]}]}, + "np": {"type": "+NoiseBuildingPart", "parents": ["n"], + "geometry": [{"type": "MultiLineString", "lod": "1", "boundaries": [[4, 7]]}]} + }, + "vertices": [[0,0,0],[9,0,0],[9,9,0],[0,9,0],[1,1,0],[2,1,0],[2,2,0],[5,5,0],[3,4,0],[6,6,0]], + "appearance": { + "default-theme-material": "visual", + "default-theme-texture": "winter", + "materials": [{"name": "m0"}, {"name": "m1", "transparency": 0.5}, {"name": "m2", "diffuseColor": [0.1, 0.2, 0.3]}, {"name": "tm"}], + "textures": [{"type": "PNG", "image": "a.png"}, {"type": "JPG", "image": "b.jpg"}, {"type": "PNG", "image": "t.png"}], + "vertices-texture": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.5, 0.5], [0.6, 0.5], [0.6, 0.6], [0.9, 0.9], [0.8, 0.8], [0.7, 0.7]] + }, + "geometry-templates": { + "templates": [{"type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]]], + "material": {"visual": {"value": 3}}, + "texture": {"winter": {"values": [[[2, 6, 7, 8]]]}}}], + "vertices-templates": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + } + }) + .to_string()) + .unwrap() +} + +#[test] +#[ignore = "address locations and default themes are lost; fixed in the next commits"] +fn a_document_using_everything_survives_cat_and_collect() { + assert_round_trips(&everything(), "everything()"); +} + +#[test] +fn every_document_fixture_survives_cat_and_collect() { + for path in ["data/3dbag_b2.city.json", "data/1b_w_texture.city.json"] { + let doc = CityJSON::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_round_trips(&doc, path); + } +} + +/// The sequence fixtures, collected and then cat-and-collected again. +#[test] +fn every_sequence_fixture_survives_cat_and_collect() { + let mut paths: Vec<_> = std::fs::read_dir("tests/data") + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.to_string_lossy().ends_with(".city.jsonl")) + .collect(); + paths.sort(); + for path in paths { + let name = path.display().to_string(); + if name.ends_with("empty_appearance.city.jsonl") { + //-- one of its features is invalid on purpose (see validate.rs) + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + let mut lines = text.lines(); + let mut c = Collector::new(CityJSON::from_str(lines.next().unwrap()).unwrap()); + for l in lines { + c.add(l.parse().unwrap()).unwrap(); + } + assert_round_trips(&c.finish(), &name); + } +} From 917dfbd1a0dd4fa3009482762db067fb7c9cb9b5 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 19:57:06 +0200 Subject: [PATCH 55/65] fix: renumber and check address locations like any geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Building, BuildingPart, BuildingUnit, Bridge or BridgePart address may carry a location, a MultiPoint into the document's vertices (§ 2.3). It sat untyped in CityObject.other, so cat left its indices pointing into the whole document's vertices from inside a feature, collect did not offset them, vertex dedup did not renumber them, and validate() did not check them. CityObject.address is now Vec, and CityObject::geometries()/geometries_mut() yield every geometry that indexes vertices, address locations included; slicing, collecting, dedup and validate() all go through them. A location that is not a MultiPoint is a validation error. --- src/city_object.rs | 37 +++++++++++++++++++++++++++++++++++ src/cityjson.rs | 6 +++--- src/validate.rs | 12 +++++++++++- tests/validate.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/city_object.rs b/src/city_object.rs index efb1bbe..50bb4a5 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -162,6 +162,17 @@ impl CityObjectType { pub const WaterBody: CityObjectType = CityObjectType::Known(KnownCityObjectType::WaterBody); } +/// One address of a City Object (§ 2.3). Its members are free-form, except +/// `location`, a MultiPoint into the document's `vertices`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +pub struct CityObjectAddress { + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + /// Every other member, kept verbatim. + #[serde(flatten)] + pub other: Map, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CityObject { #[serde(rename = "type")] @@ -171,6 +182,10 @@ pub struct CityObject { pub geographical_extent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub attributes: Option>, + /// The addresses of a Building, BuildingPart, BuildingUnit, Bridge or + /// BridgePart (§ 2.2, § 2.3). + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub geometry: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -194,6 +209,7 @@ impl CityObject { thetype, geographical_extent: None, attributes: None, + address: None, geometry: None, children: None, children_roles: None, @@ -205,6 +221,27 @@ impl CityObject { pub fn get_type(&self) -> CityObjectType { self.thetype.clone() } + /// Every geometry of the City Object that indexes `vertices`: its + /// `geometry`, then each address's `location`. + pub fn geometries(&self) -> impl Iterator { + let locations = self + .address + .iter() + .flatten() + .filter_map(|a| a.location.as_ref()); + self.geometry.iter().flatten().chain(locations) + } + + /// As [`CityObject::geometries`], mutably. + pub fn geometries_mut(&mut self) -> impl Iterator { + let locations = self + .address + .iter_mut() + .flatten() + .filter_map(|a| a.location.as_mut()); + self.geometry.iter_mut().flatten().chain(locations) + } + pub(crate) fn is_toplevel(&self) -> bool { match &self.parents { Some(x) => x.is_empty(), diff --git a/src/cityjson.rs b/src/cityjson.rs index 3baf34e..d0ba41c 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -247,7 +247,7 @@ impl CityJSON { let mut t_v_oldnew: HashMap = HashMap::new(); for (key, co) in members { let mut co2 = co.clone(); - for g in co2.geometry.iter_mut().flatten() { + for g in co2.geometries_mut() { g.update_geometry_boundaries(&mut g_vi_oldnew); g.update_material(&mut m_oldnew); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); @@ -274,7 +274,7 @@ impl CityJSON { newids.insert(VertexIndex(i), VertexIndex(new)); } for co in self.city_objects.values_mut() { - for g in co.geometry.iter_mut().flatten() { + for g in co.geometries_mut() { g.update_geometry_boundaries(&mut newids); } } @@ -454,7 +454,7 @@ impl Collector { } } for (key, mut co) in cjf.city_objects { - for g in co.geometry.iter_mut().flatten() { + for g in co.geometries_mut() { g.offset_geometry_boundaries(g_offset); g.update_material(&mut m_oldnew); g.update_texture(&mut t_oldnew, &mut t_v_oldnew); diff --git a/src/validate.rs b/src/validate.rs index 763647f..38a1d6a 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -389,9 +389,19 @@ fn city_objects(cos: &IndexMap, l: &Lengths, errs: &mut Vec< for (id, co) in cos { let mut inner = Vec::new(); city_object_type(&co.thetype, &mut inner); - for g in co.geometry.iter().flatten() { + for g in co.geometries() { geometry(g, l, &mut inner); } + for a in co.address.iter().flatten() { + if let Some(g) = &a.location { + if g.geometry_type() != crate::GeometryType::MultiPoint { + inner.push(CjseqError::Validation(format!( + "an address location is a MultiPoint, not a {:?}", + g.geometry_type() + ))); + } + } + } errs.extend(inner.into_iter().map(|e| CjseqError::InCityObject { id: id.clone(), source: Box::new(e), diff --git a/tests/validate.rs b/tests/validate.rs index 15700b6..73e36da 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -552,3 +552,51 @@ fn the_noise_extension_example_is_valid_with_a_warning_per_segment() { assert_eq!(w.len(), 2, "{w:?}"); assert!(w.iter().all(|e| matches!(e, CjseqError::ExtensionChildOfCoreObject { parent, .. } if parent == "a_noisy_bench"))); } + +/// An address's `location` is a MultiPoint into `vertices` (§ 2.3): its +/// indices are checked like any geometry's, and slicing renumbers them. +#[test] +fn an_address_location_is_a_geometry_like_any_other() { + let with_location = |boundaries: Value| { + doc( + json!({"b": {"type": "Building", "address": [{"country": "NL", + "location": {"type": "MultiPoint", "lod": "1", "boundaries": boundaries}}]}}), + json!(V4), + json!({}), + ) + }; + with_location(json!([3])).validate().unwrap(); + let e = inner(first_error(&with_location(json!([4]))), "b"); + assert!( + matches!( + e, + CjseqError::IndexOutOfRange { + kind: IndexKind::Vertex, + index: 4, + len: 4 + } + ), + "{e:?}" + ); + + let cj = with_location(json!([3])); + let f = cj.feature("b").unwrap().unwrap(); + f.validate().unwrap(); + assert_eq!(f.vertices, vec![V4[3]]); + let loc = &f.city_objects["b"].address.as_ref().unwrap()[0]; + assert_eq!(loc.other["country"], json!("NL")); + assert_eq!( + serde_json::to_value(&loc.location).unwrap()["boundaries"], + json!([0]) + ); + + //-- a location is a MultiPoint, nothing else + let bad = doc( + json!({"b": {"type": "Building", "address": [{"location": + {"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}}]}}), + json!(V4), + json!({}), + ); + let e = inner(first_error(&bad), "b"); + assert!(e.to_string().contains("MultiPoint"), "{e}"); +} From 1f76b280db6bf34c2d8053d95540ad22caded2ed Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 19:58:09 +0200 Subject: [PATCH 56/65] fix: keep default themes through cat and collect default-theme-material/-texture belong to the document, but header() only kept an appearance when there were geometry templates, so without templates the defaults rode only inside each feature's sliced appearance, and Collector::add never read them back: cat | collect lost them. The header now carries the default themes whenever the document has them; Collector adopts a feature's when the document has none, and drops one that disagrees with the document's with a DroppedMember warning. --- src/cityjson.rs | 34 ++++++++++++++++++++++++++++++++++ tests/cat_collect.rs | 14 +++++++++++++- tests/seq.rs | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/cityjson.rs b/src/cityjson.rs index d0ba41c..4d478c0 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -152,6 +152,15 @@ impl CityJSON { } //-- the templates now index the sliced appearance cj0.geometry_templates = Some(gts2); + } else if let Some(a) = &self.appearance { + //-- no templates, so no palette to carry, but the default themes + //-- are the document's and travel with the header + if a.default_theme_material.is_some() || a.default_theme_texture.is_some() { + let mut defaults = Appearance::new(); + defaults.default_theme_material = a.default_theme_material.clone(); + defaults.default_theme_texture = a.default_theme_texture.clone(); + cj0.appearance = Some(defaults); + } } Ok(cj0) } @@ -426,6 +435,31 @@ impl Collector { let g_offset = doc.vertices.len(); if let Some(cjf_app) = cjf.appearance.take() { let app = doc.appearance.get_or_insert_with(Appearance::new); + //-- a feature's default themes are the document's: adopt them if + //-- the document has none, and say so if they disagree + for (name, mine, theirs) in [ + ( + "appearance.default-theme-material", + &mut app.default_theme_material, + cjf_app.default_theme_material, + ), + ( + "appearance.default-theme-texture", + &mut app.default_theme_texture, + cjf_app.default_theme_texture, + ), + ] { + match (mine.as_ref(), theirs) { + (None, Some(t)) => *mine = Some(t), + (Some(m), Some(t)) if *m != t => { + self.warnings.push(CjseqError::DroppedMember { + feature: cjf.id.clone(), + member: format!("{name} {t:?}"), + }) + } + _ => {} + } + } for (i, m) in cjf_app.materials.into_iter().flatten().enumerate() { let all = app.materials.get_or_insert_with(Vec::new); let at = *self.materials.entry(m.key()).or_insert_with(|| { diff --git a/tests/cat_collect.rs b/tests/cat_collect.rs index aba85bc..b2bcd17 100644 --- a/tests/cat_collect.rs +++ b/tests/cat_collect.rs @@ -284,11 +284,23 @@ fn everything() -> CityJSON { } #[test] -#[ignore = "address locations and default themes are lost; fixed in the next commits"] fn a_document_using_everything_survives_cat_and_collect() { assert_round_trips(&everything(), "everything()"); } +/// Without geometry templates the header has no appearance of its own to +/// carry, so the default themes must still get through. +#[test] +fn a_document_without_templates_keeps_its_default_themes() { + let mut doc = everything(); + doc.geometry_templates = None; + doc.city_objects.shift_remove("tree"); + let g = doc.city_objects.get_mut("g").unwrap(); + g.children = Some(vec!["b2".into()]); + g.children_roles = Some(vec![Some("a".into())]); + assert_round_trips(&doc, "everything() without templates"); +} + #[test] fn every_document_fixture_survives_cat_and_collect() { for path in ["data/3dbag_b2.city.json", "data/1b_w_texture.city.json"] { diff --git a/tests/seq.rs b/tests/seq.rs index 502630f..77a0e98 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -251,3 +251,42 @@ fn collecting_reports_each_feature_root_member_it_drops() { let doc = c.finish(); assert!(!doc.other.contains_key("+provenance")); } + +/// Default themes belong to the document: the header carries them, a +/// feature's are adopted when the document has none, and one that +/// disagrees with the document's is dropped with a warning. +#[test] +fn default_themes_travel_with_the_header_and_are_reconciled() { + let doc = CityJSON::from_str( + &json!({"type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "CityObjects": {}, "vertices": [], + "appearance": {"default-theme-material": "visual", "materials": [{"name": "m"}]}}) + .to_string(), + ) + .unwrap(); + let header = doc.header().unwrap(); + let app = header.appearance.as_ref().unwrap(); + assert_eq!(app.default_theme_material.as_deref(), Some("visual")); + assert!( + app.materials.is_none(), + "no templates, so no palette in the header" + ); + + let feature = |theme: &str| { + CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": "a", "CityObjects": {"a": {"type": "Building"}}, + "vertices": [], "appearance": {"default-theme-material": theme, "default-theme-texture": "winter"}}) + .to_string(), + ) + .unwrap() + }; + let mut c = Collector::new(header); + c.add(feature("summer")).unwrap(); + assert_eq!(c.warnings().len(), 1, "{:?}", c.warnings()); + assert!(c.warnings()[0].to_string().contains("summer")); + let back = c.finish(); + let app = back.appearance.unwrap(); + assert_eq!(app.default_theme_material.as_deref(), Some("visual")); + assert_eq!(app.default_theme_texture.as_deref(), Some("winter")); +} From 074ce964ff773829be54ddbe0a712771f210c2d9 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:01:08 +0200 Subject: [PATCH 57/65] fix: cat never drops a City Object, and writes group members once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - features() roots a feature at every City Object whose parents are only CityObjectGroups (or none), and does not follow a group's children: a group's members are not its parts (§ 2.5), so each member is a feature of its own and a member of two groups is written once instead of being copied into both groups' features. - A City Object that no feature reaches (its parent is missing, or does not list it as a child) used to vanish from cat's output with exit 0. features() now yields a single NotInAnyFeature error naming all of them, and cat checks it before writing anything. - A feature's validate() accepts its root naming groups that live in other features, and a group naming members that do. - CityObject::is_toplevel is gone; number_of_city_objects counts feature roots. --- src/city_object.rs | 7 ----- src/cityjson.rs | 74 ++++++++++++++++++++++++++++++++++++--------- src/error.rs | 9 ++++++ src/main.rs | 10 ++++-- src/validate.rs | 27 ++++++++++++++--- tests/robustness.rs | 42 +++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 28 deletions(-) diff --git a/src/city_object.rs b/src/city_object.rs index 50bb4a5..9e84594 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -241,13 +241,6 @@ impl CityObject { .filter_map(|a| a.location.as_mut()); self.geometry.iter_mut().flatten().chain(locations) } - - pub(crate) fn is_toplevel(&self) -> bool { - match &self.parents { - Some(x) => x.is_empty(), - None => true, - } - } } #[cfg(test)] diff --git a/src/cityjson.rs b/src/cityjson.rs index 4d478c0..72e18b6 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,5 +1,5 @@ use crate::appearance::{Appearance, MaterialKey, MaterialObject, TextureKey, TextureObject}; -use crate::city_object::CityObject; +use crate::city_object::{CityObject, CityObjectType}; use crate::error::{CjseqError, Result}; use crate::geometry::GeometryTemplates; use crate::index::{Index, MaterialIndex, TextureIndex, UvIndex, VertexIndex}; @@ -182,12 +182,22 @@ impl CityJSON { } } - /// The ids of the top-level City Objects, in `order`. + /// Whether City Object `co` roots a feature: it has no parents, or only + /// CityObjectGroups as parents (a group's members are not its parts). + fn is_root(&self, co: &CityObject) -> bool { + co.parents.iter().flatten().all(|p| { + self.city_objects + .get(p) + .is_some_and(|pco| pco.thetype == CityObjectType::CityObjectGroup) + }) + } + + /// The ids of the City Objects that root a feature, in `order`. fn feature_ids(&self, order: SortingStrategy) -> Vec<&String> { let mut ids: Vec<&String> = self .city_objects .iter() - .filter(|(_, co)| co.is_toplevel()) + .filter(|(_, co)| self.is_root(co)) .map(|(id, _)| id) .collect(); match order { @@ -201,39 +211,71 @@ impl CityJSON { ids } - /// The document's features, one per top-level City Object, in `order`; - /// see [`CityJSON::feature`]. + /// The document's features, one per City Object that roots one, in + /// `order`; see [`CityJSON::feature`]. + /// + /// Every City Object must land in a feature. If one cannot, because its + /// parent is missing or does not list it as a child, the only item is a + /// [`CjseqError::NotInAnyFeature`] naming them all, so nothing is + /// written partially. pub fn features( &self, order: SortingStrategy, - ) -> impl Iterator> + '_ { - self.feature_ids(order) + ) -> Box> + '_> { + let plan: Result>> = self + .feature_ids(order) .into_iter() - .map(move |id| self.slice(id, &self.city_objects[id])) + .map(|id| self.members(id, &self.city_objects[id])) + .collect(); + let plan = match plan { + Ok(p) => p, + Err(e) => return Box::new(std::iter::once(Err(e))), + }; + let covered: HashSet<&str> = plan.iter().flatten().map(|(id, _)| id.as_str()).collect(); + let missing: Vec = self + .city_objects + .keys() + .filter(|id| !covered.contains(id.as_str())) + .cloned() + .collect(); + if !missing.is_empty() { + return Box::new(std::iter::once(Err(CjseqError::NotInAnyFeature { + ids: missing, + }))); + } + Box::new(plan.into_iter().map(move |m| self.slice(m))) } /// The feature rooted at City Object `id`: it with all of its /// descendants, and only the vertices and appearance entries they use, - /// renumbered. `Ok(None)` if there is no such City Object. + /// renumbered. A CityObjectGroup's feature holds just the group. + /// `Ok(None)` if there is no such City Object. /// /// A child id that names no City Object, or an index past the end of /// `vertices` or of an appearance palette, is an error. pub fn feature(&self, id: &str) -> Result> { match self.city_objects.get_key_value(id) { - Some((id, root)) => self.slice(id, root).map(Some), + Some((id, root)) => self.slice(self.members(id, root)?).map(Some), None => Ok(None), } } - fn slice(&self, id: &String, root: &CityObject) -> Result { - //-- the root, then every descendant breadth-first; `seen` stops a - //-- parent/child cycle from looping + /// The root, then every descendant breadth-first. A group's members + /// are not followed; `seen` stops a parent/child cycle from looping. + fn members<'a>( + &'a self, + id: &'a String, + root: &'a CityObject, + ) -> Result> { let mut members: Vec<(&String, &CityObject)> = vec![(id, root)]; let mut seen: HashSet<&str> = HashSet::from([id.as_str()]); let mut next = 0; while next < members.len() { let (parent_id, parent) = members[next]; next += 1; + if parent.thetype == CityObjectType::CityObjectGroup { + continue; + } for child_id in parent.children.iter().flatten() { if !seen.insert(child_id.as_str()) { continue; @@ -247,7 +289,11 @@ impl CityJSON { members.push((child_id, child)); } } + Ok(members) + } + fn slice(&self, members: Vec<(&String, &CityObject)>) -> Result { + let id = members[0].0; let mut cjf = CityJSONFeature::new(); cjf.id = id.clone(); let mut g_vi_oldnew: HashMap = HashMap::new(); @@ -348,7 +394,7 @@ impl CityJSON { pub fn number_of_city_objects(&self) -> usize { self.city_objects .values() - .filter(|co| co.is_toplevel()) + .filter(|co| self.is_root(co)) .count() } } diff --git a/src/error.rs b/src/error.rs index 1542ca7..1d9d62d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -48,6 +48,9 @@ pub enum CjseqError { /// schema allows any, but a CityJSON document has nowhere to keep them /// per feature. Reported by [`crate::Collector::warnings`]. DroppedMember { feature: String, member: String }, + /// City Objects that no feature reaches: their parent is missing, or + /// does not list them as children. + NotInAnyFeature { ids: Vec }, /// A name or version does not match the pattern the schema gives it. Pattern { /// What the value is, eg `"Extension City Object type"`. @@ -126,6 +129,11 @@ impl fmt::Display for CjseqError { f, "dropped the root member {member:?} of CityJSONFeature {feature:?}" ), + CjseqError::NotInAnyFeature { ids } => write!( + f, + "City Objects in no feature (a parent is missing or does not list them as children): {}", + ids.join(", ") + ), CjseqError::Pattern { what, value, @@ -164,6 +172,7 @@ impl std::error::Error for CjseqError { | CjseqError::Pattern { .. } | CjseqError::ExtensionChildOfCoreObject { .. } | CjseqError::DroppedMember { .. } + | CjseqError::NotInAnyFeature { .. } | CjseqError::IndexOutOfRange { .. } => None, } } diff --git a/src/main.rs b/src/main.rs index a180ad1..9ae0358 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,9 +132,15 @@ fn read_all(file: Option<&Path>) -> Result { fn cat(input: &str, order: cjseq::SortingStrategy) -> Result<()> { let cj: CityJSON = input.parse()?; + let header = cj.header()?; + let mut features = cj.features(order).peekable(); + //-- a document that cannot be sliced fails before anything is written + if let Some(Err(_)) = features.peek() { + return Err(features.next().unwrap().unwrap_err()); + } let mut w = SeqWriter::new(BufWriter::new(io::stdout().lock())); - w.write_header(&cj.header()?)?; - for f in cj.features(order) { + w.write_header(&header)?; + for f in features { w.write_feature(&f?)?; } w.into_inner().flush()?; diff --git a/src/validate.rs b/src/validate.rs index 38a1d6a..3c527a1 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -308,13 +308,24 @@ fn geometry(g: &Geometry, l: &Lengths, errs: &mut Vec) { } /// Every child and parent named exists, and names the other back. -fn hierarchy(cos: &IndexMap, errs: &mut Vec) { +/// `outside_root` is set for a feature: it names the one City Object whose +/// parents may lie outside `cos` (the feature's root, whose groups are other +/// features), and a group's members may lie outside it too. +fn hierarchy( + cos: &IndexMap, + outside_root: Option<&str>, + errs: &mut Vec, +) { let names = |list: &Option>, id: &str| { list.as_ref().is_some_and(|l| l.iter().any(|x| x == id)) }; for (id, co) in cos { + //-- in a feature, a group's members are features of their own + let members_elsewhere = + outside_root.is_some() && co.thetype == CityObjectType::CityObjectGroup; for child in co.children.iter().flatten() { match cos.get(child) { + None if members_elsewhere => {} None => errs.push(CjseqError::DanglingReference { from: id.clone(), to: child.clone(), @@ -331,6 +342,7 @@ fn hierarchy(cos: &IndexMap, errs: &mut Vec) { } for parent in co.parents.iter().flatten() { match cos.get(parent) { + None if outside_root == Some(id.as_str()) => {} None => errs.push(CjseqError::DanglingReference { from: id.clone(), to: parent.clone(), @@ -384,8 +396,13 @@ fn extension_children(cos: &IndexMap) -> Vec { .collect() } -fn city_objects(cos: &IndexMap, l: &Lengths, errs: &mut Vec) { - hierarchy(cos, errs); +fn city_objects( + cos: &IndexMap, + outside_root: Option<&str>, + l: &Lengths, + errs: &mut Vec, +) { + hierarchy(cos, outside_root, errs); for (id, co) in cos { let mut inner = Vec::new(); city_object_type(&co.thetype, &mut inner); @@ -452,7 +469,7 @@ impl CityJSON { .as_ref() .map_or(0, |t| t.templates.len()); let l = Lengths::new(self.vertices.len(), Some(templates), app); - city_objects(&self.city_objects, &l, &mut errs); + city_objects(&self.city_objects, None, &l, &mut errs); errs } @@ -487,7 +504,7 @@ impl CityJSONFeature { }); } let l = Lengths::new(self.vertices.len(), None, self.appearance.as_ref()); - city_objects(&self.city_objects, &l, &mut errs); + city_objects(&self.city_objects, Some(&self.id), &l, &mut errs); errs } diff --git a/tests/robustness.rs b/tests/robustness.rs index d199b9e..b70f711 100644 --- a/tests/robustness.rs +++ b/tests/robustness.rs @@ -203,3 +203,45 @@ fn the_centroid_of_no_vertices_is_none() { .unwrap(); assert_eq!(f.centroid(), Some([1.0, 2.0, 3.0])); } + +/// Every City Object lands in exactly one feature: one that no feature can +/// reach (its parent is missing, or does not list it as a child) is an +/// error, not silently left out. +#[test] +fn a_city_object_in_no_feature_is_an_error_not_dropped() { + let cj = doc( + json!({ + "a": {"type": "Building"}, + "orphan": {"type": "BuildingPart", "parents": ["nope"]}, + "c": {"type": "BuildingPart", "parents": ["a"]} + }), + json!([]), + ); + let results: Vec<_> = cj.features(SortingStrategy::AsIs).collect(); + assert_eq!(results.len(), 1, "one error, and nothing partial"); + match &results[0] { + Err(CjseqError::NotInAnyFeature { ids }) => assert_eq!(ids, &["orphan", "c"]), + other => panic!("expected NotInAnyFeature, got {other:?}"), + } +} + +/// A CityObjectGroup's members are not its parts (§ 2.5): each member is a +/// feature of its own, and the group's feature holds just the group, so a +/// member of two groups is written once. +#[test] +fn group_members_are_features_of_their_own() { + let cj = doc( + json!({ + "g1": {"type": "CityObjectGroup", "children": ["b"]}, + "g2": {"type": "CityObjectGroup", "children": ["b", "c"]}, + "b": {"type": "Building", "parents": ["g1", "g2"], "children": ["bp"]}, + "bp": {"type": "BuildingPart", "parents": ["b"]}, + "c": {"type": "Building", "parents": ["g2"]} + }), + json!([]), + ); + cj.validate().unwrap(); + let fs = features(&mut cj.clone(), SortingStrategy::AsIs); + let ids: Vec> = fs.iter().map(|f| f.city_objects.keys().collect()).collect(); + assert_eq!(ids, [vec!["g1"], vec!["g2"], vec!["b", "bp"], vec!["c"]]); +} From b4fe5850dfdbe95d05d41af972c826ab19dca9dc Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:02:59 +0200 Subject: [PATCH 58/65] fix: collecting several sequences merges their headers Collector::add_header kept only a later header's transform. Its geometry templates were dropped, so its features' GeometryInstances pointed at the first sequence's templates or past the end, and its extensions, metadata and root members were dropped too. Now a later header's templates are appended to the document's, with their vertices-templates offset and their appearance merged into the document's palettes, and every GeometryInstance in the features that follow is shifted to match. Its extensions are added; one with the same name but a different URL or version, or a different CityJSON version, is a HeaderMismatch error. Metadata or root members that differ from the document's are dropped with a DroppedHeaderMember warning. Appearance merging for headers and features is one function. --- src/cityjson.rs | 216 ++++++++++++++++++++++++++++++++++-------------- src/error.rs | 17 ++++ src/geometry.rs | 8 ++ tests/seq.rs | 132 +++++++++++++++++++++++++++++ 4 files changed, 311 insertions(+), 62 deletions(-) diff --git a/src/cityjson.rs b/src/cityjson.rs index 72e18b6..70b58a3 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -419,6 +419,9 @@ pub struct Collector { /// The transform the features being added are expressed in, when it is /// not the document's. correction: Option, + /// Where the current sequence's geometry templates start in the + /// document's. + template_offset: usize, /// Where each distinct material and texture already is in the /// document's palettes. materials: HashMap, @@ -426,6 +429,14 @@ pub struct Collector { warnings: Vec, } +/// How one source's appearance indices map onto the document's palettes. +#[derive(Default)] +struct PaletteMaps { + materials: HashMap, + textures: HashMap, + uvs: HashMap, +} + impl Collector { pub fn new(header: CityJSON) -> Self { let app = header.appearance.as_ref(); @@ -444,15 +455,144 @@ impl Collector { Collector { doc: header, correction: None, + template_offset: 0, materials, textures, warnings: Vec::new(), } } - /// The header of a further sequence: the features added after it are in - /// its transform. - pub fn add_header(&mut self, header: CityJSON) -> Result<()> { + /// Merge `app` into the document's appearance: identical materials and + /// textures become one entry, texture vertices are appended, and the + /// default themes are adopted when the document has none. A default + /// theme that disagrees with the document's is dropped, with a warning + /// made by `dropped`. + fn merge_appearance( + &mut self, + app: Appearance, + dropped: &dyn Fn(String) -> CjseqError, + ) -> PaletteMaps { + let mut maps = PaletteMaps::default(); + let doc_app = self.doc.appearance.get_or_insert_with(Appearance::new); + for (name, mine, theirs) in [ + ( + "appearance.default-theme-material", + &mut doc_app.default_theme_material, + app.default_theme_material, + ), + ( + "appearance.default-theme-texture", + &mut doc_app.default_theme_texture, + app.default_theme_texture, + ), + ] { + match (mine.as_ref(), theirs) { + (None, Some(t)) => *mine = Some(t), + (Some(m), Some(t)) if *m != t => { + self.warnings.push(dropped(format!("{name} {t:?}"))) + } + _ => {} + } + } + for (i, m) in app.materials.into_iter().flatten().enumerate() { + let all = doc_app.materials.get_or_insert_with(Vec::new); + let at = *self.materials.entry(m.key()).or_insert_with(|| { + all.push(m); + all.len() - 1 + }); + maps.materials.insert(MaterialIndex(i), MaterialIndex(at)); + } + for (i, t) in app.textures.into_iter().flatten().enumerate() { + let all = doc_app.textures.get_or_insert_with(Vec::new); + let at = *self.textures.entry(t.key()).or_insert_with(|| { + all.push(t); + all.len() - 1 + }); + maps.textures.insert(TextureIndex(i), TextureIndex(at)); + } + if let Some(uvs) = app.vertices_texture { + //-- appended verbatim, so each new index is the old one shifted by + //-- however many were already collected + let offset = doc_app.vertices_texture.as_ref().map_or(0, Vec::len); + for i in 0..uvs.len() { + maps.uvs.insert(UvIndex(i), UvIndex(offset + i)); + } + doc_app.add_vertices_texture(uvs); + } + maps + } + + /// The header of a further sequence, whose features follow. Its + /// transform applies to them, so their vertices are re-expressed in the + /// document's; its geometry templates are appended to the document's + /// (with their appearance merged into the document's palettes), so its + /// features' GeometryInstances keep pointing at the right ones; its + /// extensions are added. + /// + /// A different CityJSON version, or an extension of the same name with + /// a different URL or version, is an error. Metadata or other root + /// members that differ from the document's are dropped, with a warning. + pub fn add_header(&mut self, mut header: CityJSON) -> Result<()> { + if header.version != self.doc.version { + return Err(CjseqError::HeaderMismatch { + member: format!("version {} vs {}", header.version, self.doc.version), + }); + } + for (name, ext) in header.extensions.take().into_iter().flatten() { + let mine = self.doc.extensions.get_or_insert_with(IndexMap::new); + match mine.get(&name) { + Some(e) if *e != ext => { + return Err(CjseqError::HeaderMismatch { + member: format!("extensions.{name}"), + }) + } + Some(_) => {} + None => { + mine.insert(name, ext); + } + } + } + let header_dropped = |member: String| CjseqError::DroppedHeaderMember { member }; + if header.metadata.is_some() && header.metadata != self.doc.metadata { + self.warnings.push(header_dropped("metadata".to_string())); + } + for (k, v) in std::mem::take(&mut header.other) { + match self.doc.other.get(&k) { + None => { + self.doc.other.insert(k, v); + } + Some(mine) if *mine != v => self.warnings.push(header_dropped(k)), + Some(_) => {} + } + } + let maps = match header.appearance.take() { + Some(app) => self.merge_appearance(app, &header_dropped), + None => PaletteMaps::default(), + }; + let doc_templates = self + .doc + .geometry_templates + .get_or_insert_with(|| GeometryTemplates { + templates: Vec::new(), + vertices_templates: Vec::new(), + }); + self.template_offset = doc_templates.templates.len(); + if let Some(gt) = header.geometry_templates.take() { + let v_offset = doc_templates.vertices_templates.len(); + let mut maps = maps; + for mut g in gt.templates { + g.offset_geometry_boundaries(v_offset); + g.update_material(&mut maps.materials); + g.update_texture(&mut maps.textures, &mut maps.uvs); + doc_templates.templates.push(g); + } + doc_templates + .vertices_templates + .extend(gt.vertices_templates); + } + if doc_templates.templates.is_empty() { + self.doc.geometry_templates = None; + } self.correction = (header.transform != self.doc.transform).then_some(header.transform); Ok(()) } @@ -474,70 +614,22 @@ impl Collector { member: member.clone(), }); } + let id = cjf.id.clone(); + let mut maps = match cjf.appearance.take() { + Some(app) => self.merge_appearance(app, &|member| CjseqError::DroppedMember { + feature: id.clone(), + member, + }), + None => PaletteMaps::default(), + }; let doc = &mut self.doc; - let mut m_oldnew: HashMap = HashMap::new(); - let mut t_oldnew: HashMap = HashMap::new(); - let mut t_v_oldnew: HashMap = HashMap::new(); let g_offset = doc.vertices.len(); - if let Some(cjf_app) = cjf.appearance.take() { - let app = doc.appearance.get_or_insert_with(Appearance::new); - //-- a feature's default themes are the document's: adopt them if - //-- the document has none, and say so if they disagree - for (name, mine, theirs) in [ - ( - "appearance.default-theme-material", - &mut app.default_theme_material, - cjf_app.default_theme_material, - ), - ( - "appearance.default-theme-texture", - &mut app.default_theme_texture, - cjf_app.default_theme_texture, - ), - ] { - match (mine.as_ref(), theirs) { - (None, Some(t)) => *mine = Some(t), - (Some(m), Some(t)) if *m != t => { - self.warnings.push(CjseqError::DroppedMember { - feature: cjf.id.clone(), - member: format!("{name} {t:?}"), - }) - } - _ => {} - } - } - for (i, m) in cjf_app.materials.into_iter().flatten().enumerate() { - let all = app.materials.get_or_insert_with(Vec::new); - let at = *self.materials.entry(m.key()).or_insert_with(|| { - all.push(m); - all.len() - 1 - }); - m_oldnew.insert(MaterialIndex(i), MaterialIndex(at)); - } - for (i, t) in cjf_app.textures.into_iter().flatten().enumerate() { - let all = app.textures.get_or_insert_with(Vec::new); - let at = *self.textures.entry(t.key()).or_insert_with(|| { - all.push(t); - all.len() - 1 - }); - t_oldnew.insert(TextureIndex(i), TextureIndex(at)); - } - if let Some(uvs) = cjf_app.vertices_texture { - //-- the feature's texture vertices are appended verbatim, so - //-- each one's new index is its old one shifted by however - //-- many were already collected - let t_v_offset = app.vertices_texture.as_ref().map_or(0, Vec::len); - for i in 0..uvs.len() { - t_v_oldnew.insert(UvIndex(i), UvIndex(t_v_offset + i)); - } - app.add_vertices_texture(uvs); - } - } for (key, mut co) in cjf.city_objects { for g in co.geometries_mut() { g.offset_geometry_boundaries(g_offset); - g.update_material(&mut m_oldnew); - g.update_texture(&mut t_oldnew, &mut t_v_oldnew); + g.offset_template(self.template_offset); + g.update_material(&mut maps.materials); + g.update_texture(&mut maps.textures, &mut maps.uvs); } doc.city_objects.insert(key, co); } diff --git a/src/error.rs b/src/error.rs index 1d9d62d..465ca93 100644 --- a/src/error.rs +++ b/src/error.rs @@ -51,6 +51,13 @@ pub enum CjseqError { /// City Objects that no feature reaches: their parent is missing, or /// does not list them as children. NotInAnyFeature { ids: Vec }, + /// Two CityJSONSeq headers being collected into one document disagree + /// on something that cannot be merged. + HeaderMismatch { member: String }, + /// Collecting dropped a member of a later sequence's header that + /// differed from the document's. Reported by + /// [`crate::Collector::warnings`]. + DroppedHeaderMember { member: String }, /// A name or version does not match the pattern the schema gives it. Pattern { /// What the value is, eg `"Extension City Object type"`. @@ -134,6 +141,14 @@ impl fmt::Display for CjseqError { "City Objects in no feature (a parent is missing or does not list them as children): {}", ids.join(", ") ), + CjseqError::HeaderMismatch { member } => write!( + f, + "the sequences being collected disagree on {member}" + ), + CjseqError::DroppedHeaderMember { member } => write!( + f, + "dropped {member:?} of a later sequence's header, which differs from the first" + ), CjseqError::Pattern { what, value, @@ -173,6 +188,8 @@ impl std::error::Error for CjseqError { | CjseqError::ExtensionChildOfCoreObject { .. } | CjseqError::DroppedMember { .. } | CjseqError::NotInAnyFeature { .. } + | CjseqError::HeaderMismatch { .. } + | CjseqError::DroppedHeaderMember { .. } | CjseqError::IndexOutOfRange { .. } => None, } } diff --git a/src/geometry.rs b/src/geometry.rs index 4d2e27b..21b53c1 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -356,6 +356,14 @@ impl Geometry { self.for_each_vertex(&mut |vi| *vi = remap(violdnew, *vi)); } + /// Shift a GeometryInstance's template index; other geometries have + /// none. + pub(crate) fn offset_template(&mut self, offset: usize) { + if let Geometry::GeometryInstance { template, .. } = self { + template.0 += offset; + } + } + pub(crate) fn offset_geometry_boundaries(&mut self, offset: usize) { self.for_each_vertex(&mut |vi| vi.0 += offset); } diff --git a/tests/seq.rs b/tests/seq.rs index 77a0e98..b11a827 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -290,3 +290,135 @@ fn default_themes_travel_with_the_header_and_are_reconciled() { assert_eq!(app.default_theme_material.as_deref(), Some("visual")); assert_eq!(app.default_theme_texture.as_deref(), Some("winter")); } + +fn seq_header( + templates: serde_json::Value, + materials: serde_json::Value, + ext: serde_json::Value, + title: &str, +) -> CityJSON { + CityJSON::from_str( + &json!({"type": "CityJSON", "version": "2.0", + "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]}, + "metadata": {"title": title}, + "extensions": ext, + "CityObjects": {}, "vertices": [], + "appearance": {"materials": materials}, + "geometry-templates": templates}) + .to_string(), + ) + .unwrap() +} + +fn instance(id: &str, template: usize) -> CityJSONFeature { + CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": id, + "CityObjects": {id: {"type": "SolitaryVegetationObject", "geometry": [{ + "type": "GeometryInstance", "template": template, "boundaries": [0], + "transformationMatrix": [1.0,0.0,0.0,0.0, 0.0,1.0,0.0,0.0, 0.0,0.0,1.0,0.0, 0.0,0.0,0.0,1.0]}]}}, + "vertices": [[0, 0, 0]]}) + .to_string(), + ) + .unwrap() +} + +/// Each sequence's header carries its own geometry templates, their +/// appearance and its extensions; collecting several sequences merges them +/// so every GeometryInstance still means what it meant in its sequence. +#[test] +fn collecting_several_sequences_merges_their_headers() { + let template = |v: [usize; 3], m: usize| json!({"type": "MultiSurface", "lod": "1", "boundaries": [[v]], "material": {"visual": {"value": m}}}); + let noise = json!({"Noise": {"url": "https://x.org/noise.json", "version": "1.0"}}); + let h1 = seq_header( + json!({"templates": [template([0, 1, 2], 0)], "vertices-templates": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]}), + json!([{"name": "red"}]), + noise.clone(), + "one", + ); + let h2 = seq_header( + json!({"templates": [template([0, 1, 2], 0), template([2, 1, 0], 1)], + "vertices-templates": [[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [5.0, 6.0, 0.0]]}), + json!([{"name": "green"}, {"name": "red"}]), + json!({"Solar": {"url": "https://x.org/solar.json", "version": "0.8"}}), + "two", + ); + let mut c = Collector::new(h1); + c.add(instance("a", 0)).unwrap(); + c.add_header(h2).unwrap(); + c.add(instance("b", 0)).unwrap(); + c.add(instance("c", 1)).unwrap(); + assert_eq!( + c.warnings().len(), + 1, + "metadata differs: {:?}", + c.warnings() + ); + let doc = c.finish(); + doc.validate().unwrap(); + + let v = serde_json::to_value(&doc).unwrap(); + let gt = &v["geometry-templates"]; + let resolve = |id: &str| { + let t = v["CityObjects"][id]["geometry"][0]["template"] + .as_u64() + .unwrap() as usize; + let tpl = >["templates"][t]; + let pts: Vec<_> = tpl["boundaries"][0][0] + .as_array() + .unwrap() + .iter() + .map(|i| gt["vertices-templates"][i.as_u64().unwrap() as usize].clone()) + .collect(); + let m = tpl["material"]["visual"]["value"].as_u64().unwrap() as usize; + (pts, v["appearance"]["materials"][m]["name"].clone()) + }; + assert_eq!( + resolve("a"), + ( + vec![ + json!([0.0, 0.0, 0.0]), + json!([1.0, 0.0, 0.0]), + json!([0.0, 1.0, 0.0]) + ], + json!("red") + ) + ); + assert_eq!( + resolve("b"), + ( + vec![ + json!([5.0, 0.0, 0.0]), + json!([6.0, 0.0, 0.0]), + json!([5.0, 6.0, 0.0]) + ], + json!("green") + ) + ); + assert_eq!( + resolve("c"), + ( + vec![ + json!([5.0, 6.0, 0.0]), + json!([6.0, 0.0, 0.0]), + json!([5.0, 0.0, 0.0]) + ], + json!("red") + ) + ); + let ext = doc.extensions.as_ref().unwrap(); + assert!(ext.contains_key("Noise") && ext.contains_key("Solar")); + assert_eq!(doc.metadata.unwrap().title.as_deref(), Some("one")); +} + +#[test] +fn collecting_sequences_with_conflicting_headers_is_an_error() { + let ext = |v: &str| json!({"Noise": {"url": "https://x.org/noise.json", "version": v}}); + let empty = json!({"templates": [], "vertices-templates": []}); + let mut c = Collector::new(seq_header(empty.clone(), json!([]), ext("1.0"), "x")); + assert!(c + .add_header(seq_header(empty.clone(), json!([]), ext("2.0"), "x")) + .is_err()); + let mut h = seq_header(empty, json!([]), ext("1.0"), "x"); + h.version = cjseq::CityJSONVersion::V1_1; + assert!(c.add_header(h).is_err()); +} From 339b9380c8dffa291f688137c6036ab1bbc54fb8 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:04:15 +0200 Subject: [PATCH 59/65] fix: collect rejects indices past a feature's own data Collector::add renumbers a feature's material, texture and texture-vertex indices through maps built from the feature's palettes, and shifts its vertex and template indices. An index past the end of the feature's own data was not in a map, so it was handed the next free position, and a shifted vertex or template index landed in another feature's data: an invalid feature came out of collect looking valid. add() now checks every vertex, material, texture, texture-vertex and template index against the feature's own vertices and palettes and the current sequence's templates before merging, and rejects the feature with IndexOutOfRange inside InCityObject. collect time on the 65 MB sequence is unchanged (0.41-0.43 s), output identical. --- src/cityjson.rs | 51 ++++++++++++++++++++++++++++++++-- tests/seq.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/cityjson.rs b/src/cityjson.rs index 70b58a3..91e0d9b 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,7 +1,7 @@ use crate::appearance::{Appearance, MaterialKey, MaterialObject, TextureKey, TextureObject}; use crate::city_object::{CityObject, CityObjectType}; -use crate::error::{CjseqError, Result}; -use crate::geometry::GeometryTemplates; +use crate::error::{CjseqError, IndexKind, Result}; +use crate::geometry::{Geometry, GeometryTemplates}; use crate::index::{Index, MaterialIndex, TextureIndex, UvIndex, VertexIndex}; use crate::metadata::{Metadata, Transform}; use indexmap::IndexMap; @@ -597,6 +597,52 @@ impl Collector { Ok(()) } + /// Every index in `cjf` must point into its own vertices and palettes, + /// or into the current sequence's templates: once merged, an index past + /// the end would silently point at another feature's data. + fn check_indices(&self, cjf: &mut CityJSONFeature) -> Result<()> { + let len = |v: Option| v.unwrap_or(0); + let app = cjf.appearance.as_ref(); + let materials = len(app.and_then(|a| a.materials.as_ref()).map(Vec::len)); + let textures = len(app.and_then(|a| a.textures.as_ref()).map(Vec::len)); + let uvs = len(app.and_then(|a| a.vertices_texture.as_ref()).map(Vec::len)); + let templates = self + .doc + .geometry_templates + .as_ref() + .map_or(0, |t| t.templates.len()) + - self.template_offset; + let vertices = cjf.vertices.len(); + for (id, co) in &mut cjf.city_objects { + let mut err: Option = None; + let mut check = |kind: IndexKind, index: usize, len: usize| { + if index >= len && err.is_none() { + err = Some(CjseqError::IndexOutOfRange { kind, index, len }); + } + }; + for g in co.geometries_mut() { + g.visit_vertices(&mut |v| check(IndexKind::Vertex, v.0, vertices)); + g.for_each_material(&mut |m| check(IndexKind::Material, m.0, materials)); + g.for_each_texture(&mut |t: &mut TextureIndex| { + check(IndexKind::Texture, t.0, textures) + }); + g.for_each_texture(&mut |uv: &mut UvIndex| { + check(IndexKind::TextureVertex, uv.0, uvs) + }); + if let Geometry::GeometryInstance { template, .. } = g { + check(IndexKind::Template, template.0, templates); + } + } + if let Some(e) = err { + return Err(CjseqError::InCityObject { + id: id.clone(), + source: Box::new(e), + }); + } + } + Ok(()) + } + /// Add one feature: its City Objects, its vertices (re-expressed if a /// later header changed the transform) and its appearance, merged into /// the document's palettes. Root members the feature schema allows but @@ -608,6 +654,7 @@ impl Collector { to: cjf.id, }); } + self.check_indices(&mut cjf)?; for member in cjf.other.keys() { self.warnings.push(CjseqError::DroppedMember { feature: cjf.id.clone(), diff --git a/tests/seq.rs b/tests/seq.rs index b11a827..9f73602 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -422,3 +422,77 @@ fn collecting_sequences_with_conflicting_headers_is_an_error() { h.version = cjseq::CityJSONVersion::V1_1; assert!(c.add_header(h).is_err()); } + +/// An index past the end of a feature's own vertices or palettes must not +/// come out of collect as a valid-looking index into somebody else's. +#[test] +fn collecting_rejects_a_feature_whose_indices_point_past_its_own_data() { + let header = || { + seq_header( + json!({"templates": [{"type": "MultiPoint", "lod": "1", "boundaries": [0]}], "vertices-templates": [[0.0, 0.0, 0.0]]}), + json!([]), + json!({}), + "x", + ) + }; + let feature = |geometry: serde_json::Value| { + CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": "a", + "CityObjects": {"a": {"type": "Building", "geometry": [geometry]}}, + "vertices": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + "appearance": {"materials": [{"name": "m"}], "textures": [{"image": "a.png"}], + "vertices-texture": [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]}}) + .to_string(), + ) + .unwrap() + }; + let ms = |extra: serde_json::Value| { + let mut g = json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}); + g.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + g + }; + let tm = + json!([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]); + for (what, g, kind) in [ + ( + "vertex", + json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 9]]]}), + cjseq::IndexKind::Vertex, + ), + ( + "material", + ms(json!({"material": {"m": {"value": 5}}})), + cjseq::IndexKind::Material, + ), + ( + "texture", + ms(json!({"texture": {"t": {"values": [[[4, 0, 1, 2]]]}}})), + cjseq::IndexKind::Texture, + ), + ( + "uv", + ms(json!({"texture": {"t": {"values": [[[0, 0, 1, 7]]]}}})), + cjseq::IndexKind::TextureVertex, + ), + ( + "template", + json!({"type": "GeometryInstance", "template": 3, "boundaries": [0], "transformationMatrix": tm}), + cjseq::IndexKind::Template, + ), + ] { + let mut c = Collector::new(header()); + let e = c.add(feature(g)).expect_err(what); + match e { + CjseqError::InCityObject { id, source } => { + assert_eq!(id, "a"); + assert!( + matches!(*source, CjseqError::IndexOutOfRange { kind: k, .. } if k == kind), + "{what}: {source:?}" + ); + } + other => panic!("{what}: {other:?}"), + } + } +} From 472ede653ebf25159bcd3b7436a0dd224f9d8b8e Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:04:59 +0200 Subject: [PATCH 60/65] fix: collect rejects a City Object id already collected Collector::add inserted every City Object of a feature by id, so a second feature carrying an id already collected silently replaced the first one's object. Ids are unique in a document; such a feature is now a DuplicateId error, raised before anything of it is added. --- src/cityjson.rs | 10 ++++++++++ src/error.rs | 7 +++++++ tests/seq.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/cityjson.rs b/src/cityjson.rs index 91e0d9b..75edd92 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -647,6 +647,9 @@ impl Collector { /// later header changed the transform) and its appearance, merged into /// the document's palettes. Root members the feature schema allows but /// does not name are dropped, each with a [`Collector::warnings`] entry. + /// + /// A City Object id already collected from an earlier feature is a + /// [`CjseqError::DuplicateId`], and nothing of the feature is added. pub fn add(&mut self, mut cjf: CityJSONFeature) -> Result<()> { if !cjf.city_objects.contains_key(&cjf.id) { return Err(CjseqError::DanglingReference { @@ -654,6 +657,13 @@ impl Collector { to: cjf.id, }); } + if let Some(id) = cjf + .city_objects + .keys() + .find(|id| self.doc.city_objects.contains_key(*id)) + { + return Err(CjseqError::DuplicateId { id: id.clone() }); + } self.check_indices(&mut cjf)?; for member in cjf.other.keys() { self.warnings.push(CjseqError::DroppedMember { diff --git a/src/error.rs b/src/error.rs index 465ca93..932280d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -58,6 +58,9 @@ pub enum CjseqError { /// differed from the document's. Reported by /// [`crate::Collector::warnings`]. DroppedHeaderMember { member: String }, + /// A City Object id that is already in the document, met again while + /// collecting. + DuplicateId { id: String }, /// A name or version does not match the pattern the schema gives it. Pattern { /// What the value is, eg `"Extension City Object type"`. @@ -149,6 +152,9 @@ impl fmt::Display for CjseqError { f, "dropped {member:?} of a later sequence's header, which differs from the first" ), + CjseqError::DuplicateId { id } => { + write!(f, "City Object {id:?} is in more than one feature") + } CjseqError::Pattern { what, value, @@ -189,6 +195,7 @@ impl std::error::Error for CjseqError { | CjseqError::DroppedMember { .. } | CjseqError::NotInAnyFeature { .. } | CjseqError::HeaderMismatch { .. } + | CjseqError::DuplicateId { .. } | CjseqError::DroppedHeaderMember { .. } | CjseqError::IndexOutOfRange { .. } => None, } diff --git a/tests/seq.rs b/tests/seq.rs index 9f73602..a761b44 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -496,3 +496,31 @@ fn collecting_rejects_a_feature_whose_indices_point_past_its_own_data() { } } } + +/// City Object ids are unique in a document, so two features carrying the +/// same id cannot both be collected: the second is an error, not a silent +/// overwrite of the first. +#[test] +fn collecting_rejects_a_city_object_id_seen_in_an_earlier_feature() { + let (header, _) = read("tests/data/small.city.jsonl"); + let f = |id: &str, part: &str| { + CityJSONFeature::from_str( + &json!({"type": "CityJSONFeature", "id": id, + "CityObjects": {id: {"type": "Building", "children": [part]}, + part: {"type": "BuildingPart", "parents": [id]}}, + "vertices": []}) + .to_string(), + ) + .unwrap() + }; + let mut c = Collector::new(header); + c.add(f("a", "p")).unwrap(); + match c.add(f("b", "p")) { + Err(CjseqError::DuplicateId { id }) => assert_eq!(id, "p"), + other => panic!("expected DuplicateId, got {other:?}"), + } + //-- the rejected feature left nothing behind + let doc = c.finish(); + assert!(!doc.city_objects.contains_key("b")); + assert_eq!(doc.city_objects["p"].parents, Some(vec!["a".to_string()])); +} From 80802a52a5d53f660d43c380433f57294437781a Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:06:18 +0200 Subject: [PATCH 61/65] feat!: #[non_exhaustive] on the public enums CjseqError, IndexKind, Geometry, GeometryType, KnownCityObjectType, KnownSemanticSurfaceType, CityJSONVersion, SortingStrategy, ContactType, ContactRole, TextureFormat, WrapMode and TextureType are #[non_exhaustive], so a new error variant, CityJSON version, geometry or codelist value is not a breaking change; a compile_fail doctest pins it. Line stays exhaustive: a sequence line is a header or a feature by definition, and every reader matches on it. --- src/appearance.rs | 3 +++ src/city_object.rs | 1 + src/cityjson.rs | 2 ++ src/error.rs | 2 ++ src/geometry.rs | 2 ++ src/lib.rs | 15 +++++++++++++++ src/metadata.rs | 2 ++ src/semantics.rs | 1 + 8 files changed, 28 insertions(+) diff --git a/src/appearance.rs b/src/appearance.rs index def9f77..56522d6 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -165,6 +165,7 @@ pub enum BorderColor { /// A texture image's format, `"PNG"` or `"JPG"` (upper case, unlike /// `wrapMode` and `textureType`). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum TextureFormat { PNG, JPG, @@ -173,6 +174,7 @@ pub enum TextureFormat { /// How a texture repeats outside `[0, 1]`. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum WrapMode { None, Wrap, @@ -184,6 +186,7 @@ pub enum WrapMode { /// What a texture depicts. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum TextureType { Unknown, Specific, diff --git a/src/city_object.rs b/src/city_object.rs index 9e84594..4f71749 100644 --- a/src/city_object.rs +++ b/src/city_object.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value}; /// The 1st- and 2nd-level City Object types the spec defines (§ 2), each /// serialized as its name. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum KnownCityObjectType { Bridge, BridgePart, diff --git a/src/cityjson.rs b/src/cityjson.rs index 75edd92..5f1e498 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -61,6 +61,7 @@ fn slice_appearance( /// The order in which [`CityJSON::features`] hands out features. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum SortingStrategy { /// The order the top-level City Objects appear in the document. AsIs, @@ -794,6 +795,7 @@ pub fn cjseq_to_cj(header: CityJSON, features: Vec) -> Result; /// The tag of a [`Geometry`], for callers that need the kind without matching /// on the whole enum. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum GeometryType { MultiPoint, MultiLineString, @@ -143,6 +144,7 @@ fn nesting_depth(v: &Value) -> usize { /// MultiPoint or MultiLineString carries no material or texture. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(tag = "type", deny_unknown_fields)] +#[non_exhaustive] pub enum Geometry { MultiPoint { lod: Lod, diff --git a/src/lib.rs b/src/lib.rs index 45c573c..49ecca2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,21 @@ pub mod wasm; #[cfg(all(target_arch = "wasm32", feature = "wasm"))] pub use wasm::*; +/// The public enums are `#[non_exhaustive]`, so new CityJSON versions, +/// geometry types or error variants are not breaking changes: a match +/// outside the crate needs a `_` arm. +/// +/// ```compile_fail +/// fn name(v: cjseq::CityJSONVersion) -> &'static str { +/// match v { +/// cjseq::CityJSONVersion::V1_1 => "1.1", +/// cjseq::CityJSONVersion::V2_0 => "2.0", +/// } +/// } +/// ``` +#[cfg(doctest)] +pub struct NonExhaustiveDoctests; + /// The README's examples, compiled and run as doctests. #[cfg(doctest)] #[doc = include_str!("../README.md")] diff --git a/src/metadata.rs b/src/metadata.rs index be1e5d6..fbe898d 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -74,6 +74,7 @@ pub struct PointOfContact { /// `pointOfContact.contactType` (§ 5.3). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum ContactType { Individual, Organization, @@ -82,6 +83,7 @@ pub enum ContactType { /// `pointOfContact.role`, from the ISO 19115 role codelist (§ 5.3). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum ContactRole { ResourceProvider, Custodian, diff --git a/src/semantics.rs b/src/semantics.rs index efaa173..e88764f 100644 --- a/src/semantics.rs +++ b/src/semantics.rs @@ -7,6 +7,7 @@ use serde_json::{Map, Value}; /// The semantic surface types the spec defines (§ 3.3), each serialized as /// its name. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum KnownSemanticSurfaceType { RoofSurface, GroundSurface, From 4fb324360bf253c5c2696d87b4fb376eb773236b Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:08:29 +0200 Subject: [PATCH 62/65] build!: the library no longer depends on clap, rand or getrandom - clap, rand and (on wasm32) getrandom are optional, behind a default `cli` feature that the cjseq binary and the CLI tests require. A library user adds cjseq with default-features = false and gets only serde, serde_json and indexmap; the wasm build no longer compiles clap or needs getrandom's js backend. - SortingStrategy::Random { seed } shuffles with a built-in SplitMix64 Fisher-Yates, so an order is reproducible from its seed; cjseq cat --order random takes --seed, and draws one when it is absent. - CI also lints and tests the library without default features and builds wasm without the CLI; the npm workflow and README build wasm the same way. --- .github/workflows/ci.yml | 6 +++++- .github/workflows/publish-npm.yml | 2 +- Cargo.toml | 21 +++++++++++++++----- README.md | 8 +++++--- src/cityjson.rs | 26 ++++++++++++++++++------ src/main.rs | 9 +++++++-- tests/seq.rs | 33 ++++++++++++++++++++++++++++++- 7 files changed, 86 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1628be..6ed988e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,9 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: Run tests run: cargo test + - name: Library alone (no CLI dependencies) + run: | + cargo clippy --no-default-features --all-targets -- -D warnings + cargo test --no-default-features - name: Build wasm - run: cargo build --target wasm32-unknown-unknown --features wasm + run: cargo build --target wasm32-unknown-unknown --no-default-features --features wasm diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index b0ae177..61978a4 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -27,7 +27,7 @@ jobs: - name: Build WASM run: | - wasm-pack build --target web --scope cityjson -- --features wasm + wasm-pack build --target web --scope cityjson -- --no-default-features --features wasm - name: Publish to NPM env: diff --git a/Cargo.toml b/Cargo.toml index d5bd53a..fb86913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,15 +12,19 @@ repository = "https://github.com/cityjson/cjseq" [lib] crate-type = ["cdylib", "rlib"] -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[bin]] +name = "cjseq" +path = "src/main.rs" +required-features = ["cli"] [dependencies] -clap = { version = "4.5.4", features = ["derive"] } indexmap = { version = "2", features = ["serde"] } -rand = "0.8.5" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip", "preserve_order"] } -# serde_derive = "1.0" + +# CLI dependencies +clap = { version = "4.5.4", features = ["derive"], optional = true } +rand = { version = "0.8.5", optional = true } # WASM dependencies wasm-bindgen = { version = "0.2", features = ["serde-serialize"], optional = true } @@ -34,7 +38,14 @@ optional = true # WASM-specific dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = { version = "0.1", optional = true } -getrandom = { version = "0.2", features = ["js"] } +getrandom = { version = "0.2", features = ["js"], optional = true } [features] +default = ["cli"] +#-- the `cjseq` binary; the library itself needs none of these +cli = ["dep:clap", "dep:rand", "dep:getrandom"] wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:web-sys", "dep:console_error_panic_hook"] + +[[test]] +name = "cli" +required-features = ["cli"] diff --git a/README.md b/README.md index 9c7ba76..c707798 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ Its types follow the CityJSON 2.0 schema: a geometry's boundaries and its semant ### Using the library ```sh -cargo add cjseq +cargo add cjseq --no-default-features ``` +The default `cli` feature builds the `cjseq` binary and pulls in `clap` and `rand`; the library itself depends only on `serde`, `serde_json` and `indexmap`. + ### Compiling the project 1. Install the [Rust compiler](https://www.rust-lang.org/learn/get-started) @@ -47,7 +49,7 @@ Alternatively, to use stdin as input: cat myfile.city.json | cjseq cat ``` -By default the features are written in the order of the input; `--order lexicographical` sorts them by id, `--order random` shuffles them. +By default the features are written in the order of the input; `--order lexicographical` sorts them by id, `--order random` shuffles them (`--seed ` reproduces a shuffle). ### Convert CityJSONSeq to CityJSON @@ -151,5 +153,5 @@ cargo install wasm-pack Build the WASM bindings, which are behind the `wasm` cargo feature: ```sh -wasm-pack build --release --target web --out-dir js -- --features wasm +wasm-pack build --release --target web --out-dir js -- --no-default-features --features wasm ``` diff --git a/src/cityjson.rs b/src/cityjson.rs index 5f1e498..a5e905d 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -59,14 +59,31 @@ fn slice_appearance( Ok(a) } +/// Fisher-Yates over a SplitMix64 stream: a reproducible shuffle without a +/// random-number dependency. +fn shuffle(v: &mut [T], seed: u64) { + let mut state = seed; + let mut next = || { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + for i in (1..v.len()).rev() { + let j = (next() % (i as u64 + 1)) as usize; + v.swap(i, j); + } +} + /// The order in which [`CityJSON::features`] hands out features. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum SortingStrategy { /// The order the top-level City Objects appear in the document. AsIs, - /// A random permutation, different on every call. - Random, + /// A random permutation, the same for the same seed. + Random { seed: u64 }, /// Sorted by City Object id. Lexicographical, } @@ -203,10 +220,7 @@ impl CityJSON { .collect(); match order { SortingStrategy::AsIs => {} - SortingStrategy::Random => { - use rand::seq::SliceRandom; - ids.shuffle(&mut rand::thread_rng()); - } + SortingStrategy::Random { seed } => shuffle(&mut ids, seed), SortingStrategy::Lexicographical => ids.sort(), } ids diff --git a/src/main.rs b/src/main.rs index 9ae0358..e8f926b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,9 @@ enum Commands { /// Sorting for the cat output #[arg(short, long, value_enum)] order: Option, + /// Seed for `--order random`, to reproduce an order; random if absent + #[arg(long)] + seed: Option, }, /// CityJSONSeq ==> CityJSON Collect { @@ -73,10 +76,12 @@ enum Commands { fn main() { let cli = Cli::parse(); let result = match &cli.command { - Commands::Cat { file, order } => { + Commands::Cat { file, order, seed } => { let order = match order.clone().unwrap_or(SortingStrategy::AsIs) { SortingStrategy::AsIs => cjseq::SortingStrategy::AsIs, - SortingStrategy::Random => cjseq::SortingStrategy::Random, + SortingStrategy::Random => cjseq::SortingStrategy::Random { + seed: seed.unwrap_or_else(rand::random), + }, SortingStrategy::Lexicographical => cjseq::SortingStrategy::Lexicographical, }; read_all(file.as_deref()).and_then(|s| cat(&s, order)) diff --git a/tests/seq.rs b/tests/seq.rs index a761b44..0edfbd7 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -64,7 +64,7 @@ fn features_come_out_in_the_order_asked_for() { let ids = |o| -> Vec { cj.features(o).map(|f| f.unwrap().id).collect() }; assert_eq!(ids(SortingStrategy::AsIs), ["c", "a", "b"]); assert_eq!(ids(SortingStrategy::Lexicographical), ["a", "b", "c"]); - let mut r = ids(SortingStrategy::Random); + let mut r = ids(SortingStrategy::Random { seed: 7 }); r.sort(); assert_eq!(r, ["a", "b", "c"]); } @@ -524,3 +524,34 @@ fn collecting_rejects_a_city_object_id_seen_in_an_earlier_feature() { assert!(!doc.city_objects.contains_key("b")); assert_eq!(doc.city_objects["p"].parents, Some(vec!["a".to_string()])); } + +/// A random order is reproducible: the same seed gives the same order, and +/// different seeds give different ones. +#[test] +fn a_random_order_is_reproducible_from_its_seed() { + let objects: String = (0..50) + .map(|i| format!(r#""o{i}":{{"type":"Building"}}"#)) + .collect::>() + .join(","); + let cj = CityJSON::from_str(&format!( + r#"{{"type":"CityJSON","version":"2.0","transform":{{"scale":[1.0,1.0,1.0],"translate":[0.0,0.0,0.0]}},"CityObjects":{{{objects}}},"vertices":[]}}"# + )) + .unwrap(); + let order = |seed| -> Vec { + cj.features(SortingStrategy::Random { seed }) + .map(|f| f.unwrap().id) + .collect() + }; + assert_eq!(order(1), order(1)); + assert_ne!(order(1), order(2)); + let as_is: Vec = cj + .features(SortingStrategy::AsIs) + .map(|f| f.unwrap().id) + .collect(); + assert_ne!(order(1), as_is); + let mut sorted = order(1); + sorted.sort(); + let mut expected = as_is; + expected.sort(); + assert_eq!(sorted, expected); +} From a95d751d771a2c6fa567ddb7ace25932ed8a8dd7 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:09:47 +0200 Subject: [PATCH 63/65] feat!: warnings are a Warning type, not CjseqError CityJSON::warnings(), CityJSONFeature::warnings() and Collector::warnings() return Warning (ExtensionChildOfCoreObject, DroppedMember, DroppedHeaderMember), a #[non_exhaustive] enum of its own with Display, so a caller can tell from the type alone that none of them is fatal. The three variants are gone from CjseqError. --- src/cityjson.rs | 14 +++++----- src/error.rs | 65 +++++++++++++++++++++++++++-------------------- src/lib.rs | 2 +- src/main.rs | 6 ++--- src/validate.rs | 10 ++++---- tests/seq.rs | 4 +-- tests/validate.rs | 15 +++++++++-- 7 files changed, 69 insertions(+), 47 deletions(-) diff --git a/src/cityjson.rs b/src/cityjson.rs index a5e905d..7cc5437 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,6 +1,6 @@ use crate::appearance::{Appearance, MaterialKey, MaterialObject, TextureKey, TextureObject}; use crate::city_object::{CityObject, CityObjectType}; -use crate::error::{CjseqError, IndexKind, Result}; +use crate::error::{CjseqError, IndexKind, Result, Warning}; use crate::geometry::{Geometry, GeometryTemplates}; use crate::index::{Index, MaterialIndex, TextureIndex, UvIndex, VertexIndex}; use crate::metadata::{Metadata, Transform}; @@ -441,7 +441,7 @@ pub struct Collector { /// document's palettes. materials: HashMap, textures: HashMap, - warnings: Vec, + warnings: Vec, } /// How one source's appearance indices map onto the document's palettes. @@ -485,7 +485,7 @@ impl Collector { fn merge_appearance( &mut self, app: Appearance, - dropped: &dyn Fn(String) -> CjseqError, + dropped: &dyn Fn(String) -> Warning, ) -> PaletteMaps { let mut maps = PaletteMaps::default(); let doc_app = self.doc.appearance.get_or_insert_with(Appearance::new); @@ -567,7 +567,7 @@ impl Collector { } } } - let header_dropped = |member: String| CjseqError::DroppedHeaderMember { member }; + let header_dropped = |member: String| Warning::DroppedHeaderMember { member }; if header.metadata.is_some() && header.metadata != self.doc.metadata { self.warnings.push(header_dropped("metadata".to_string())); } @@ -681,14 +681,14 @@ impl Collector { } self.check_indices(&mut cjf)?; for member in cjf.other.keys() { - self.warnings.push(CjseqError::DroppedMember { + self.warnings.push(Warning::DroppedMember { feature: cjf.id.clone(), member: member.clone(), }); } let id = cjf.id.clone(); let mut maps = match cjf.appearance.take() { - Some(app) => self.merge_appearance(app, &|member| CjseqError::DroppedMember { + Some(app) => self.merge_appearance(app, &|member| Warning::DroppedMember { feature: id.clone(), member, }), @@ -723,7 +723,7 @@ impl Collector { } /// What collecting has had to drop so far. - pub fn warnings(&self) -> &[CjseqError] { + pub fn warnings(&self) -> &[Warning] { &self.warnings } diff --git a/src/error.rs b/src/error.rs index cf58769..7b5a082 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,24 +41,12 @@ pub enum CjseqError { expected: usize, found: usize, }, - /// A core City Object has an Extension City Object as a child, which - /// § 8.7 rule 3 says it should not. Reported by `warnings()`, not - /// `errors()`: the Noise Extension published with the spec does it. - ExtensionChildOfCoreObject { parent: String, child: String }, - /// Collecting dropped a root member of a CityJSONFeature: the feature - /// schema allows any, but a CityJSON document has nowhere to keep them - /// per feature. Reported by [`crate::Collector::warnings`]. - DroppedMember { feature: String, member: String }, /// City Objects that no feature reaches: their parent is missing, or /// does not list them as children. NotInAnyFeature { ids: Vec }, /// Two CityJSONSeq headers being collected into one document disagree /// on something that cannot be merged. HeaderMismatch { member: String }, - /// Collecting dropped a member of a later sequence's header that - /// differed from the document's. Reported by - /// [`crate::Collector::warnings`]. - DroppedHeaderMember { member: String }, /// A City Object id that is already in the document, met again while /// collecting. DuplicateId { id: String }, @@ -94,6 +82,44 @@ pub enum CjseqError { }, } +/// Something a valid document or a collect may still do, but that the +/// spec advises against or that lost data on the way. Never fatal. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Warning { + /// A core City Object has an Extension City Object as a child, which + /// § 8.7 rule 3 says it should not; the Noise Extension published with + /// the spec does it. + ExtensionChildOfCoreObject { parent: String, child: String }, + /// Collecting dropped a root member of a CityJSONFeature: the feature + /// schema allows any, but a CityJSON document has nowhere to keep them + /// per feature. Also a feature's default theme that disagrees with the + /// document's. + DroppedMember { feature: String, member: String }, + /// Collecting dropped a member of a later sequence's header that + /// differed from the document's. + DroppedHeaderMember { member: String }, +} + +impl fmt::Display for Warning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Warning::ExtensionChildOfCoreObject { parent, child } => write!( + f, + "core City Object {parent:?} has the Extension City Object {child:?} as a child (§ 8.7 rule 3)" + ), + Warning::DroppedMember { feature, member } => write!( + f, + "dropped the root member {member:?} of CityJSONFeature {feature:?}" + ), + Warning::DroppedHeaderMember { member } => write!( + f, + "dropped {member:?} of a later sequence's header, which differs from the first" + ), + } + } +} + /// What an out-of-range index was indexing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -133,14 +159,6 @@ impl fmt::Display for CjseqError { expected, found, } => write!(f, "{found} {what} where the boundaries call for {expected}"), - CjseqError::ExtensionChildOfCoreObject { parent, child } => write!( - f, - "core City Object {parent:?} has the Extension City Object {child:?} as a child (§ 8.7 rule 3)" - ), - CjseqError::DroppedMember { feature, member } => write!( - f, - "dropped the root member {member:?} of CityJSONFeature {feature:?}" - ), CjseqError::NotInAnyFeature { ids } => write!( f, "City Objects in no feature (a parent is missing or does not list them as children): {}", @@ -150,10 +168,6 @@ impl fmt::Display for CjseqError { f, "the sequences being collected disagree on {member}" ), - CjseqError::DroppedHeaderMember { member } => write!( - f, - "dropped {member:?} of a later sequence's header, which differs from the first" - ), CjseqError::DuplicateId { id } => { write!(f, "City Object {id:?} is in more than one feature") } @@ -193,12 +207,9 @@ impl std::error::Error for CjseqError { | CjseqError::ValueCount { .. } | CjseqError::MisplacedMember { .. } | CjseqError::Pattern { .. } - | CjseqError::ExtensionChildOfCoreObject { .. } - | CjseqError::DroppedMember { .. } | CjseqError::NotInAnyFeature { .. } | CjseqError::HeaderMismatch { .. } | CjseqError::DuplicateId { .. } - | CjseqError::DroppedHeaderMember { .. } | CjseqError::IndexOutOfRange { .. } => None, } } diff --git a/src/lib.rs b/src/lib.rs index 49ecca2..1cbfc1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,7 @@ pub use cityjson::*; //-- `std::result::Result` in the importing module -- a surprise this crate has //-- no business inflicting on its callers. `CjseqError` is re-exported by //-- name; the alias stays reachable as `cjseq::error::Result`. -pub use error::{CjseqError, IndexKind}; +pub use error::{CjseqError, IndexKind, Warning}; pub use geometry::*; pub use index::{ Index, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, diff --git a/src/main.rs b/src/main.rs index e8f926b..4d8abae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -271,9 +271,9 @@ fn validate(input: &str) -> Result<()> { warnings.extend(h.warnings().iter().map(|e| format!("header: {e}"))); } Ok(Line::Feature(f)) => { - let at = |e: &CjseqError| format!("feature {:?}: {e}", f.id); - problems.extend(f.errors().iter().map(at)); - warnings.extend(f.warnings().iter().map(at)); + let at = |e: &dyn std::fmt::Display| format!("feature {:?}: {e}", f.id); + problems.extend(f.errors().iter().map(|e| at(e))); + warnings.extend(f.warnings().iter().map(|w| at(w))); } } } diff --git a/src/validate.rs b/src/validate.rs index 3c527a1..811014f 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -5,7 +5,7 @@ use crate::appearance::{Appearance, MaterialReference, TextureReference, TexturedRing}; use crate::city_object::{CityObject, CityObjectType}; use crate::cityjson::{CityJSON, CityJSONFeature}; -use crate::error::{CjseqError, Result}; +use crate::error::{CjseqError, Result, Warning}; use crate::geometry::{Geometry, Shell, Surface, Themes}; use crate::index::{ Index, Indices, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, @@ -364,7 +364,7 @@ fn hierarchy( /// A CityObjectGroup's `children` are its members, not a hierarchy, so the /// rule does not apply to it. Each pair is reported once, whichever side /// names the other. -fn extension_children(cos: &IndexMap) -> Vec { +fn extension_children(cos: &IndexMap) -> Vec { let core = |co: &CityObject| { matches!(co.thetype, CityObjectType::Known(_)) && co.thetype != CityObjectType::CityObjectGroup @@ -389,7 +389,7 @@ fn extension_children(cos: &IndexMap) -> Vec { } pairs .into_iter() - .map(|(parent, child)| CjseqError::ExtensionChildOfCoreObject { + .map(|(parent, child)| Warning::ExtensionChildOfCoreObject { parent: parent.to_string(), child: child.to_string(), }) @@ -480,7 +480,7 @@ impl CityJSON { /// What the spec advises against but a valid document may still do: /// a core City Object with an Extension child (§ 8.7 rule 3). - pub fn warnings(&self) -> Vec { + pub fn warnings(&self) -> Vec { extension_children(&self.city_objects) } } @@ -514,7 +514,7 @@ impl CityJSONFeature { } /// As [`CityJSON::warnings`]. - pub fn warnings(&self) -> Vec { + pub fn warnings(&self) -> Vec { extension_children(&self.city_objects) } } diff --git a/tests/seq.rs b/tests/seq.rs index 0edfbd7..18d1468 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -245,9 +245,9 @@ fn collecting_reports_each_feature_root_member_it_drops() { let w = c.warnings(); assert_eq!(w.len(), 2, "{w:?}"); assert!( - matches!(&w[0], CjseqError::DroppedMember { feature, member } if feature == "a" && member == "+provenance") + matches!(&w[0], cjseq::Warning::DroppedMember { feature, member } if feature == "a" && member == "+provenance") ); - assert!(matches!(&w[1], CjseqError::DroppedMember { member, .. } if member == "+note")); + assert!(matches!(&w[1], cjseq::Warning::DroppedMember { member, .. } if member == "+note")); let doc = c.finish(); assert!(!doc.other.contains_key("+provenance")); } diff --git a/tests/validate.rs b/tests/validate.rs index 73e36da..32d8c45 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -513,7 +513,7 @@ fn an_extension_child_under_a_core_city_object_is_a_warning() { let w = cj.warnings(); assert_eq!(w.len(), 1, "{w:?}"); assert!( - matches!(&w[0], CjseqError::ExtensionChildOfCoreObject { parent, child } if parent == "b" && child == "x"), + matches!(&w[0], cjseq::Warning::ExtensionChildOfCoreObject { parent, child } if parent == "b" && child == "x"), "{w:?}" ); //-- an Extension parent may have either kind of child @@ -550,7 +550,7 @@ fn the_noise_extension_example_is_valid_with_a_warning_per_segment() { cj.validate().unwrap(); let w = cj.warnings(); assert_eq!(w.len(), 2, "{w:?}"); - assert!(w.iter().all(|e| matches!(e, CjseqError::ExtensionChildOfCoreObject { parent, .. } if parent == "a_noisy_bench"))); + assert!(w.iter().all(|e| matches!(e, cjseq::Warning::ExtensionChildOfCoreObject { parent, .. } if parent == "a_noisy_bench"))); } /// An address's `location` is a MultiPoint into `vertices` (§ 2.3): its @@ -600,3 +600,14 @@ fn an_address_location_is_a_geometry_like_any_other() { let e = inner(first_error(&bad), "b"); assert!(e.to_string().contains("MultiPoint"), "{e}"); } + +/// Warnings and errors are different types, so a caller cannot mistake one +/// for the other. +#[test] +fn warnings_are_not_errors() { + fn is_warnings(_: &[cjseq::Warning]) {} + let cj = doc(json!({}), json!([]), json!({})); + is_warnings(&cj.warnings()); + is_warnings(&CityJSONFeature::new().warnings()); + is_warnings(cjseq::Collector::new(cj).warnings()); +} From 195c5085f47b03beadf3eef0e04be27284263b5b Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 20:12:09 +0200 Subject: [PATCH 64/65] build: rust-version 1.85, checked in CI 1.85 is the oldest Rust the locked dependencies build on (indexmap 2.14 and its hashbrown, clap 4.6); the whole test suite passes on it. A CI job builds and tests with --locked on exactly 1.85. The CHANGELOG covers this round's fixes and API changes. --- .github/workflows/ci.yml | 8 ++++++++ CHANGELOG.md | 10 +++++++++- Cargo.toml | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ed988e..04ddd16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,11 @@ jobs: cargo test --no-default-features - name: Build wasm run: cargo build --target wasm32-unknown-unknown --no-default-features --features wasm + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Rust (the rust-version in Cargo.toml) + uses: dtolnay/rust-toolchain@1.85 + - name: Build and test on the minimum supported Rust + run: cargo test --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index f50db69..e47c458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,11 @@ - Objects the schema closes (geometries, `transform`, `appearance`, `geometry-templates`, materials, textures) reject members it does not name; every other object keeps them in an `other: serde_json::Map`. - `CityObjects` and the material/texture theme maps are `IndexMap`s, and object member order is preserved, so output follows input order and is deterministic. - The document model and CityJSONSeq sequencing are split. `CityJSON` holds only the document; `header()` (was `get_metadata`) and `features(order)` slice it into a sequence, `feature(id)` slices one feature, and `Collector` (`new`, `add`, `add_header`, `finish`) replaces `add_cjfeature`/`add_transform_correction`. `SeqReader`/`SeqWriter` read and write a sequence line by line, as `Line::Header`/`Line::Feature`. `get_cjfeature`, `sort_cjfeatures` and the hidden sorting state are gone; `cjseq_to_cj` returns a `Result`. -- `SortingStrategy` is `AsIs` (input order, now the CLI default), `Random` (a real shuffle) or `Lexicographical`; the unimplemented `Morton`/`Hilbert` are gone. +- `SortingStrategy` is `AsIs` (input order, now the CLI default), `Random { seed }` (a shuffle reproducible from its seed; `cjseq cat --seed`) or `Lexicographical`; the unimplemented `Morton`/`Hilbert` are gone. +- A feature is rooted at every City Object whose parents are only CityObjectGroups; a group's feature holds just the group, so a member of two groups is written once. A City Object no feature reaches is a `NotInAnyFeature` error instead of being left out. +- Warnings are a `Warning` type of their own, not `CjseqError` variants. +- The public enums are `#[non_exhaustive]` (except `Line`). +- `clap`, `rand` and `getrandom` are optional, behind a default `cli` feature that the binary needs; the library depends only on `serde`, `serde_json` and `indexmap`. `rust-version` is 1.85. - Errors are a `CjseqError` with structured variants (`IndexOutOfRange`, `DanglingReference`, `HierarchyMismatch`, `ValueCount`, `MisplacedMember`, located by `InCityObject`, `InTemplate`, `AtLine`); `Result` lives at `cjseq::error::Result`. Malformed input is an error, never a panic. - The library is split into per-concern modules; every type is re-exported at the crate root. `CityJSON` and `CityJSONFeature` implement `FromStr` and `Default`. - wasm-bindgen bindings are behind an off-by-default `wasm` cargo feature, so library consumers building for wasm32 no longer inherit them. @@ -26,6 +30,10 @@ - Round-trip, validation and sequencing tests over real CityJSONSeq fixtures; CI runs fmt, clippy with warnings denied, the tests and the wasm build. ### Fixed - `collect` corrupted the texture coordinates of every textured feature after the first. +- Address `location`s (Building, BuildingPart, BuildingUnit, Bridge, BridgePart) were never renumbered: `cat` and `collect` left them pointing at the wrong vertices. They are now a typed `CityObjectAddress`, renumbered and validated like any geometry. +- `cat | collect` lost `default-theme-material`/`-texture` when the document had no geometry templates. +- Collecting several sequences kept only a later header's transform; its geometry templates (and their appearance), extensions and root members are now merged, with GeometryInstances shifted to match, and conflicts reported. +- `collect` turned an index past the end of a feature's own vertices or palettes into a valid-looking index into another feature's data; it is now an error. A City Object id seen in an earlier feature is a `DuplicateId` error instead of a silent overwrite. - The header written by `cat` cut the appearance down to what the geometry templates use but left the templates' indices unrenumbered, so they pointed at the wrong materials and textures. - Metadata, address, `pointOfContact`, semantics, appearance and feature members the schema permits are kept instead of dropped or rejected; every `referenceSystem` the schema's URL pattern permits is accepted. - Coordinates round-trip exactly (`serde_json`'s `float_roundtrip`). diff --git a/Cargo.toml b/Cargo.toml index fb86913..daed62b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.5.0" description = "Create+process+modify+convert CityJSONSeq" authors = ["Hugo Ledoux "] edition = "2021" +#-- the oldest Rust the locked dependencies build on (indexmap 2.14, clap 4.6) +rust-version = "1.85" license = "MIT" readme = "README.md" homepage = "https://github.com/cityjson/cjseq" From 0d92c4b51f4b32b9dc2aadf649b4b6caf8ad69f7 Mon Sep 17 00:00:00 2001 From: HideBa Date: Mon, 12 Oct 2026 21:19:27 +0200 Subject: [PATCH 65/65] revert!: group members travel inside their group's feature again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undoes the group policy of 074ce96. A CityObjectGroup's members name the group in their parents (§ 2.5), so they are part of the group's feature, as before; a member of two groups is in both groups' features. The NotInAnyFeature check from the same commit stays: cat still never drops a City Object silently. To collect such sequences, Collector::add now takes a City Object it has already collected when the new copy is identical, and still reports DuplicateId when it differs (nothing of that feature's City Objects or vertices is added then). Identical copies compare equal because vertices and texture vertices are now merged as features are added (finish() did this for vertices already, so collect output is byte-identical on the 65 MB sequence, 0.38 s). A feature's validate() accepts a City Object with a group parent in the feature naming its other groups, which live in other features. --- CHANGELOG.md | 4 +- src/appearance.rs | 5 -- src/cityjson.rs | 149 +++++++++++++++++++++++++++----------------- src/validate.rs | 31 +++++---- tests/robustness.rs | 27 +++++--- tests/seq.rs | 44 +++++++++---- 6 files changed, 160 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47c458..2fafb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - `CityObjects` and the material/texture theme maps are `IndexMap`s, and object member order is preserved, so output follows input order and is deterministic. - The document model and CityJSONSeq sequencing are split. `CityJSON` holds only the document; `header()` (was `get_metadata`) and `features(order)` slice it into a sequence, `feature(id)` slices one feature, and `Collector` (`new`, `add`, `add_header`, `finish`) replaces `add_cjfeature`/`add_transform_correction`. `SeqReader`/`SeqWriter` read and write a sequence line by line, as `Line::Header`/`Line::Feature`. `get_cjfeature`, `sort_cjfeatures` and the hidden sorting state are gone; `cjseq_to_cj` returns a `Result`. - `SortingStrategy` is `AsIs` (input order, now the CLI default), `Random { seed }` (a shuffle reproducible from its seed; `cjseq cat --seed`) or `Lexicographical`; the unimplemented `Morton`/`Hilbert` are gone. -- A feature is rooted at every City Object whose parents are only CityObjectGroups; a group's feature holds just the group, so a member of two groups is written once. A City Object no feature reaches is a `NotInAnyFeature` error instead of being left out. +- A City Object no feature reaches (its parent is missing, or does not list it as a child) is a `NotInAnyFeature` error from `features()` instead of being left out of `cat`'s output. - Warnings are a `Warning` type of their own, not `CjseqError` variants. - The public enums are `#[non_exhaustive]` (except `Line`). - `clap`, `rand` and `getrandom` are optional, behind a default `cli` feature that the binary needs; the library depends only on `serde`, `serde_json` and `indexmap`. `rust-version` is 1.85. @@ -33,7 +33,7 @@ - Address `location`s (Building, BuildingPart, BuildingUnit, Bridge, BridgePart) were never renumbered: `cat` and `collect` left them pointing at the wrong vertices. They are now a typed `CityObjectAddress`, renumbered and validated like any geometry. - `cat | collect` lost `default-theme-material`/`-texture` when the document had no geometry templates. - Collecting several sequences kept only a later header's transform; its geometry templates (and their appearance), extensions and root members are now merged, with GeometryInstances shifted to match, and conflicts reported. -- `collect` turned an index past the end of a feature's own vertices or palettes into a valid-looking index into another feature's data; it is now an error. A City Object id seen in an earlier feature is a `DuplicateId` error instead of a silent overwrite. +- `collect` turned an index past the end of a feature's own vertices or palettes into a valid-looking index into another feature's data; it is now an error. A City Object met in more than one feature (a member of two CityObjectGroups travels in both groups' features) is collected once when both copies are identical, and is a `DuplicateId` error instead of a silent overwrite when they are not; equal vertices and texture vertices are merged as features are added, so identical copies compare equal. - The header written by `cat` cut the appearance down to what the geometry templates use but left the templates' indices unrenumbered, so they pointed at the wrong materials and textures. - Metadata, address, `pointOfContact`, semantics, appearance and feature members the schema permits are kept instead of dropped or rejected; every `referenceSystem` the schema's URL pattern permits is accepted. - Coordinates round-trip exactly (`serde_json`'s `float_roundtrip`). diff --git a/src/appearance.rs b/src/appearance.rs index 56522d6..3fa0f44 100644 --- a/src/appearance.rs +++ b/src/appearance.rs @@ -325,11 +325,6 @@ impl Appearance { default_theme_material: None, } } - pub(crate) fn add_vertices_texture(&mut self, mut vs: Vec<[f64; 2]>) { - self.vertices_texture - .get_or_insert_with(Vec::new) - .append(&mut vs); - } } #[cfg(test)] diff --git a/src/cityjson.rs b/src/cityjson.rs index 7cc5437..655915b 100644 --- a/src/cityjson.rs +++ b/src/cityjson.rs @@ -1,5 +1,5 @@ use crate::appearance::{Appearance, MaterialKey, MaterialObject, TextureKey, TextureObject}; -use crate::city_object::{CityObject, CityObjectType}; +use crate::city_object::CityObject; use crate::error::{CjseqError, IndexKind, Result, Warning}; use crate::geometry::{Geometry, GeometryTemplates}; use crate::index::{Index, MaterialIndex, TextureIndex, UvIndex, VertexIndex}; @@ -200,14 +200,12 @@ impl CityJSON { } } - /// Whether City Object `co` roots a feature: it has no parents, or only - /// CityObjectGroups as parents (a group's members are not its parts). + /// Whether City Object `co` roots a feature: it has no parents. A + /// CityObjectGroup's members name the group as a parent (§ 2.5), so + /// they travel inside the group's feature; a member of two groups is in + /// both, and [`Collector`] takes it once. fn is_root(&self, co: &CityObject) -> bool { - co.parents.iter().flatten().all(|p| { - self.city_objects - .get(p) - .is_some_and(|pco| pco.thetype == CityObjectType::CityObjectGroup) - }) + co.parents.as_ref().is_none_or(|p| p.is_empty()) } /// The ids of the City Objects that root a feature, in `order`. @@ -263,8 +261,7 @@ impl CityJSON { /// The feature rooted at City Object `id`: it with all of its /// descendants, and only the vertices and appearance entries they use, - /// renumbered. A CityObjectGroup's feature holds just the group. - /// `Ok(None)` if there is no such City Object. + /// renumbered. `Ok(None)` if there is no such City Object. /// /// A child id that names no City Object, or an index past the end of /// `vertices` or of an appearance palette, is an error. @@ -275,8 +272,8 @@ impl CityJSON { } } - /// The root, then every descendant breadth-first. A group's members - /// are not followed; `seen` stops a parent/child cycle from looping. + /// The root, then every descendant breadth-first; `seen` stops a + /// parent/child cycle from looping. fn members<'a>( &'a self, id: &'a String, @@ -288,9 +285,6 @@ impl CityJSON { while next < members.len() { let (parent_id, parent) = members[next]; next += 1; - if parent.thetype == CityObjectType::CityObjectGroup { - continue; - } for child_id in parent.children.iter().flatten() { if !seen.insert(child_id.as_str()) { continue; @@ -441,9 +435,19 @@ pub struct Collector { /// document's palettes. materials: HashMap, textures: HashMap, + /// Where each distinct vertex and texture vertex already is, so equal + /// ones get one index and a City Object met in two features comes out + /// identical both times. + vertices: HashMap, + uvs: HashMap<[u64; 2], usize>, warnings: Vec, } +/// A texture vertex's bits, `-0.0` folded onto `0.0`. +fn uv_key(uv: [f64; 2]) -> [u64; 2] { + uv.map(|x| (x + 0.0).to_bits()) +} + /// How one source's appearance indices map onto the document's palettes. #[derive(Default)] struct PaletteMaps { @@ -467,12 +471,21 @@ impl Collector { .flatten() .map(TextureObject::key), ); + let vertices = positions(header.vertices.iter().copied()); + let uvs = positions( + app.and_then(|a| a.vertices_texture.as_ref()) + .into_iter() + .flatten() + .map(|uv| uv_key(*uv)), + ); Collector { doc: header, correction: None, template_offset: 0, materials, textures, + vertices, + uvs, warnings: Vec::new(), } } @@ -525,14 +538,13 @@ impl Collector { }); maps.textures.insert(TextureIndex(i), TextureIndex(at)); } - if let Some(uvs) = app.vertices_texture { - //-- appended verbatim, so each new index is the old one shifted by - //-- however many were already collected - let offset = doc_app.vertices_texture.as_ref().map_or(0, Vec::len); - for i in 0..uvs.len() { - maps.uvs.insert(UvIndex(i), UvIndex(offset + i)); - } - doc_app.add_vertices_texture(uvs); + for (i, uv) in app.vertices_texture.into_iter().flatten().enumerate() { + let all = doc_app.vertices_texture.get_or_insert_with(Vec::new); + let at = *self.uvs.entry(uv_key(uv)).or_insert_with(|| { + all.push(uv); + all.len() - 1 + }); + maps.uvs.insert(UvIndex(i), UvIndex(at)); } maps } @@ -660,11 +672,16 @@ impl Collector { /// Add one feature: its City Objects, its vertices (re-expressed if a /// later header changed the transform) and its appearance, merged into - /// the document's palettes. Root members the feature schema allows but - /// does not name are dropped, each with a [`Collector::warnings`] entry. + /// the document's: equal vertices, texture vertices, materials and + /// textures become one entry. Root members the feature schema allows + /// but does not name are dropped, each with a [`Collector::warnings`] + /// entry. /// - /// A City Object id already collected from an earlier feature is a - /// [`CjseqError::DuplicateId`], and nothing of the feature is added. + /// A City Object already collected from an earlier feature is taken + /// once when it is the same City Object (as a member of two groups is, + /// in both groups' features), and is a [`CjseqError::DuplicateId`] + /// when it is not; then no City Object or vertex of the feature is + /// added. pub fn add(&mut self, mut cjf: CityJSONFeature) -> Result<()> { if !cjf.city_objects.contains_key(&cjf.id) { return Err(CjseqError::DanglingReference { @@ -672,20 +689,7 @@ impl Collector { to: cjf.id, }); } - if let Some(id) = cjf - .city_objects - .keys() - .find(|id| self.doc.city_objects.contains_key(*id)) - { - return Err(CjseqError::DuplicateId { id: id.clone() }); - } self.check_indices(&mut cjf)?; - for member in cjf.other.keys() { - self.warnings.push(Warning::DroppedMember { - feature: cjf.id.clone(), - member: member.clone(), - }); - } let id = cjf.id.clone(); let mut maps = match cjf.appearance.take() { Some(app) => self.merge_appearance(app, &|member| Warning::DroppedMember { @@ -694,30 +698,61 @@ impl Collector { }), None => PaletteMaps::default(), }; - let doc = &mut self.doc; - let g_offset = doc.vertices.len(); + //-- each vertex, re-expressed in the document's transform, and the + //-- index it gets: an existing one, or a new one held back until the + //-- feature is known to be accepted + let base = self.doc.vertices.len(); + let mut new_vertices: Vec = Vec::new(); + let mut pending: HashMap = HashMap::new(); + let t = &self.doc.transform; + let table: Vec = cjf + .vertices + .iter() + .map(|v| { + let v = match &self.correction { + None => *v, + Some(c) => [0, 1, 2].map(|k| { + (((v[k] as f64 * c.scale[k]) + c.translate[k] - t.translate[k]) + / t.scale[k]) + .round() as i64 + }), + }; + match self.vertices.get(&v) { + Some(at) => *at, + None => *pending.entry(v).or_insert_with(|| { + new_vertices.push(v); + base + new_vertices.len() - 1 + }), + } + }) + .collect(); + let mut cos = Vec::with_capacity(cjf.city_objects.len()); for (key, mut co) in cjf.city_objects { for g in co.geometries_mut() { - g.offset_geometry_boundaries(g_offset); + g.for_each_vertex(&mut |vi| *vi = VertexIndex(table[vi.0])); g.offset_template(self.template_offset); g.update_material(&mut maps.materials); g.update_texture(&mut maps.textures, &mut maps.uvs); } - doc.city_objects.insert(key, co); - } - match &self.correction { - None => doc.vertices.extend(cjf.vertices), - Some(c) => { - //-- re-express each vertex in the document's transform - let t = &doc.transform; - doc.vertices.extend(cjf.vertices.iter().map(|v| { - [0, 1, 2].map(|k| { - (((v[k] as f64 * c.scale[k]) + c.translate[k] - t.translate[k]) - / t.scale[k]) - .round() as i64 - }) - })); + if let Some(existing) = self.doc.city_objects.get(&key) { + if *existing != co { + return Err(CjseqError::DuplicateId { id: key }); + } } + cos.push((key, co)); + } + for member in cjf.other.keys() { + self.warnings.push(Warning::DroppedMember { + feature: cjf.id.clone(), + member: member.clone(), + }); + } + for (i, v) in new_vertices.into_iter().enumerate() { + self.vertices.insert(v, base + i); + self.doc.vertices.push(v); + } + for (key, co) in cos { + self.doc.city_objects.entry(key).or_insert(co); } Ok(()) } diff --git a/src/validate.rs b/src/validate.rs index 811014f..7eb8c22 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -308,24 +308,21 @@ fn geometry(g: &Geometry, l: &Lengths, errs: &mut Vec) { } /// Every child and parent named exists, and names the other back. -/// `outside_root` is set for a feature: it names the one City Object whose -/// parents may lie outside `cos` (the feature's root, whose groups are other -/// features), and a group's members may lie outside it too. -fn hierarchy( - cos: &IndexMap, - outside_root: Option<&str>, - errs: &mut Vec, -) { +/// `in_feature` is set when `cos` is one feature: a member of several +/// CityObjectGroups travels with each, so a City Object with a group parent +/// in the feature may name its other groups, which live in other features. +fn hierarchy(cos: &IndexMap, in_feature: bool, errs: &mut Vec) { + let is_group = |id: &String| { + cos.get(id) + .is_some_and(|c| c.thetype == CityObjectType::CityObjectGroup) + }; let names = |list: &Option>, id: &str| { list.as_ref().is_some_and(|l| l.iter().any(|x| x == id)) }; for (id, co) in cos { - //-- in a feature, a group's members are features of their own - let members_elsewhere = - outside_root.is_some() && co.thetype == CityObjectType::CityObjectGroup; + let other_groups_elsewhere = in_feature && co.parents.iter().flatten().any(&is_group); for child in co.children.iter().flatten() { match cos.get(child) { - None if members_elsewhere => {} None => errs.push(CjseqError::DanglingReference { from: id.clone(), to: child.clone(), @@ -342,7 +339,7 @@ fn hierarchy( } for parent in co.parents.iter().flatten() { match cos.get(parent) { - None if outside_root == Some(id.as_str()) => {} + None if other_groups_elsewhere => {} None => errs.push(CjseqError::DanglingReference { from: id.clone(), to: parent.clone(), @@ -398,11 +395,11 @@ fn extension_children(cos: &IndexMap) -> Vec { fn city_objects( cos: &IndexMap, - outside_root: Option<&str>, + in_feature: bool, l: &Lengths, errs: &mut Vec, ) { - hierarchy(cos, outside_root, errs); + hierarchy(cos, in_feature, errs); for (id, co) in cos { let mut inner = Vec::new(); city_object_type(&co.thetype, &mut inner); @@ -469,7 +466,7 @@ impl CityJSON { .as_ref() .map_or(0, |t| t.templates.len()); let l = Lengths::new(self.vertices.len(), Some(templates), app); - city_objects(&self.city_objects, None, &l, &mut errs); + city_objects(&self.city_objects, false, &l, &mut errs); errs } @@ -504,7 +501,7 @@ impl CityJSONFeature { }); } let l = Lengths::new(self.vertices.len(), None, self.appearance.as_ref()); - city_objects(&self.city_objects, Some(&self.id), &l, &mut errs); + city_objects(&self.city_objects, true, &l, &mut errs); errs } diff --git a/tests/robustness.rs b/tests/robustness.rs index b70f711..2e7b6e3 100644 --- a/tests/robustness.rs +++ b/tests/robustness.rs @@ -225,23 +225,36 @@ fn a_city_object_in_no_feature_is_an_error_not_dropped() { } } -/// A CityObjectGroup's members are not its parts (§ 2.5): each member is a -/// feature of its own, and the group's feature holds just the group, so a -/// member of two groups is written once. +/// A CityObjectGroup's feature carries its members (§ 2.5: they name the +/// group in `parents`), so a member of two groups is in both groups' +/// features; each of those features is valid on its own, and collecting +/// them gives the member back once. #[test] -fn group_members_are_features_of_their_own() { +fn group_members_travel_inside_their_groups_features() { let cj = doc( json!({ "g1": {"type": "CityObjectGroup", "children": ["b"]}, "g2": {"type": "CityObjectGroup", "children": ["b", "c"]}, - "b": {"type": "Building", "parents": ["g1", "g2"], "children": ["bp"]}, + "b": {"type": "Building", "parents": ["g1", "g2"], "children": ["bp"], + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [0, 1]}]}, "bp": {"type": "BuildingPart", "parents": ["b"]}, "c": {"type": "Building", "parents": ["g2"]} }), - json!([]), + json!([[1, 2, 3], [4, 5, 6]]), ); cj.validate().unwrap(); let fs = features(&mut cj.clone(), SortingStrategy::AsIs); let ids: Vec> = fs.iter().map(|f| f.city_objects.keys().collect()).collect(); - assert_eq!(ids, [vec!["g1"], vec!["g2"], vec!["b", "bp"], vec!["c"]]); + assert_eq!(ids, [vec!["g1", "b", "bp"], vec!["g2", "b", "c", "bp"]]); + for f in &fs { + f.validate().unwrap_or_else(|e| panic!("{}: {e}", f.id)); + } + let mut c = cjseq::Collector::new(cj.header().unwrap()); + for f in fs { + c.add(f).unwrap(); + } + let back = c.finish(); + back.validate().unwrap(); + assert_eq!(back.city_objects.len(), 5); + assert_eq!(back.vertices.len(), 2); } diff --git a/tests/seq.rs b/tests/seq.rs index 18d1468..addeeda 100644 --- a/tests/seq.rs +++ b/tests/seq.rs @@ -497,32 +497,52 @@ fn collecting_rejects_a_feature_whose_indices_point_past_its_own_data() { } } -/// City Object ids are unique in a document, so two features carrying the -/// same id cannot both be collected: the second is an error, not a silent -/// overwrite of the first. +/// City Object ids are unique in a document. The same City Object may +/// come in more than one feature (a member of two groups is in both +/// groups' features), and is collected once; two different City Objects +/// under one id are an error, not a silent overwrite. #[test] -fn collecting_rejects_a_city_object_id_seen_in_an_earlier_feature() { +fn collecting_accepts_an_identical_repeat_and_rejects_a_different_one() { let (header, _) = read("tests/data/small.city.jsonl"); - let f = |id: &str, part: &str| { + let f = |id: &str, part: &str, x: i64| { CityJSONFeature::from_str( &json!({"type": "CityJSONFeature", "id": id, "CityObjects": {id: {"type": "Building", "children": [part]}, - part: {"type": "BuildingPart", "parents": [id]}}, - "vertices": []}) + part: {"type": "BuildingPart", "parents": [id], + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [1]}]}}, + "vertices": [[9, 9, 9], [x, 0, 0]]}) .to_string(), ) .unwrap() }; let mut c = Collector::new(header); - c.add(f("a", "p")).unwrap(); - match c.add(f("b", "p")) { + c.add(f("a", "p", 1)).unwrap(); + //-- the same "p" again, its vertex at the same place but a different index + let mut again = f("a2", "p", 1); + again.city_objects["p"].parents = Some(vec!["a".into()]); + again.city_objects.shift_remove("a2"); + again.city_objects.insert( + "a2".into(), + cjseq::CityObject::new(cjseq::CityObjectType::Building), + ); + again.vertices.swap(0, 1); + let g = again.city_objects["p"].geometry.as_mut().unwrap(); + g[0] = serde_json::from_value(json!({"type": "MultiPoint", "lod": "1", "boundaries": [0]})) + .unwrap(); + c.add(again).unwrap(); + //-- a different "p" + let mut other = f("b", "p", 2); + other.city_objects["p"].parents = Some(vec!["a".into()]); + match c.add(other) { Err(CjseqError::DuplicateId { id }) => assert_eq!(id, "p"), other => panic!("expected DuplicateId, got {other:?}"), } - //-- the rejected feature left nothing behind let doc = c.finish(); - assert!(!doc.city_objects.contains_key("b")); - assert_eq!(doc.city_objects["p"].parents, Some(vec!["a".to_string()])); + assert!( + !doc.city_objects.contains_key("b"), + "the rejected feature left nothing behind" + ); + assert_eq!(doc.city_objects.len(), 3); } /// A random order is reproducible: the same seed gives the same order, and