diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2212ddc..04ddd16 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,27 @@ 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: 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 --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/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index a5f2572..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 @@ -38,7 +35,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 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index ae42c8e..61978a4 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: | @@ -30,7 +27,7 @@ jobs: - name: Build WASM run: | - wasm-pack build --target web --no-pack + wasm-pack build --target web --scope cityjson -- --no-default-features --features wasm - name: Publish to NPM env: diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a27b9..2fafb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## [0.5.0] - unreleased +### Modified (breaking) +- 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 { seed }` (a shuffle reproducible from its seed; `cjseq cat --seed`) or `Lexicographical`; the unimplemented `Morton`/`Hilbert` are gone. +- 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. +- 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 +- `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. +### 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 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`). +- 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 - 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 5946fed..daed62b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "cjseq" -version = "0.4.0" +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" @@ -12,24 +14,40 @@ 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"] } -rand = "0.8.5" +indexmap = { version = "2", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -# serde_derive = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip", "preserve_order"] } + +# 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"] } -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" -getrandom = { version = "0.2", features = ["js"] } +console_error_panic_hook = { version = "0.1", optional = true } +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 388aff8..c707798 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,13 @@ 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 --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 @@ -44,6 +49,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 (`--seed ` reproduces a shuffle). + ### Convert CityJSONSeq to CityJSON The operator "collect" converts a CityJSONSeq stream to a CityJSON file: @@ -56,12 +63,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 +94,53 @@ 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 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 - 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 +150,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 -- --no-default-features --features wasm ``` diff --git a/src/appearance.rs b/src/appearance.rs new file mode 100644 index 0000000..3fa0f44 --- /dev/null +++ b/src/appearance.rs @@ -0,0 +1,734 @@ +use crate::index::{Indices, MaterialIndex, PrimitiveValues, TextureIndex, UvIndex}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// 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: TextureIndex, + 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: TextureIndex(*texture), + uvs: rest + .iter() + .map(|uv| { + uv.map(UvIndex).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.0)) + .chain(uvs.into_iter().map(|uv| Some(uv.0))) + .collect(), + } + } +} +/// 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; + +impl Indices for TexturedRing { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut TextureIndex)) { + if let TexturedRing::Textured { texture, .. } = self { + f(texture) + } + } + fn visit_index(&self, f: &mut dyn FnMut(TextureIndex)) { + if let TexturedRing::Textured { texture, .. } = self { + f(*texture) + } + } +} + +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) + } + } + 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: 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>, + D: serde::Deserializer<'de>, +{ + T::deserialize(de).map(Some) +} + +/// One theme's material for a geometry: a `value` for the whole geometry, or +/// `values` with one index per primitive. +/// +/// 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)] +#[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>, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + /// Members the schema does not name, kept verbatim. + #[serde(flatten)] + 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); + } + fn visit_index(&self, f: &mut dyn FnMut(MaterialIndex)) { + self.value.visit_index(f); + self.values.visit_index(f); + } +} + +/// 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, + /// Members the schema does not name, kept verbatim. + #[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); + } + fn visit_index(&self, f: &mut dyn FnMut(I)) { + self.values.visit_index(f); + } +} + +/// An RGB colour, three numbers. +pub type Color = [f64; 3]; + +/// A texture's `borderColor`: three or four numbers, written back as given. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum BorderColor { + /// `minItems: 3` + Rgb(Color), + /// `maxItems: 4` + Rgba([f64; 4]), +} + +/// 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, +} + +/// 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, + Mirror, + Clamp, + Border, +} + +/// What a texture depicts. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum TextureType { + Unknown, + Specific, + Typical, +} + +/// 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 { + /// 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 `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 format (`type` in JSON). + #[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, +} + +/// 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. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Appearance { + #[serde(skip_serializing_if = "Option::is_none")] + pub materials: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub textures: Option>, + /// 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>, + #[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, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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 = + serde_json::from_value(serde_json::json!({"value": 0, "vendorData": true})).unwrap(); + assert_eq!(m.value, Some(MaterialIndex(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"}) + ); + } + + /// 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. + 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() { + 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]]]}) + ); + } + + /// 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(); + assert_eq!(m.value, Some(MaterialIndex(3))); + assert_eq!( + serde_json::to_value(&m).unwrap(), + serde_json::json!({"value": 3}) + ); + } + + /// 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(); + 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!({}) + ); + + //-- `{"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!({})); + } + + /// `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 + 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(vec![Some(MaterialIndex(0)), Some(MaterialIndex(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!({})); + } + + //------------------------------------------------------------------- + //-- the document-level appearance *library*: `materials`/`textures` + //------------------------------------------------------------------- + + /// 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"] { + 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` 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"] { + 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" + ); + } + } + + /// 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": [{ + "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" + ); + } + + #[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" + ); + } + } + + #[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); + } + + /// 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!( + 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 too + assert!( + serde_json::from_value::( + serde_json::json!({"textures": [{"wrapmode": "wrap"}]}) + ) + .is_err(), + "`wrapmode` is not `wrapMode`" + ); + } + + /// The appearance object is closed too. + #[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); + } + + #[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); + } + + /// Empty palettes stay empty rather than becoming absent. + #[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); + } +} diff --git a/src/city_object.rs b/src/city_object.rs new file mode 100644 index 0000000..4f71749 --- /dev/null +++ b/src/city_object.rs @@ -0,0 +1,438 @@ +use crate::geometry::Geometry; +use crate::metadata::GeographicalExtent; +use serde::{Deserialize, Serialize}; +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, + 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` 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. +/// +/// # Matching +/// +/// Each known type is also an associated `const` ([`CityObjectType::BuildingPart`] +/// and so on). Match on a value, not a reference: +/// +/// ``` +/// # use cjseq::CityObjectType; +/// # let t = &CityObjectType::Building; +/// match *t { +/// CityObjectType::Building => { /* .. */ } +/// CityObjectType::Extension(ref s) => { let _ = s; } +/// _ => {} +/// } +/// ``` +/// +/// 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), + /// An Extension type, with its leading `+`. + Extension(String), +} + +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, + } + } +} + +/// 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); + 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); +} + +/// 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")] + pub thetype: CityObjectType, + #[serde(rename = "geographicalExtent")] + #[serde(skip_serializing_if = "Option::is_none")] + 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")] + pub children: Option>, + /// 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, kept verbatim. + #[serde(flatten)] + pub other: Map, +} + +impl CityObject { + /// 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, + geographical_extent: None, + attributes: None, + address: None, + geometry: None, + children: None, + children_roles: None, + parents: None, + other: Map::new(), + } + } + + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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); + 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 = + 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 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(); + assert_eq!(t, CityObjectType::Extension("+Building".into())); + assert_ne!(t, CityObjectType::Building); + } + + /// 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!( + r#"{"type":"CityJSONFeature","id":"id-1","#, + r#""CityObjects":{"id-1":{"type":"+NoiseCityFurnitureSegment","#, + r#""+noiseLevel":{"dB":55}}},"#, + 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 with an extra flattened member inside a \ + CityJSONFeature must round-trip byte-for-byte" + ); + } + + #[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); + } + + /// `geographicalExtent` has exactly six numbers. + #[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()); + + 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] + 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); + } + + /// `children_roles` round-trips, `null` roles included. + #[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 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)] = &[ + ("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 new file mode 100644 index 0000000..655915b --- /dev/null +++ b/src/cityjson.rs @@ -0,0 +1,960 @@ +use crate::appearance::{Appearance, MaterialKey, MaterialObject, TextureKey, TextureObject}; +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}; +use crate::metadata::{Metadata, Transform}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +/// 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. +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: I::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)?); + } + if let Some(t) = &all.textures { + a.textures = Some(reindex(t, t_oldnew)?); + } + if let Some(tv) = &all.vertices_texture { + a.vertices_texture = Some(reindex(tv, t_v_oldnew)?); + } + 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, the same for the same seed. + Random { seed: u64 }, + /// Sorted by City Object id. + Lexicographical, +} + +/// 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, + pub version: CityJSONVersion, + pub transform: Transform, + #[serde(rename = "CityObjects")] + pub city_objects: IndexMap, + 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>, + /// Root members the schema does not name, kept verbatim. + #[serde(flatten)] + pub other: Map, +} + +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 { + Ok(serde_json::from_str(s)?) + } +} + +impl CityJSON { + /// An empty CityJSON 2.0 document with the identity transform. + pub fn new() -> Self { + CityJSON { + thetype: CityJSONType::CityJSON, + version: CityJSONVersion::V2_0, + transform: Transform::new(), + city_objects: IndexMap::new(), + vertices: Vec::new(), + metadata: None, + appearance: None, + geometry_templates: None, + extensions: None, + other: Map::new(), + } + } + + /// 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 { + city_objects: IndexMap::new(), + vertices: Vec::new(), + appearance: None, + ..self.clone_without_objects() + }; + 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); + } + if let Some(a) = &self.appearance { + 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); + } 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) + } + + /// 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(), + } + } + + /// 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.as_ref().is_none_or(|p| p.is_empty()) + } + + /// 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)| self.is_root(co)) + .map(|(id, _)| id) + .collect(); + match order { + SortingStrategy::AsIs => {} + SortingStrategy::Random { seed } => shuffle(&mut ids, seed), + SortingStrategy::Lexicographical => ids.sort(), + } + ids + } + + /// 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, + ) -> Box> + '_> { + let plan: Result>> = self + .feature_ids(order) + .into_iter() + .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. + /// + /// 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(self.members(id, root)?).map(Some), + None => Ok(None), + } + } + + /// The root, then every descendant breadth-first; `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; + 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)); + } + } + 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(); + 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.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); + } + cjf.add_co(key.clone(), co2); + } + 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)?); + } + Ok(cjf) + } + + /// 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(); + 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(VertexIndex(i), VertexIndex(new)); + } + for co in self.city_objects.values_mut() { + for g in co.geometries_mut() { + g.update_geometry_boundaries(&mut newids); + } + } + self.vertices = newvertices; + } + /// 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]); + } + (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 Some((mins, _)) = self.vertex_bounds() else { + return; + }; + for v in &mut self.vertices { + for (c, m) in v.iter_mut().zip(mins) { + *c -= m; + } + } + let t = &mut self.transform; + for ((tr, sc), m) in t.translate.iter_mut().zip(t.scale).zip(mins) { + *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| self.is_root(co)) + .count() + } +} + +/// 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. +pub struct Collector { + doc: CityJSON, + /// 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, + 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 { + materials: HashMap, + textures: HashMap, + uvs: 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), + ); + 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(), + } + } + + /// 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) -> Warning, + ) -> 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)); + } + 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 + } + + /// 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| Warning::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(()) + } + + /// 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: 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 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 { + from: "CityJSONFeature.id".to_string(), + to: cjf.id, + }); + } + self.check_indices(&mut cjf)?; + let id = cjf.id.clone(); + let mut maps = match cjf.appearance.take() { + Some(app) => self.merge_appearance(app, &|member| Warning::DroppedMember { + feature: id.clone(), + member, + }), + None => PaletteMaps::default(), + }; + //-- 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.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); + } + 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(()) + } + + /// What collecting has had to drop so far. + pub fn warnings(&self) -> &[Warning] { + &self.warnings + } + + /// 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 + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct CityJSONFeature { + #[serde(rename = "type")] + pub thetype: CityJSONFeatureType, + pub id: String, + #[serde(rename = "CityObjects")] + pub city_objects: IndexMap, + 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 { + 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 { + CityJSONFeature { + thetype: CityJSONFeatureType::CityJSONFeature, + id: "".to_string(), + city_objects: IndexMap::new(), + vertices: Vec::new(), + appearance: None, + other: Map::new(), + } + } + pub fn add_co(&mut self, id: String, co: CityObject) { + self.city_objects.insert(id, co); + } + /// 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 k in 0..3 { + totals[k] += v[k] as f64; + } + } + Some(totals.map(|t| t / self.vertices.len() as f64)) + } +} + +/// 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. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CityJSONVersion { + #[serde(rename = "1.1")] + V1_1, + #[serde(rename = "2.0")] + 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)] +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 +/// 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_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 = CityJSONVersion::V1_1; + assert_ne!(a, c); + } + + #[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/error.rs b/src/error.rs new file mode 100644 index 0000000..7b5a082 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,253 @@ +use crate::geometry::GeometryType; +use std::fmt; + +/// The crate's error type. +#[derive(Debug)] +#[non_exhaustive] +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` 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. + expected: usize, + /// 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), + /// 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 (or a feature's `id`) refers to an id that is not there. + DanglingReference { + /// The City Object holding the reference. + from: String, + /// 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, + }, + /// 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 }, + /// 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"`. + 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 }, + /// 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, + source: Box, + }, + /// An index points past the end of the array it indexes. + IndexOutOfRange { + kind: IndexKind, + index: usize, + /// The length of the array indexed. + len: usize, + }, +} + +/// 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] +pub enum IndexKind { + Vertex, + TextureVertex, + Material, + Texture, + Surface, + Template, +} + +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 these boundaries are nested {found} levels deep" + ), + 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::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::NotInAnyFeature { ids } => write!( + f, + "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::DuplicateId { id } => { + write!(f, "City Object {id:?} is in more than one feature") + } + 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" + ), + 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}") + } + CjseqError::IndexOutOfRange { kind, index, len } => { + write!(f, "{kind:?} index {index} is out of range (there are {len})") + } + } + } +} + +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, + CjseqError::Io(e) => Some(e), + CjseqError::InCityObject { source, .. } + | CjseqError::InTemplate { source, .. } + | CjseqError::AtLine { source, .. } => Some(source.as_ref()), + CjseqError::Validation(_) + | CjseqError::DanglingReference { .. } + | CjseqError::HierarchyMismatch { .. } + | CjseqError::ValueCount { .. } + | CjseqError::MisplacedMember { .. } + | CjseqError::Pattern { .. } + | CjseqError::NotInAnyFeature { .. } + | CjseqError::HeaderMismatch { .. } + | CjseqError::DuplicateId { .. } + | CjseqError::IndexOutOfRange { .. } => None, + } + } +} + +impl From for CjseqError { + fn from(e: serde_json::Error) -> Self { + CjseqError::Json(e) + } +} + +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/geometry.rs b/src/geometry.rs new file mode 100644 index 0000000..d41d177 --- /dev/null +++ b/src/geometry.rs @@ -0,0 +1,683 @@ +use crate::appearance::{ + MaterialReference, TextureReference, TexturedRing, TexturedShell, TexturedSurface, +}; +use crate::error::{CjseqError, Result}; +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; +use std::collections::HashMap; + +/// 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)] +#[non_exhaustive] +pub enum GeometryType { + MultiPoint, + MultiLineString, + MultiSurface, + CompositeSurface, + Solid, + MultiSolid, + CompositeSolid, + 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, + } + } +} + +/// 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; + +/// 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 (§ 3). +/// +/// 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 an error. A +/// 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, + boundaries: Ring, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + }, + MultiLineString { + lod: Lod, + boundaries: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + semantics: Option, + }, + MultiSurface { + lod: Lod, + boundaries: Vec, + #[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 { + lod: Lod, + boundaries: Vec, + #[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 { + lod: Lod, + boundaries: Vec, + #[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 { + lod: Lod, + boundaries: Vec>, + #[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 { + lod: Lod, + boundaries: Vec>, + #[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. + boundaries: [VertexIndex; 1], + template: TemplateIndex, + #[serde(rename = "transformationMatrix")] + transformation_matrix: [f64; 16], + }, +} + +impl Geometry { + /// 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 a Deserializer, so this borrows `v` rather than + //-- copying it + 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| GeometryType::deserialize(t).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, + }); + } + } + Err(CjseqError::Json(err)) + } + + 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 { + match self { + Geometry::MultiPoint { lod, .. } + | Geometry::MultiLineString { lod, .. } + | Geometry::MultiSurface { lod, .. } + | Geometry::CompositeSurface { lod, .. } + | Geometry::Solid { lod, .. } + | Geometry::MultiSolid { lod, .. } + | Geometry::CompositeSolid { lod, .. } => Some(*lod), + Geometry::GeometryInstance { .. } => None, + } + } + + /// 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, .. } => { + 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, + } + } + + /// 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::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), + } + } + + /// 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 { + Geometry::MultiSurface { material, .. } + | 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) + } + _ => {} + } + } + + /// 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::MultiSurface { texture, .. } | Geometry::CompositeSurface { texture, .. } => { + texture.for_each_index(f) + } + Geometry::Solid { texture, .. } => texture.for_each_index(f), + Geometry::MultiSolid { texture, .. } | Geometry::CompositeSolid { texture, .. } => { + texture.for_each_index(f) + } + _ => {} + } + } + + pub(crate) fn update_geometry_boundaries( + &mut self, + violdnew: &mut HashMap, + ) { + 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); + } + + /// Renumber every material index against `m_oldnew`, first-encounter + /// order. + pub(crate) fn update_material(&mut self, m_oldnew: &mut HashMap) { + self.for_each_material(&mut |m| *m = remap(m_oldnew, *m)); + } + + /// 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 `Collector::add` + /// does) fills the map first. + pub(crate) fn update_texture( + &mut self, + t_oldnew: &mut HashMap, + t_v_oldnew: &mut HashMap, + ) { + 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)); + } +} + +#[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 + /// their instances' transformation matrices. + #[serde(rename = "vertices-templates")] + pub vertices_templates: Vec<[f64; 3]>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(v: serde_json::Value) -> std::result::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().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"], + boundaries, + "{name} must round-trip its boundaries" + ); + + //-- 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" + ); + + //-- 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" + ); + } + } + + #[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.surfaces().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); + } + + /// 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!({ + "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().map(|(a, b)| (a.0, b.0)).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, + /// both in first-encounter order, and a repeated index maps to the same + /// new index every time. + #[test] + 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]]], + "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); + + 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]]]) + ); + 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().map(|(a, b)| (a.0, b.0)).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", "lod": "2", + "boundaries": [[[0, 1, 2]]], + "texture": {"t": {"values": [[[0, 2, 0, 1]]]}} + })) + .unwrap(); + 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"], + serde_json::json!([[[3, 12, 10, 11]]]) + ); + } + + /// `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!({ + "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 = serde_json::to_value(&g).unwrap()["material"]["irradiation"]["values"].clone(); + 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().map(|(a, b)| (a.0, b.0)).collect(); + m.sort(); + assert_eq!(m, vec![(0, 0), (2, 1)]); + } + + /// `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!({ + "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_eq!( + *found, 4, + "the depth actually found must be reported as a number, not prose" + ); + } + 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!({ + "type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(&g).unwrap(), + serde_json::json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}) + ); + } +} diff --git a/src/index.rs b/src/index.rs new file mode 100644 index 0000000..771abac --- /dev/null +++ b/src/index.rs @@ -0,0 +1,155 @@ +//! 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 Indices<$name> for $name { + 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 { + 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 +); + +/// 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)); + fn visit_index(&self, f: &mut dyn FnMut(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); + } + } + 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] { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + for t in self { + 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 { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + if let Some(t) = self { + 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 { + fn for_each_index(&mut self, f: &mut dyn FnMut(&mut I)) { + for t in self.values_mut() { + 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 +/// 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 bce0202..1cbfc1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,1252 +1,76 @@ -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 -} +//! `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` +//! - `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` +//! - `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 +//! 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; +pub mod error; +mod geometry; +mod index; +mod metadata; +mod semantics; +mod seq; +mod validate; + +pub use appearance::*; +pub use city_object::*; +pub use cityjson::*; +//-- 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, IndexKind, Warning}; +pub use geometry::*; +pub use index::{ + Index, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, + TemplateIndex, TextureIndex, UvIndex, VertexIndex, +}; +pub use metadata::*; +pub use semantics::*; +pub use seq::{Line, SeqReader, SeqWriter}; // 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::*; + +/// 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")] +pub struct ReadmeDoctests; diff --git a/src/main.rs b/src/main.rs index 20e2171..4d8abae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,12 @@ -use cjseq::CityJSON; -use cjseq::CityJSONFeature; -use cjseq::Transform; - -extern crate clap; +use cjseq::error::Result; +use cjseq::{ + CityJSON, CityObjectType, CjseqError, Collector, Line, SeqReader, SeqWriter, Transform, +}; 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; -use std::io::{self, Read, Write}; -use std::path::PathBuf; +use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; #[derive(Parser)] #[command(version, about = "Create/process/modify CityJSONSeq files", long_about = None)] @@ -23,6 +17,8 @@ struct Cli { #[derive(Clone, ValueEnum)] pub enum SortingStrategy { + /// The order of the input + AsIs, Random, Lexicographical, } @@ -36,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 { @@ -66,76 +65,28 @@ enum Commands { #[arg(long, value_name = "X", value_parser = clap::value_parser!(u32).range(1..), group = "exclusive")] random: Option, }, -} - -#[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) - } + /// 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 - Commands::Cat { file, order } => { - let o2 = match order.clone().unwrap_or(SortingStrategy::Random) { - SortingStrategy::Random => cjseq::SortingStrategy::Random, + let result = match &cli.command { + Commands::Cat { file, order, seed } => { + let order = match order.clone().unwrap_or(SortingStrategy::AsIs) { + SortingStrategy::AsIs => cjseq::SortingStrategy::AsIs, + SortingStrategy::Random => cjseq::SortingStrategy::Random { + seed: seed.unwrap_or_else(rand::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, @@ -143,250 +94,202 @@ 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); - } - } - 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); + 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()), } - } + } 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<(), MyError> { - 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 == true && !exclude) || (w == false && 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<(), MyError> { - 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 == true && !exclude) || (w == false && 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: &Vec) -> Result<(), MyError> { - 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)?; - 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; - } - if (w == true && !exclude) || (w == false && exclude) { - io::stdout().write_all(&format!("{}\n", l).as_bytes())?; - } - } - } +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(&header)?; + for f in features { + w.write_feature(&f?)?; } + w.into_inner().flush()?; Ok(()) } -fn filter_radius(exclude: bool, x: f64, y: f64, r: f64) -> Result<(), MyError> { - 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)?; - 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; - } - if (w == true && !exclude) || (w == false && 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)?))?; + } + } + 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)?; + out.write_all(b"\n")?; + out.flush()?; Ok(()) } -fn collect_from_stdin() -> Result<(), MyError> { - 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); - } - } 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: &Vec) -> Result<(), MyError> { - 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 == false { - 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<(), MyError> { - let mut input = String::new(); - match std::io::stdin().read_to_string(&mut input) { - Ok(_) => { - let mut cjj: CityJSON = CityJSON::from_str(&input)?; - let _ = cat(&mut cjj, order)?; +/// 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)); } - 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}"))); + warnings.extend(h.warnings().iter().map(|e| format!("header: {e}"))); + } + Ok(Line::Feature(f)) => { + 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))); + } + } + } } } - Ok(()) -} - -fn cat_from_file(file: &PathBuf, order: cjseq::SortingStrategy) -> Result<(), MyError> { - 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<(), MyError> { - if cjj.thetype != "CityJSON" { - return Err(MyError::CityJsonError( - "Input file not CityJSON.".to_string(), - )); + for w in &warnings { + println!("warning: {w}"); } - if cjj.version != "1.1" && cjj.version != "2.0" { - return Err(MyError::CityJsonError( - "Input file not CityJSON v1.1 nor v2.0.".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 new file mode 100644 index 0000000..fbe898d --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,500 @@ +use crate::error::CjseqError; +use serde::{Deserialize, Serialize}; +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 { + pub scale: [f64; 3], + pub translate: [f64; 3], +} +impl Default for Transform { + fn default() -> Self { + Self::new() + } +} + +impl Transform { + /// 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], + } + } +} + +/// `[minx, miny, minz, maxx, maxy, maxz]`. +pub type GeographicalExtent = [f64; 6]; + +/// The `address` member of a [`PointOfContact`] (§ 5.3). +/// +/// 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)] + pub members: Map, +} + +/// The `pointOfContact` member of [`Metadata`] (§ 5.3). +/// +/// `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")] + pub contact_name: String, + #[serde(rename = "contactType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_type: Option, + #[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, + /// 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")] + pub address: Option
, + /// Every contact member the schema does not name, kept verbatim. + #[serde(flatten)] + pub other: Map, +} + +/// `pointOfContact.contactType` (§ 5.3). +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +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")] +#[non_exhaustive] +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 `referenceSystem` URL, `https://www.opengis.net/def/crs/{authority}/{version}/{code}` +/// in the OGC form every real file uses. +/// +/// 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. +/// +/// 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 { + /// Everything up to and including `.../def/crs`, with no trailing slash. + pub base_url: 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 { + ReferenceSystem { + base_url: base_url.unwrap_or_else(|| DEFAULT_CRS_BASE_URL.to_string()), + segments: vec![authority, version, 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) + } + + /// The `{version}` element (`"0"` when the CRS has no version). + pub fn version(&self) -> Option<&str> { + self.segments.get(1).map(String::as_str) + } + + /// The `{code}` element identifying the CRS. + pub fn code(&self) -> Option<&str> { + self.segments.get(2).map(String::as_str) + } + + /// 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. Only the prefix the schema requires + /// is checked. + pub fn from_url(url: &str) -> crate::error::Result { + //-- 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/", + ]; + 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 = prefix.len(); + Ok(ReferenceSystem { + //-- everything before the `/` that separates prefix from path + base_url: url[..split - 1].to_string(), + segments: url[split..].split('/').map(str::to_string).collect(), + }) + } +} + +/// The conversions serde uses: [`ReferenceSystem::to_url`] and +/// [`ReferenceSystem::from_url`]. +impl From for String { + fn from(rs: ReferenceSystem) -> String { + rs.to_url() + } +} + +impl TryFrom for ReferenceSystem { + type Error = CjseqError; + fn try_from(url: String) -> crate::error::Result { + ReferenceSystem::from_url(&url) + } +} + +/// The `metadata` member of a [`crate::CityJSON`] (§ 5). +/// +/// 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")] + #[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, + /// Every metadata member the core spec does not name, kept verbatim. + #[serde(flatten)] + pub other: Map, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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 = [1.0, 0.0, 0.0]; + assert_ne!(a, c); + } + + #[test] + fn address_with_equal_fields_is_equal() { + 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] + 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, + organization: None, + address: None, + other: Map::new(), + }; + let b = a.clone(); + assert_eq!(a, b); + } + + /// 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!({ + "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); + } + } + + #[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); + } + + /// `contactName` and `emailAddress` are required. + #[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(); + 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); + } + + /// 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"; + let rs: ReferenceSystem = serde_json::from_value(serde_json::json!(url)).unwrap(); + 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)); + } + + /// 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 [ + "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); + } + + /// The prefix is still required. + #[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", + "", + //-- 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", + ] { + assert!( + ReferenceSystem::from_url(bad).is_err(), + "{bad:?} does not match the schema pattern and must be rejected" + ); + } + } + + #[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); + } + + #[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 { + 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()), + other: Map::new(), + }; + let b = a.clone(); + assert_eq!(a, b); + } + + /// 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!({ + "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"}) + ); + } +} diff --git a/src/semantics.rs b/src/semantics.rs new file mode 100644 index 0000000..e88764f --- /dev/null +++ b/src/semantics.rs @@ -0,0 +1,386 @@ +use crate::index::{Indices, PrimitiveValues, SurfaceIndex}; +#[cfg(doc)] +use crate::index::{MultiSolidValues, SolidValues}; +use serde::{Deserialize, Serialize}; +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, + WallSurface, + ClosureSurface, + OuterCeilingSurface, + OuterFloorSurface, + Window, + Door, + InteriorWallSurface, + CeilingSurface, + FloorSurface, + WaterSurface, + WaterGroundSurface, + WaterClosureSurface, + TrafficArea, + AuxiliaryTrafficArea, + TransportationMarking, + TransportationHole, +} + +/// 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. +/// +/// # Matching +/// +/// Each known type is also an associated `const` ([`SemanticSurfaceType::RoofSurface`] +/// and so on). Match on a value, not a reference: +/// +/// ``` +/// # use cjseq::SemanticSurfaceType; +/// # let t = &SemanticSurfaceType::RoofSurface; +/// match *t { +/// SemanticSurfaceType::RoofSurface => { /* .. */ } +/// SemanticSurfaceType::Extension(ref s) => { let _ = s; } +/// _ => {} +/// } +/// ``` +/// +/// 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), + /// An Extension type, with its leading `+`. + Extension(String), +} + +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, + } + } +} + +/// 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 = + 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. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct SemanticsSurface { + #[serde(rename = "type")] + pub thetype: SemanticSurfaceType, + #[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: Map, +} + +/// 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 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 surfaces: Vec, + 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); + } + 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)] +mod tests { + use super::*; + + /// 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!({ + "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!({ + "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(); + //-- `null`, never `[]` + assert_eq!( + serde_json::to_value(&s).unwrap()["values"], + serde_json::json!([null, 0]) + ); + } + + #[test] + fn semantics_surface_and_semantics_implement_eq() { + fn assert_impls_eq() {} + assert_impls_eq::(); + assert_impls_eq::(); + } + + /// A semantic surface's hierarchy and extra members round-trip. + #[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![SurfaceIndex(1)])); + assert_eq!(s.surfaces[1].parent, Some(SurfaceIndex(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); + } + + /// With no extra members, none are written. + #[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); + } + + /// `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!({ + "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); + } + + #[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); + } + + /// 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!( + 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(); + let reserialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!( + reserialized, input, + "an Extension-typed SemanticsSurface with an extra flattened member inside a \ + Geometry must round-trip byte-for-byte" + ); + } + + /// 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)] = &[ + ("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/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/validate.rs b/src/validate.rs new file mode 100644 index 0000000..7eb8c22 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,517 @@ +//! 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, CityObjectType}; +use crate::cityjson::{CityJSON, CityJSONFeature}; +use crate::error::{CjseqError, Result, Warning}; +use crate::geometry::{Geometry, Shell, Surface, Themes}; +use crate::index::{ + Index, Indices, MaterialIndex, MultiSolidValues, PrimitiveValues, SolidValues, SurfaceIndex, + TextureIndex, UvIndex, +}; +use crate::semantics::{SemanticSurfaceType, 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); + } + } +} + +/// `\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 { + 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. +/// `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 { + 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 => 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 if other_groups_elsewhere => {} + 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(), + }); + } + } + } + } + } +} + +/// § 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)| Warning::ExtensionChildOfCoreObject { + parent: parent.to_string(), + child: child.to_string(), + }) + .collect() +} + +fn city_objects( + cos: &IndexMap, + in_feature: bool, + l: &Lengths, + errs: &mut Vec, +) { + hierarchy(cos, in_feature, errs); + for (id, co) in cos { + let mut inner = Vec::new(); + city_object_type(&co.thetype, &mut inner); + 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), + })); + } +} + +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(); + 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); + 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, false, &l, &mut errs); + errs + } + + /// The first of [`CityJSON::errors`], if any. + 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 { + /// 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, true, &l, &mut errs); + errs + } + + /// The first of [`CityJSONFeature::errors`], if any. + pub fn validate(&self) -> Result<()> { + first(self.errors()) + } + + /// As [`CityJSON::warnings`]. + pub fn warnings(&self) -> Vec { + extension_children(&self.city_objects) + } +} 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/cat_collect.rs b/tests/cat_collect.rs new file mode 100644 index 0000000..b2bcd17 --- /dev/null +++ b/tests/cat_collect.rs @@ -0,0 +1,335 @@ +//! `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] +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"] { + 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); + } +} diff --git a/tests/cj_input.rs b/tests/cj_input.rs index 6ae7967..0ff61c0 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] @@ -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_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(); + 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(); - assert_eq!(cjnext.thetype, "CityJSONFeature"); - assert_eq!(cjnext.id, "NL.IMBAG.Pand.0503100000028341"); - - cjnext = cjj.get_cjfeature(1).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 51ec149..b4e2365 100755 --- a/tests/cjf_input.rs +++ b/tests/cjf_input.rs @@ -1,33 +1,21 @@ -use crate::cjseq::{CityJSON, CityJSONFeature}; -use cjseq; -use std::path::PathBuf; - -use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; +use cjseq::{CityJSON, CityJSONFeature, Collector, SortingStrategy}; +use std::str::FromStr; #[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).is_none()); - assert_eq!(cjj.vertices.is_empty(), true); - } 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/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/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..14601d0 --- /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":[{"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":[]}} +{"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]]} diff --git a/tests/depth_rejection.rs b/tests/depth_rejection.rs new file mode 100644 index 0000000..315294d --- /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 + 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); +} 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"]); +} 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/robustness.rs b/tests/robustness.rs new file mode 100644 index 0000000..2e7b6e3 --- /dev/null +++ b/tests/robustness.rs @@ -0,0 +1,260 @@ +//! 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.features(order).map(|f| f.unwrap()).collect() +} + +#[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", "lod": "1", "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.feature("b") { + 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", "lod": "1", "boundaries": [0, 7]}]}}), + json!([[1, 2, 3]]), + ); + assert!(matches!( + cj.feature("b"), + Err(CjseqError::IndexOutOfRange { .. }) + )); +} + +#[test] +fn an_index_past_the_last_feature_is_none() { + let cj = doc(json!({"b": {"type": "Building"}}), json!([])); + assert!(cj.feature("nope").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])); +} + +/// 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 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_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"], + "geometry": [{"type": "MultiPoint", "lod": "1", "boundaries": [0, 1]}]}, + "bp": {"type": "BuildingPart", "parents": ["b"]}, + "c": {"type": "Building", "parents": ["g2"]} + }), + 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", "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/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..b494377 --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,336 @@ +//! Real-file round trips: every fixture is parsed, serialized and compared +//! with its input, which catches any member the model drops. +//! +//! 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}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +/// 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`. +/// +/// 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 + //-- 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", + //-- 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. + "tests/data/duplicate_keys.city.jsonl", + "tests/data/inferable_types.city.jsonl", + "tests/data/long_strings.city.jsonl", + "tests/data/single_feature.city.jsonl", + "data/3dbag_b2.city.jsonl", + ] + .iter() + .map(PathBuf::from) + .collect() +} + +/// 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). +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. 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:?}") + } + }; + 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}"); + } + //-- `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(original), + canonical_numbers(reserialized), + "{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 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 +/// 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()); + } +} diff --git a/tests/seq.rs b/tests/seq.rs new file mode 100644 index 0000000..addeeda --- /dev/null +++ b/tests/seq.rs @@ -0,0 +1,577 @@ +//! 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 { seed: 7 }); + 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()); +} + +/// 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))); +} + +/// 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], cjseq::Warning::DroppedMember { feature, member } if feature == "a" && member == "+provenance") + ); + assert!(matches!(&w[1], cjseq::Warning::DroppedMember { member, .. } if member == "+note")); + 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")); +} + +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()); +} + +/// 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:?}"), + } + } +} + +/// 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_accepts_an_identical_repeat_and_rejects_a_different_one() { + let (header, _) = read("tests/data/small.city.jsonl"); + 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], + "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", 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:?}"), + } + let doc = c.finish(); + 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 +/// 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); +} diff --git a/tests/texture_merge.rs b/tests/texture_merge.rs new file mode 100644 index 0000000..49a5870 --- /dev/null +++ b/tests/texture_merge.rs @@ -0,0 +1,154 @@ +//! 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]).unwrap(); + cj.validate().unwrap(); + + 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]])]).unwrap(); + let cj = CityJSON::from_str(&serde_json::to_string(&cj).unwrap()).unwrap(); + + let f = cj.feature("a").unwrap().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]]), + ], + ) + .unwrap(); + let expected_a = resolved_uvs(&doc, "a"); + let expected_b = resolved_uvs(&doc, "b"); + + 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); + 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")); +} diff --git a/tests/typed_strings.rs b/tests/typed_strings.rs new file mode 100644 index 0000000..047d8fb --- /dev/null +++ b/tests/typed_strings.rs @@ -0,0 +1,167 @@ +//! 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: cjseq::TextureIndex(0), + uvs: vec![cjseq::UvIndex(10), cjseq::UvIndex(11), cjseq::UvIndex(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}" + ); + } +} + +#[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()) + ); +} 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); +} diff --git a/tests/validate.rs b/tests/validate.rs new file mode 100644 index 0000000..32d8c45 --- /dev/null +++ b/tests/validate.rs @@ -0,0 +1,613 @@ +//! `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(); + 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).unwrap()] + } 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}")); + 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)); + } + 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.header().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) + ); +} + +/// 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:?}" + ); + } +} + +/// § 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], cjseq::Warning::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, cjseq::Warning::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}"); +} + +/// 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()); +} 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![]; +}