ifc_lite_server/services/
parquet_schema.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The geometry Parquet WIRE SCHEMA — the three tables the standard transport
6//! emits, in one place.
7//!
8//! Split out of `parquet.rs` so the contract each client decodes against is
9//! readable on its own (and so the serializer stays under its module-size
10//! budget). Any column added or removed here is a wire-format change: keep it
11//! additive and backward-compatible, and pin it with a test in
12//! `parquet_tests.rs` — silently dropping a column is exactly how the per-mesh
13//! `origin` went missing in issue #1841.
14
15use arrow::datatypes::{DataType, Field, Schema};
16
17/// Absent marker for the two source ids. Mirrors `ABSENT_SOURCE_ID` in
18/// `packages/cache/src/sections/geometry.ts`.
19///
20/// A REAL collision, taken deliberately: `#4294967295` is a legal instance name
21/// (`fast_parse_tests.rs:57` asserts u32::MAX parses; `step_tests.rs:436`
22/// writes one), so a mesh sourced from exactly that id loses its drill target.
23/// The alternatives are worse: `0` is reachable, a nullable column leaks the
24/// neighbouring row's id on parquet-wasm 0.7.x, and presence columns cost two
25/// per row to disambiguate one value in 4.29 billion.
26pub(super) const ABSENT_SOURCE_ID: u32 = 0xFFFF_FFFF;
27use std::sync::Arc;
28
29/// The flat transport's mesh table.
30///
31/// `-parquet-v6` (issue #3888; `-parquet-v9` since #5130): the `vertex_start`/`vertex_count` and
32/// `index_start`/`index_count` columns no longer name a block this row OWNS.
33/// Several rows can point at ONE shared block — the rotation-aware shape
34/// sharing `/optimized` has carried since #3575, brought to the flat route —
35/// and the `rot0..rot8` tail below is what places each of them:
36/// `world = origin + R * p`, the same contract and the same Y-up frame as
37/// `instance_schema()`.
38///
39/// Two consequences for a decoder, both load-bearing:
40/// - `origin_x/y/z` was zero on every row of a v5 flat blob from a DEFAULT
41///   native server (the placement was baked into the vertices), so a decoder
42///   that ignored it was accidentally correct. Only on a default one:
43///   `local_frame_enabled` in `rust/geometry/src/router/transforms/mod.rs` is
44///   opt-in on native via `IFC_LITE_LOCAL_FRAME`, and a deployment that sets
45///   it has emitted non-zero origins here since #1841. On v6 the column
46///   carries real values wherever a shape is shared, on every deployment, so
47///   reading it is MANDATORY rather than merely recommended.
48/// - `rot0..rot8` are absent on a v5 blob. Absent means identity, which is
49///   exactly v5's behaviour; a v6 writer always emits them, identity included.
50///
51/// `include_rotation` follows the LAYOUT the client asked for
52/// (`ParquetLayout`), not whether anything was actually shared: a v5 request
53/// must come back byte-identical to what this route emitted before #3888, and
54/// a v6 request must carry the columns even from a batch-local stream, which
55/// shares nothing. That is why this is a parameter rather than being derived
56/// from the payload, unlike `instance_schema`, whose transport HAS a version
57/// byte to disambiguate an absent block from a truncated one.
58pub(super) fn mesh_schema(include_rotation: bool) -> Arc<Schema> {
59    Arc::new(Schema::new(
60        vec![
61            Field::new("express_id", DataType::UInt32, false),
62            Field::new("ifc_type", DataType::Utf8, false),
63            Field::new("vertex_start", DataType::UInt32, false),
64            Field::new("vertex_count", DataType::UInt32, false),
65            Field::new("index_start", DataType::UInt32, false),
66            Field::new("index_count", DataType::UInt32, false),
67            Field::new("color_r", DataType::Float32, false),
68            Field::new("color_g", DataType::Float32, false),
69            Field::new("color_b", DataType::Float32, false),
70            Field::new("color_a", DataType::Float32, false),
71        ]
72        .into_iter()
73        .chain(shared_trailing_fields())
74        .chain(if include_rotation { rotation_fields() } else { Vec::new() })
75        .collect::<Vec<_>>(),
76    ))
77}
78
79pub(super) fn vertex_schema() -> Arc<Schema> {
80    Arc::new(Schema::new(vec![
81        Field::new("x", DataType::Float32, false),
82        Field::new("y", DataType::Float32, false),
83        Field::new("z", DataType::Float32, false),
84        Field::new("nx", DataType::Float32, false),
85        Field::new("ny", DataType::Float32, false),
86        Field::new("nz", DataType::Float32, false),
87    ]))
88}
89
90pub(super) fn index_schema() -> Arc<Schema> {
91    Arc::new(Schema::new(vec![
92        Field::new("i0", DataType::UInt32, false),
93        Field::new("i1", DataType::UInt32, false),
94        Field::new("i2", DataType::UInt32, false),
95    ]))
96}
97
98/// One mesh's row across the three tables, in the order `mesh_schema()` lists.
99///
100/// A struct, not the 11-element tuple this was. That tuple was POSITIONAL and
101/// several slots share a type -- the four `u32` offsets/counts, and the two
102/// `Option<u32>` source ids. Measured BEFORE this branch grew its tests:
103/// swapping the two ids compiled and left 202/202 green, writing every
104/// representation-item id into the material column and back.
105///
106/// It is caught now -- `mesh_table_carries_both_source_ids_on_the_right_columns`
107/// fails on that swap. Named fields make it unreachable at the construction
108/// site as well, which is belt and braces, not a reason to drop the test: the
109/// RecordBatch arrays are still positional against the schema.
110pub(super) struct MeshRow<'a> {
111    pub express_id: u32,
112    pub ifc_type: &'a str,
113    pub v_start: u32,
114    pub vert_count: u32,
115    pub i_start: u32,
116    pub index_count: u32,
117    pub color: [f32; 4],
118    /// In the SAME Y-up frame as the emitted positions. The swap is linear, so
119    /// swap(origin + position) = swap(origin) + swap(position); see
120    /// `services::axis` for the one definition.
121    pub origin: [f64; 3],
122    pub geometry_class: u8,
123    pub geometry_item_id: Option<u32>,
124    pub material_id: Option<u32>,
125    /// `[metallic, roughness]`, NaN where unauthored (#5984).
126    pub finish: [f32; 2],
127    /// Row-major 3x3, Y-up, placing the SHARED block this row points at:
128    /// `world = origin + R * p`. Identity for a row that owns its geometry.
129    pub rotation: [f32; 9],
130}
131
132/// Where one mesh row's geometry lives and how it is placed. Separate from the
133/// mesh because on the shared-shapes layout the two come apart: the ranges belong to a
134/// shape the occurrence may only borrow, possibly one an earlier stream batch
135/// wrote (#5407), which is why the counts travel here rather than being read
136/// off a `MeshData` the row may no longer have.
137pub(super) struct RowPlacement {
138    pub v_start: u32,
139    pub vert_count: u32,
140    pub i_start: u32,
141    pub index_count: u32,
142    /// Y-up metres, already swapped by the caller.
143    pub origin: [f64; 3],
144    /// Row-major 3x3, Y-up.
145    pub rotation: [f32; 9],
146}
147
148impl<'a> MeshRow<'a> {
149    /// Build one row from an occurrence and the placement of the shape block
150    /// it draws.
151    ///
152    /// Here rather than at the call site so the field order is stated once,
153    /// next to the schema whose column order it feeds. On every unshared row,
154    /// which is every row of a `-parquet-v8` payload, the block is `mesh`'s own.
155    pub fn new(mesh: &'a crate::types::MeshData, placement: RowPlacement) -> Self {
156        Self {
157            express_id: mesh.express_id,
158            ifc_type: mesh.ifc_type.as_str(),
159            v_start: placement.v_start,
160            vert_count: placement.vert_count,
161            i_start: placement.i_start,
162            index_count: placement.index_count,
163            color: mesh.color,
164            origin: placement.origin,
165            geometry_class: mesh.geometry_class,
166            geometry_item_id: mesh.geometry_item_id,
167            material_id: mesh.material_id,
168            finish: mesh.finish_wire(),
169            rotation: placement.rotation,
170        }
171    }
172}
173
174/// The trailing per-mesh columns both transports carry, in order.
175///
176/// A FUNCTION both schemas call, not a list a test compares them against.
177/// `mesh_schema()` and the inline instance schema in `parquet_optimized.rs`
178/// were kept in step by hand and that does not hold: `benches/serialization.rs`
179/// is a third copy and has already drifted, missing `origin_x/y/z` and
180/// `geometry_class` long before #3215. Adding a column to one and not the other
181/// compiles and leaves every test green -- the TS decoder resolves by name, so
182/// the transport that lost it just omits it forever.
183///
184/// Sharing the constructor removes the drift rather than detecting it. The
185/// LEADING columns still differ legitimately (`express_id` vs `entity_id`), so
186/// only this tail is shared.
187pub(super) fn shared_trailing_fields() -> Vec<Field> {
188    vec![
189        // Per-mesh local-frame origin, in the SAME Y-up metres frame as the
190        // emitted positions (world vertex = origin + position). Zero for
191        // world-baked meshes (issue #1841).
192        Field::new("origin_x", DataType::Float64, false),
193        Field::new("origin_y", DataType::Float64, false),
194        Field::new("origin_z", DataType::Float64, false),
195        // Canonical geometry provenance (0 = occurrence, 1 = orphan type map,
196        // 2 = instanced type-library template). The viewer must NOT draw class
197        // 2 in the normal view (issue #1841).
198        Field::new("geometry_class", DataType::UInt8, false),
199        // The two DISJOINT source ids `MeshData` carries: `geometry_item_id` is
200        // the `IfcRepresentationItem` a mesh was tessellated from, `material_id`
201        // the `IfcMaterial` whose layer it slices. Never both. Parquet omitted
202        // both, so drill-to-source worked over /api/v1/parse and not over the
203        // binary transport (#3215).
204        //
205        // NOT NULLABLE, with an explicit ABSENT_SOURCE_ID sentinel -- the same
206        // choice `packages/cache/src/sections/geometry.ts` made at v14, for a
207        // sharper reason than tidiness. A nullable UInt32's values buffer is
208        // undefined at null rows, and parquet-wasm 0.7.x leaks the NEIGHBOURING
209        // row's value into it: a mesh with no material decoded as
210        // `material_id: 902`, a real-looking id for a different entity.
211        // `apps/viewer` and `packages/export` already pin ^0.7.2 while
212        // server-client's peer range is >=0.5.0, so that reader is in the tree.
213        // A sentinel in a non-nullable column has no validity bitmap to leak.
214        //
215        // 0xFFFFFFFF and not 0: `MeshData::with_style_metadata` already filters
216        // 0 to None (an OPTIONAL `IfcMaterialLayer.Material` arrives as 0), and
217        // an absence marker the domain can produce is one change from wrong.
218        Field::new("geometry_item_id", DataType::UInt32, false),
219        Field::new("material_id", DataType::UInt32, false),
220        // The IFC-authored finish (#5984), `crate::types::MeshData`'s
221        // `metallic` / `roughness`. NaN where unauthored, NOT a null, for the
222        // reason the two ids above use a sentinel: parquet-wasm 0.7.x leaks the
223        // neighbouring row's value into a nullable column's null slots, which
224        // here would hand an unauthored mesh another mesh's finish. NaN is the
225        // wasm `styleFinishes` wire's own "unauthored", and an authored value
226        // is always finite in [0, 1], so the sentinel cannot collide.
227        Field::new("metallic", DataType::Float32, false),
228        Field::new("roughness", DataType::Float32, false),
229    ]
230}
231
232/// The optimized transport's instance table.
233///
234/// Lives here, beside `mesh_schema()`, because the two share their trailing
235/// block and differ only in the leading identity columns (`entity_id` vs
236/// `express_id`). It was inline in `parquet_optimized.rs`, which is how the
237/// pair drifted by hand in the first place.
238///
239/// The `rot0..rot8` tail (issue #3575) is appended after the columns shared
240/// with `mesh_schema()` rather than folded into `shared_trailing_fields()`,
241/// because the two schemas gate it independently: this one omits it entirely
242/// when no non-identity rotation was written (wire version 2), while
243/// `mesh_schema()` always emits it from `-parquet-v6` on. It was
244/// `/optimized`-ONLY until #3888 brought rotation-aware sharing to the flat
245/// route. A row-major 3x3, in the SAME Y-up
246/// frame as `origin_x/y/z`: `world = origin + R * template_position`. Nine
247/// plain columns (not a quaternion) because the underlying transform can carry
248/// non-uniform scale/shear baked in by an `IfcCartesianTransformationOperator`,
249/// which a quaternion cannot represent losslessly; identity
250/// (`1,0,0,0,1,0,0,0,1`) for every instance the server did not verify as a
251/// rotation-safe dedup, which is exactly today's `origin`-only placement.
252///
253/// `include_rotation` is false when the writer produced no non-identity
254/// rotation at all: the table is then byte-shaped exactly as it was before
255/// #3575 and ships under wire version 2, so a client that predates the
256/// rotation columns keeps decoding it (see `optimized_wire_version` in
257/// `parquet_optimized.rs`).
258pub(super) fn instance_schema(include_rotation: bool) -> Arc<Schema> {
259    Arc::new(Schema::new(
260        vec![
261            Field::new("entity_id", DataType::UInt32, false),
262            Field::new("ifc_type", DataType::Utf8, false),
263            Field::new("mesh_index", DataType::UInt32, false),
264            Field::new("material_index", DataType::UInt32, false),
265        ]
266        .into_iter()
267        .chain(shared_trailing_fields())
268        .chain(if include_rotation { rotation_fields() } else { Vec::new() })
269        .collect::<Vec<_>>(),
270    ))
271}
272
273/// The nine row-major rotation columns appended to `instance_schema()` and, as
274/// of `-parquet-v6` (#3888), to `mesh_schema()` too (see either doc comment for
275/// the coordinate-frame contract).
276fn rotation_fields() -> Vec<Field> {
277    (0..9)
278        .map(|i| Field::new(format!("rot{i}"), DataType::Float32, false))
279        .collect()
280}