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