Skip to main content

fcb_core/
obj.rs

1//! A minimal CityJSON-to-OBJ writer.
2//!
3//! Ported from `cjseq2` 0.1.1's `conv::obj`, which the typed rewrite dropped.
4//! The behaviour is unchanged, including its crude notion of a face: every ring
5//! becomes an `f` line, interior rings included, and nothing is triangulated.
6//!
7//! The one thing that did change is how the rings are found. The old version
8//! recursed through an untyped `Boundaries` tree until it hit a leaf; here each
9//! geometry's ring iterator comes from its variant, so the depth is never
10//! guessed at.
11
12use cjseq::{CityJSON, Geometry, Ring};
13use std::io::{Result as IoResult, Write};
14
15/// Converts a CityJSON object to OBJ format and returns it as a string.
16pub fn to_obj_string(city_json: &CityJSON) -> String {
17    let mut output = Vec::new();
18    // Writing into a `Vec<u8>` cannot fail, and the bytes are the ASCII we
19    // just wrote, so neither unwrap can fire.
20    to_obj(city_json, &mut output).expect("writing to a Vec cannot fail");
21    String::from_utf8(output).expect("OBJ output is ASCII")
22}
23
24/// Writes a CityJSON object as OBJ to `writer`.
25pub fn to_obj<W: Write>(city_json: &CityJSON, writer: &mut W) -> IoResult<()> {
26    writeln!(writer, "# Converted from CityJSON to OBJ")?;
27    writeln!(writer, "# by CJSeq converter")?;
28    writeln!(writer)?;
29
30    // Vertices are stored as integers plus a transform; OBJ wants the real
31    // coordinates.
32    let scale = &city_json.transform.scale;
33    let translate = &city_json.transform.translate;
34
35    for vertex in &city_json.vertices {
36        let x = (vertex[0] as f64 * scale[0]) + translate[0];
37        let y = (vertex[1] as f64 * scale[1]) + translate[1];
38        let z = (vertex[2] as f64 * scale[2]) + translate[2];
39        writeln!(writer, "v {x} {y} {z}")?;
40    }
41
42    writeln!(writer)?;
43
44    for city_object in city_json.city_objects.values() {
45        if let Some(geometries) = &city_object.geometry {
46            for geometry in find_highest_lod_geometry(geometries) {
47                for ring in rings(geometry) {
48                    write_obj_face(ring, writer)?;
49                }
50            }
51        }
52    }
53
54    Ok(())
55}
56
57/// Every ring of a geometry, in document order. The nesting depth comes from
58/// the variant, so no runtime inspection of the boundaries is needed.
59fn rings(geometry: &Geometry) -> Box<dyn Iterator<Item = &Ring> + '_> {
60    match geometry {
61        Geometry::MultiPoint { boundaries, .. } | Geometry::GeometryInstance { boundaries, .. } => {
62            Box::new(std::iter::once(boundaries))
63        }
64        Geometry::MultiLineString { boundaries, .. } => Box::new(boundaries.iter()),
65        Geometry::MultiSurface { boundaries, .. }
66        | Geometry::CompositeSurface { boundaries, .. } => Box::new(boundaries.iter().flatten()),
67        Geometry::Solid { boundaries, .. } => Box::new(boundaries.iter().flatten().flatten()),
68        Geometry::MultiSolid { boundaries, .. } | Geometry::CompositeSolid { boundaries, .. } => {
69            Box::new(boundaries.iter().flatten().flatten().flatten())
70        }
71    }
72}
73
74/// The geometries with the highest numeric lod, or all of them when no
75/// geometry has an lod that parses as a number.
76fn find_highest_lod_geometry(geometries: &[Geometry]) -> Vec<&Geometry> {
77    let lod_of = |g: &Geometry| g.lod().and_then(|l| l.parse::<f64>().ok());
78
79    let Some(max_lod) = geometries
80        .iter()
81        .filter_map(lod_of)
82        .fold(None, |max: Option<f64>, lod| {
83            Some(max.map_or(lod, |m| m.max(lod)))
84        })
85    else {
86        return geometries.iter().collect();
87    };
88
89    geometries
90        .iter()
91        .filter(|g| lod_of(g).is_some_and(|lod| (lod - max_lod).abs() < f64::EPSILON))
92        .collect()
93}
94
95/// Writes one ring as an OBJ face. OBJ indices are 1-based, CityJSON's 0-based.
96fn write_obj_face<W: Write>(indices: &[usize], writer: &mut W) -> IoResult<()> {
97    if indices.is_empty() {
98        return Ok(());
99    }
100
101    write!(writer, "f")?;
102    for idx in indices {
103        write!(writer, " {}", idx + 1)?;
104    }
105    writeln!(writer)?;
106
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use serde_json::json;
114
115    fn city_json(geometry: serde_json::Value) -> CityJSON {
116        let doc = json!({
117            "type": "CityJSON",
118            "version": "2.0",
119            "transform": {"scale": [0.001, 0.001, 0.001], "translate": [1.0, 2.0, 3.0]},
120            "CityObjects": {"co-1": {"type": "Building", "geometry": [geometry]}},
121            "vertices": [[0, 0, 0], [1000, 0, 0], [1000, 1000, 0]]
122        });
123        serde_json::from_value(doc).expect("test document must parse")
124    }
125
126    #[test]
127    fn vertices_are_written_in_real_world_coordinates() {
128        let obj = to_obj_string(&city_json(
129            json!({"type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]]]}),
130        ));
131        assert!(obj.contains("v 1 2 3"), "{obj}");
132        assert!(obj.contains("v 2 2 3"), "{obj}");
133        assert!(obj.contains("v 2 3 3"), "{obj}");
134    }
135
136    /// Each geometry type must reach its own rings; the depth comes from the
137    /// variant, which is the whole point of the rewrite.
138    #[test]
139    fn every_geometry_type_yields_its_rings_as_faces() {
140        for (geometry, expected) in [
141            (
142                json!({"type": "MultiLineString", "lod": "1", "boundaries": [[0, 1, 2]]}),
143                "f 1 2 3",
144            ),
145            (
146                json!({"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]}),
147                "f 1 2 3",
148            ),
149            (
150                json!({"type": "Solid", "lod": "1", "boundaries": [[[[0, 1, 2]]]]}),
151                "f 1 2 3",
152            ),
153            (
154                json!({"type": "CompositeSolid", "lod": "1", "boundaries": [[[[[0, 1, 2]]]]]}),
155                "f 1 2 3",
156            ),
157        ] {
158            let obj = to_obj_string(&city_json(geometry));
159            assert!(obj.contains(expected), "{obj}");
160        }
161    }
162
163    #[test]
164    fn only_the_highest_lod_is_written() {
165        let doc = json!({
166            "type": "CityJSON",
167            "version": "2.0",
168            "transform": {"scale": [1.0, 1.0, 1.0], "translate": [0.0, 0.0, 0.0]},
169            "CityObjects": {"co-1": {"type": "Building", "geometry": [
170                {"type": "MultiSurface", "lod": "1", "boundaries": [[[0, 1, 2]]]},
171                {"type": "MultiSurface", "lod": "2", "boundaries": [[[2, 1, 0]]]}
172            ]}},
173            "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0]]
174        });
175        let cj: CityJSON = serde_json::from_value(doc).expect("document must parse");
176        let obj = to_obj_string(&cj);
177        assert!(obj.contains("f 3 2 1"), "{obj}");
178        assert!(!obj.contains("f 1 2 3"), "{obj}");
179    }
180}