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}