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;