Skip to main content

ifc_lite_processing/symbolic/
primitives.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
5use super::output_cap_types::SymbolicTruncation;
6use serde::{Deserialize, Serialize};
7
8// ────────────────────────────────────────────────────────────────────────────
9// NaN sentinel <-> JSON `null`.
10//
11// Several scalars below use `f32::NAN` as a SENTINEL meaning "never resolved"
12// — `world_y` when the placement chain yields no elevation, and
13// `hatch_angle_secondary` when a fill carries no cross-hatch. The whole point
14// of that sentinel is that "unresolved" must not read as `0.0`, which is a
15// perfectly real elevation / angle.
16//
17// JSON has no NaN. `serde_json` already WRITES a non-finite `f32` as `null`,
18// but the derived `Deserialize` could not READ it back: `invalid type: null,
19// expected f32`. So every JSON hop destroyed the sentinel — most sharply in
20// `apps/server/src/routes/parse/cache_keys.rs`, where the symbolic cache is
21// re-read with `from_slice(..).unwrap_or_else(|_| SymbolicData::default())`,
22// meaning ONE unresolved scalar made the entire cached blob unreadable and
23// every replayed request served no symbolic data at all.
24//
25// `nan_as_null` fixes the READ side only. Its serialize half emits exactly
26// what `serde_json` already emitted (`null` for NaN, the plain number
27// otherwise), so no finite value — `0.0` included — changes shape on the
28// wire. `null` and `0` stay distinct, and an OMITTED key stays a hard
29// deserialization error rather than a third spelling of "unresolved".
30// ────────────────────────────────────────────────────────────────────────────
31
32/// `serialize_with`/`deserialize_with` pair mapping the `f32::NAN`
33/// "unresolved" sentinel to and from JSON `null`.
34///
35/// Read back, any `null` becomes `f32::NAN`. `serde_json` also writes `±inf`
36/// as `null`, so an infinity round-trips as NaN — `operator.rs`'s unchecked
37/// `as_float().unwrap_or(0.0)` on a placement's Z can make `tz`, and so
38/// `world_y`, infinite, and NaN is the correct reading of "not a usable
39/// elevation" either way. Consumers must test `is_nan()`, not `!is_finite()`.
40pub(crate) mod nan_as_null {
41    use serde::{Deserialize, Deserializer, Serializer};
42
43    pub(crate) fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
44    where
45        S: Serializer,
46    {
47        if value.is_nan() {
48            serializer.serialize_none()
49        } else {
50            serializer.serialize_f32(*value)
51        }
52    }
53
54    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
55    where
56        D: Deserializer<'de>,
57    {
58        Ok(Option::<f32>::deserialize(deserializer)?.unwrap_or(f32::NAN))
59    }
60}
61
62// ────────────────────────────────────────────────────────────────────────────
63// Pure-Rust serializable primitive types. The wasm-bindgen wrappers in
64// `rust/wasm-bindings/src/zero_copy.rs` are thin views over these.
65// ────────────────────────────────────────────────────────────────────────────
66
67/// A single 2D polyline for symbolic representations.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SymbolicPolyline {
70    /// Express ID of the IFC entity that authored the curve.
71    pub express_id: u32,
72    /// Owning element's IFC type name.
73    pub ifc_type: String,
74    /// Flat 2D points `[x0, y0, x1, y1, …]` in metres.
75    pub points: Vec<f32>,
76    /// True if the curve is a closed loop.
77    pub closed: bool,
78    /// World-Y elevation captured from the placement chain or the
79    /// polyline's own 3D `IfcCartesianPoint` Z component. `f32::NAN` when the
80    /// elevation could not be resolved — distinct from a genuine `0.0`, and
81    /// spelled `null` on the JSON wire (see [`nan_as_null`]).
82    #[serde(with = "nan_as_null")]
83    pub world_y: f32,
84    /// Representation identifier (`Plan`, `Annotation`, `FootPrint`, `Axis`).
85    pub representation: String,
86}
87
88/// A single 2D circle / arc for symbolic representations.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SymbolicCircle {
91    pub express_id: u32,
92    pub ifc_type: String,
93    pub center_x: f32,
94    pub center_y: f32,
95    pub radius: f32,
96    /// World-Y elevation (see [`SymbolicPolyline::world_y`]).
97    #[serde(with = "nan_as_null")]
98    pub world_y: f32,
99    /// Start angle in radians (0 for full circle).
100    pub start_angle: f32,
101    /// End angle in radians (`TAU` for full circle).
102    pub end_angle: f32,
103    pub representation: String,
104}
105
106impl SymbolicCircle {
107    /// Full-circle constructor.
108    pub fn full(
109        express_id: u32,
110        ifc_type: String,
111        center_x: f32,
112        center_y: f32,
113        radius: f32,
114        world_y: f32,
115        representation: String,
116    ) -> Self {
117        Self {
118            express_id,
119            ifc_type,
120            center_x,
121            center_y,
122            radius,
123            world_y,
124            start_angle: 0.0,
125            end_angle: std::f32::consts::TAU,
126            representation,
127        }
128    }
129}
130
131/// A 2D text annotation (`IfcTextLiteral` / `IfcTextLiteralWithExtent`).
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SymbolicText {
134    pub express_id: u32,
135    pub ifc_type: String,
136    /// Anchor point on the text baseline (model units).
137    pub x: f32,
138    pub y: f32,
139    /// Baseline orientation as a `(cos, sin)` pair. Defaults to `(1, 0)`.
140    pub dir_x: f32,
141    pub dir_y: f32,
142    /// Font height in model units (already unit-scaled).
143    pub height: f32,
144    /// UTF-8 text content, ALREADY DECODED. It is read through
145    /// `AttributeValue::from_token`, which un-doubles `''` and runs
146    /// `decode_ifc_string` (`\X2\…\X0\`, `\X\NN`, `\S\X`, `\\`) at the parse
147    /// boundary (#2394) — so consumers must NOT decode it again. A second
148    /// decode is not idempotent: it collapses `\\` twice, turning an authored
149    /// `\\server\share` into `\server\share`.
150    pub content: String,
151    /// IFC `BoxAlignment` (`top-left`, `center`, `bottom-right`, …). Empty
152    /// string when absent.
153    pub alignment: String,
154    /// World-Y elevation (see [`SymbolicPolyline::world_y`]).
155    #[serde(with = "nan_as_null")]
156    pub world_y: f32,
157    /// sRGB straight-alpha colour `[r, g, b, a]`. Defaults to dark-grey
158    /// when no IfcStyledItem chain resolves a colour.
159    pub color: [f32; 4],
160    /// Per-instance target screen-pixel cap height. `0.0` = renderer
161    /// global default (~14 px for body text).
162    pub target_px: f32,
163    pub representation: String,
164}
165
166/// A 2D filled region (`IfcAnnotationFillArea`).
167///
168/// Outer ring + optional inner rings (holes) packed into a single `points`
169/// buffer. `holes_offsets[i]` is the vertex index where hole `i` begins —
170/// outer ring spans `[0, holes_offsets[0])` (or all points when no holes).
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct SymbolicFillArea {
173    pub express_id: u32,
174    pub ifc_type: String,
175    /// All ring vertices: outer ring first, then each hole back-to-back.
176    /// Format: `[x0, y0, x1, y1, …]`.
177    pub points: Vec<f32>,
178    /// Inclusive prefix of where each hole begins (in vertex indices).
179    pub holes_offsets: Vec<u32>,
180    /// Fill colour sRGB, 0..1. Defaults to opaque black.
181    pub fill_color: [f32; 4],
182    /// Whether this fill carries a hatching style.
183    pub has_hatching: bool,
184    pub hatch_spacing: f32,
185    pub hatch_angle: f32,
186    /// Secondary cross-hatch angle. NaN if absent — `null` on the JSON wire
187    /// (see [`nan_as_null`]), which is NOT the same as a genuine `0.0` angle.
188    #[serde(with = "nan_as_null")]
189    pub hatch_angle_secondary: f32,
190    pub hatch_line_width: f32,
191    /// World-Y elevation (see [`SymbolicPolyline::world_y`]).
192    #[serde(with = "nan_as_null")]
193    pub world_y: f32,
194    pub representation: String,
195}
196
197/// A single `IfcGridAxis` tag + axis curve (server-friendly endpoint-pair
198/// representation; the wasm pipeline emits the same data via
199/// [`SymbolicPolyline`] axis lines and [`SymbolicText`] bubbles, both of
200/// which are also populated below).
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SymbolicGridAxis {
203    pub express_id: u32,
204    pub grid_express_id: u32,
205    pub tag: String,
206    /// Endpoint pair `[x0, y0, x1, y1]` in metres (plan view).
207    pub endpoints: [f32; 4],
208    /// World-Y elevation (see [`SymbolicPolyline::world_y`]).
209    #[serde(with = "nan_as_null")]
210    pub world_y: f32,
211}
212
213/// Server-friendly summary of the IFC's 2D symbol data.
214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
215pub struct SymbolicData {
216    /// Axis endpoints for every `IfcGridAxis` (compact summary shape).
217    pub grid_axes: Vec<SymbolicGridAxis>,
218    /// All polylines (`IfcPolyline`, `IfcIndexedPolyCurve`, `IfcEllipse`
219    /// tessellations, `IfcTrimmedCurve` arcs, grid axis lines).
220    pub polylines: Vec<SymbolicPolyline>,
221    /// All circles (`IfcCircle` full disks).
222    pub circles: Vec<SymbolicCircle>,
223    /// All text annotations (`IfcTextLiteral`, grid bubble outlines + tags).
224    pub texts: Vec<SymbolicText>,
225    /// All filled regions (`IfcAnnotationFillArea`).
226    pub fills: Vec<SymbolicFillArea>,
227    /// Set when extraction stopped at [`MAX_SYMBOLIC_ELEMENTS`]; `None` when the
228    /// file was emitted in full.
229    ///
230    /// `#[serde(default)]` so a `SymbolicData` cached before this field existed
231    /// still deserializes (`apps/server/src/routes/parse/cache_keys.rs` reads
232    /// cached JSON), and `skip_serializing_if` so an untruncated response is
233    /// byte-identical to what it was before.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub truncated: Option<SymbolicTruncation>,
236}
237
238
239impl SymbolicData {
240    /// Total primitives across every collection.
241    pub(super) fn len(&self) -> usize {
242        self.grid_axes.len()
243            + self.polylines.len()
244            + self.circles.len()
245            + self.texts.len()
246            + self.fills.len()
247    }
248
249    /// Returns true if no symbolic primitives were extracted — the server
250    /// can omit the field from its response instead of emitting an empty
251    /// object.
252    pub fn is_empty(&self) -> bool {
253        self.grid_axes.is_empty()
254            && self.polylines.is_empty()
255            && self.circles.is_empty()
256            && self.texts.is_empty()
257            && self.fills.is_empty()
258            // A truncated result is never "empty", even when it carries no
259            // primitives. `apps/server/src/types/response.rs` uses this for
260            // `skip_serializing_if`, so returning true here would drop the
261            // diagnostic on exactly the response that needs it most. Not
262            // reachable from extraction under the shipped bounds, but it
263            // round-trips in from cached JSON.
264            && self.truncated.is_none()
265    }
266}