ifc_lite_server/services/axis.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 ONE definition of the server wire's axis convention (issue #1841).
6//!
7//! IFC is Z-up; WebGL/glTF (and every ifc-lite client) is Y-up. Every server
8//! transport therefore emits **Y-up metres**, and this module is the single
9//! place that conversion is defined.
10//!
11//! It used to be open-coded inline in each parquet serializer and simply
12//! MISSING from the JSON route, so `POST /api/v1/parse` shipped raw IFC Z-up
13//! while the parquet endpoints shipped Y-up — the same class of silent
14//! per-transport drift as the dropped `origin`. Keep every transport routed
15//! through these helpers so the frame can never diverge again.
16
17use ifc_lite_processing::MeshData;
18
19/// Convert one position/normal triplet from IFC Z-up to WebGL Y-up:
20/// X stays, new Y = old Z (vertical), new Z = -old Y (depth).
21#[inline]
22pub fn zup_to_yup(x: f32, y: f32, z: f32) -> (f32, f32, f32) {
23 (x, z, -y)
24}
25
26/// The same swap for an f64 triplet — used for the per-mesh `origin`, which
27/// must land in the SAME frame as the positions it offsets (the swap is
28/// linear, so `swap(origin + position) == swap(origin) + swap(position)`).
29#[inline]
30pub fn zup_to_yup_f64(v: [f64; 3]) -> [f64; 3] {
31 [v[0], v[2], -v[1]]
32}
33
34/// Rewrite a mesh into the Y-up wire frame in place: positions, normals, and
35/// the per-mesh `origin` together, so the mesh stays internally consistent.
36///
37/// Used by the JSON transport, which serializes `MeshData` directly. The
38/// parquet transports apply [`zup_to_yup`] fused into their columnar extraction
39/// instead (same math, no extra pass over the vertices).
40pub fn mesh_to_yup_in_place(mesh: &mut MeshData) {
41 // Iterate only COMPLETE triplets. A malformed buffer whose length is not a
42 // multiple of 3 must not index past the end — `debug_assert` would be a
43 // no-op in release and the last partial chunk would panic the handler.
44 //
45 // Drop the partial tail rather than shipping it: a 1-2 float remainder is
46 // not a position, and leaving it would put unrotated Z-up values on the
47 // wire next to Y-up ones. Same rule as the normals below.
48 let positions_end = mesh.positions.len() / 3 * 3;
49 if positions_end != mesh.positions.len() {
50 tracing::warn!(
51 express_id = mesh.express_id,
52 ifc_type = %mesh.ifc_type,
53 positions = mesh.positions.len(),
54 "Mesh position buffer is not a whole number of triplets; dropping the partial tail"
55 );
56 mesh.positions.truncate(positions_end);
57 }
58 for i in (0..positions_end).step_by(3) {
59 let (x, y, z) = zup_to_yup(mesh.positions[i], mesh.positions[i + 1], mesh.positions[i + 2]);
60 mesh.positions[i] = x;
61 mesh.positions[i + 1] = y;
62 mesh.positions[i + 2] = z;
63 }
64
65 // Normals must end up in the SAME frame as the positions. Some pipelines
66 // (advanced_brep) emit positions with absent or mismatched normals; the
67 // parquet path emits zero normals for those rather than shipping a buffer
68 // that doesn't parallel the vertices. Match that here instead of leaving a
69 // half-rotated Z-up normal buffer on the wire — a silently wrong frame is
70 // exactly the drift this module exists to prevent. An empty buffer is the
71 // documented "client recomputes flat normals" signal.
72 if mesh.normals.len() == mesh.positions.len() {
73 for i in (0..positions_end).step_by(3) {
74 let (x, y, z) = zup_to_yup(mesh.normals[i], mesh.normals[i + 1], mesh.normals[i + 2]);
75 mesh.normals[i] = x;
76 mesh.normals[i + 1] = y;
77 mesh.normals[i + 2] = z;
78 }
79 } else if !mesh.normals.is_empty() {
80 tracing::warn!(
81 express_id = mesh.express_id,
82 ifc_type = %mesh.ifc_type,
83 positions = mesh.positions.len(),
84 normals = mesh.normals.len(),
85 "Mesh normals length mismatch; dropping normals rather than emitting a Z-up buffer"
86 );
87 mesh.normals.clear();
88 }
89
90 mesh.origin = zup_to_yup_f64(mesh.origin);
91}
92
93#[cfg(test)]
94#[path = "axis_tests.rs"]
95mod axis_tests;