ifc_lite_geometry/processors/swept/disk.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 crate::{
6 profiles::ProfileProcessor, scale_segments, Error, Mesh, Point3, Result, TessellationQuality,
7 Vector3,
8};
9use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
10
11use crate::router::GeometryProcessor;
12
13/// Two directrix samples closer than this (model units) are one point for
14/// tangent purposes: their difference has no usable direction (#5191).
15const TUBE_DEDUPE_EPS: f64 = 1e-9;
16
17/// Gate every directrix before `build_tube_rmf` (#5191). A non-finite sample
18/// is a load error: it would put NaN into every frame the RMF propagates to.
19/// Fewer than two distinct samples (including all-coincident, which would
20/// otherwise fabricate a flat disc from the end caps) sweeps nothing.
21pub(crate) fn directrix_is_sweepable(points: &[Point3<f64>]) -> Result<bool> {
22 if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite())) {
23 return Err(Error::geometry("swept solid directrix has a non-finite coordinate".to_string()));
24 }
25 Ok(points.iter().any(|p| (p - points[0]).norm() >= TUBE_DEDUPE_EPS))
26}
27
28/// Build a rotation-minimising frame (RMF) for sweeping a circular cross-section
29/// along `curve_points`. Returns `(tangents, perp1s, perp2s)`, each of length
30/// `curve_points.len()`.
31///
32/// The previous implementation re-picked the cross-section's `up` vector at
33/// every sample based on `tangent.x.abs() < 0.9`. When two consecutive tangents
34/// straddled that threshold, `up` flipped, swapping the sign of `perp1` between
35/// rings — visible as a twisted / flat-ribbon tube at sharp bends.
36///
37/// RMF instead picks `up` ONCE for the first sample, then propagates the frame
38/// by rotating it from `tangents[i-1]` onto `tangents[i]` (the minimum rotation
39/// that aligns them). When consecutive tangents are parallel the frame stays
40/// untouched.
41///
42/// Precondition: `curve_points` passed [`directrix_is_sweepable`].
43pub(crate) fn build_tube_rmf(
44 curve_points: &[Point3<f64>],
45) -> (Vec<Vector3<f64>>, Vec<Vector3<f64>>, Vec<Vector3<f64>>) {
46 let n = curve_points.len();
47 let mut tangents = Vec::with_capacity(n);
48 let mut perp1s = Vec::with_capacity(n);
49 let mut perp2s = Vec::with_capacity(n);
50 if n < 2 {
51 return (tangents, perp1s, perp2s);
52 }
53
54 // #5191: a duplicate consecutive directrix point (e.g. two composite-curve
55 // segments sharing an endpoint) makes the finite difference a zero vector,
56 // whose `.normalize()` is NaN. Difference over the distinct points (`kept`)
57 // and give each dropped duplicate its owner's tangent, so `tangents` stays
58 // parallel to `curve_points`.
59 let mut kept: Vec<Point3<f64>> = Vec::with_capacity(n);
60 let mut owner: Vec<usize> = Vec::with_capacity(n);
61 for &p in curve_points {
62 if let Some(&last) = kept.last() {
63 if (p - last).norm() < TUBE_DEDUPE_EPS {
64 owner.push(kept.len() - 1);
65 continue;
66 }
67 }
68 kept.push(p);
69 owner.push(kept.len() - 1);
70 }
71
72 let m = kept.len();
73 let kept_tangents: Vec<Vector3<f64>> = if m < 2 {
74 // Every sample coincides: no direction exists. `process` never gets
75 // here (it meshes nothing); a fixed axis keeps the frame finite.
76 vec![Vector3::new(1.0, 0.0, 0.0); m]
77 } else {
78 (0..m)
79 .map(|k| {
80 let t = if k == 0 {
81 kept[1] - kept[0]
82 } else if k == m - 1 {
83 kept[k] - kept[k - 1]
84 } else {
85 (kept[k + 1] - kept[k - 1]) / 2.0
86 };
87 // `kept` has no consecutive duplicates: the difference is non-zero.
88 t.normalize()
89 })
90 .collect()
91 };
92
93 for &o in &owner {
94 tangents.push(kept_tangents[o]);
95 }
96
97 let up0 = if tangents[0].x.abs() < 0.9 {
98 Vector3::new(1.0, 0.0, 0.0)
99 } else {
100 Vector3::new(0.0, 1.0, 0.0)
101 };
102 let mut perp1 = tangents[0].cross(&up0).normalize();
103 let mut perp2 = tangents[0].cross(&perp1).normalize();
104 perp1s.push(perp1);
105 perp2s.push(perp2);
106
107 for i in 1..n {
108 let prev = tangents[i - 1];
109 let curr = tangents[i];
110 let cos_a = prev.dot(&curr).clamp(-1.0, 1.0);
111 let axis = prev.cross(&curr);
112 let axis_norm = axis.norm();
113 // Skip rotation when tangents are (nearly) parallel — frame is preserved.
114 // Anti-parallel (cos_a ≈ -1) leaves axis ill-defined, but a 180° turn
115 // between consecutive samples on a swept-disk directrix is physically
116 // implausible; we keep the previous frame and accept the degraded case.
117 //
118 // NaN-latching hazard (#5191): NaN comparisons are false, so a NaN
119 // tangent also reads as "nearly parallel" and freezes a poisoned frame
120 // for every later ring. Finite tangents are a precondition, held by the
121 // dedupe above and by `directrix_is_sweepable` refusing non-finite points.
122 if axis_norm > 1e-9 && cos_a < 1.0 - 1e-12 {
123 let axis = axis / axis_norm;
124 let sin_a = (1.0 - cos_a * cos_a).max(0.0).sqrt();
125 // Rodrigues' rotation of `perp1` around `axis` by angle = acos(cos_a)
126 perp1 = perp1 * cos_a
127 + axis.cross(&perp1) * sin_a
128 + axis * axis.dot(&perp1) * (1.0 - cos_a);
129 perp1 = perp1.normalize();
130 perp2 = curr.cross(&perp1).normalize();
131 }
132 perp1s.push(perp1);
133 perp2s.push(perp2);
134 }
135
136 (tangents, perp1s, perp2s)
137}
138
139/// Mesh a circular (optionally annular) cross-section swept along
140/// `curve_points`: one ring of `segments` vertices per sample on the outer
141/// wall, a second ring per sample on the bore when `inner_radius` is set, and
142/// caps at both ends (discs for a rod, annuli for a tube). Returns
143/// `(positions, indices)`.
144///
145/// Every triangle is wound so its geometric normal points OUT of the material:
146/// outer wall radially outward, bore wall radially inward (toward the axis),
147/// start cap along `-t`, end cap along `+t`. The frame `(perp1, perp2, t)` is
148/// right-handed (`perp2 = t x perp1`), so a ring vertex at angle `theta` sits
149/// at `p + r(cos theta perp1 + sin theta perp2)` and CCW order in the
150/// `(perp1, perp2)` plane faces `+t`. `swept_disk_side_walls_face_outward` in
151/// `tests/swept_disk_winding_and_bore.rs` pins the sign.
152fn build_tube(
153 curve_points: &[Point3<f64>],
154 radius: f64,
155 inner_radius: Option<f64>,
156 segments: usize,
157) -> (Vec<f32>, Vec<u32>) {
158 let n = curve_points.len();
159 let seg = segments as u32;
160 let walls = if inner_radius.is_some() { 2 } else { 1 };
161 let mut positions: Vec<f32> = Vec::with_capacity(3 * (walls * n * segments + 2));
162 let mut indices: Vec<u32> = Vec::with_capacity(6 * segments * walls * n);
163
164 // Build a rotation-minimising frame across all sample points up-front.
165 // (Per-iteration `up` selection caused frame flips at sharp bends.)
166 let (_, perp1s, perp2s) = build_tube_rmf(curve_points);
167 let unit_circle: Vec<(f64, f64)> = (0..segments)
168 .map(|j| {
169 let angle = 2.0 * std::f64::consts::PI * j as f64 / segments as f64;
170 (angle.cos(), angle.sin())
171 })
172 .collect();
173
174 let push_ring = |positions: &mut Vec<f32>, i: usize, r: f64| {
175 let p = curve_points[i];
176 for &(cos, sin) in &unit_circle {
177 let vertex = p + (perp1s[i] * (r * cos) + perp2s[i] * (r * sin));
178 positions.extend_from_slice(&[vertex.x as f32, vertex.y as f32, vertex.z as f32]);
179 }
180 };
181 // Join ring `a` to ring `b`. `outward` is the outer wall's winding
182 // (ring i to ring i+1, normal away from the axis); `false` is its mirror.
183 // The bore wall and both annular caps (outer ring to bore ring) reuse it.
184 let push_strip = |indices: &mut Vec<u32>, a: u32, b: u32, outward: bool| {
185 for j in 0..seg {
186 let j_next = (j + 1) % seg;
187 if outward {
188 indices.extend_from_slice(&[a + j, b + j_next, b + j]);
189 indices.extend_from_slice(&[a + j, a + j_next, b + j_next]);
190 } else {
191 indices.extend_from_slice(&[a + j, b + j, b + j_next]);
192 indices.extend_from_slice(&[a + j, b + j_next, a + j_next]);
193 }
194 }
195 };
196
197 let ring = |i: usize| (i * segments) as u32;
198 for i in 0..n {
199 push_ring(&mut positions, i, radius);
200 }
201 for i in 0..n - 1 {
202 push_strip(&mut indices, ring(i), ring(i + 1), true);
203 }
204
205 match inner_radius {
206 Some(inner) => {
207 // Bore rings follow the outer rings, same sample order.
208 let bore = |i: usize| ring(n + i);
209 for i in 0..n {
210 push_ring(&mut positions, i, inner);
211 }
212 for i in 0..n - 1 {
213 push_strip(&mut indices, bore(i), bore(i + 1), false);
214 }
215 // Annular caps: start faces -t, end faces +t.
216 push_strip(&mut indices, ring(0), bore(0), false);
217 push_strip(&mut indices, ring(n - 1), bore(n - 1), true);
218 }
219 None => {
220 // Disc caps fanned from the sample point on the axis.
221 let (c0, c1) = (ring(n), ring(n) + 1);
222 for p in [curve_points[0], curve_points[n - 1]] {
223 positions.extend_from_slice(&[p.x as f32, p.y as f32, p.z as f32]);
224 }
225 let (o0, o1) = (ring(0), ring(n - 1));
226 for j in 0..seg {
227 let jn = (j + 1) % seg;
228 indices.extend_from_slice(&[c0, o0 + jn, o0 + j]); // start cap faces -t
229 indices.extend_from_slice(&[c1, o1 + j, o1 + jn]); // end cap faces +t
230 }
231 }
232 }
233
234 (positions, indices)
235}
236
237/// SweptDiskSolid processor
238/// Handles IfcSweptDiskSolid - sweeps a circular profile along a curve
239pub struct SweptDiskSolidProcessor {
240 profile_processor: ProfileProcessor,
241}
242
243impl SweptDiskSolidProcessor {
244 pub fn new(schema: IfcSchema) -> Self {
245 Self {
246 profile_processor: ProfileProcessor::new(schema),
247 }
248 }
249}
250
251impl GeometryProcessor for SweptDiskSolidProcessor {
252 fn process(
253 &self,
254 entity: &DecodedEntity,
255 decoder: &mut EntityDecoder,
256 _schema: &IfcSchema,
257 quality: TessellationQuality,
258 ) -> Result<Mesh> {
259 // IfcSweptDiskSolid attributes:
260 // 0: Directrix (IfcCurve) - the path to sweep along
261 // 1: Radius (IfcPositiveLengthMeasure) - outer radius
262 // 2: InnerRadius (optional) - inner radius for hollow tubes
263 // 3: StartParam (optional)
264 // 4: EndParam (optional)
265
266 let directrix_attr = entity
267 .get(0)
268 .ok_or_else(|| Error::geometry("SweptDiskSolid missing Directrix".to_string()))?;
269
270 let radius = entity
271 .get_float(1)
272 .ok_or_else(|| Error::geometry("SweptDiskSolid missing Radius".to_string()))?;
273
274 // InnerRadius (optional): a hollow tube's bore. Only a bore strictly
275 // inside the outer wall bounds an annulus; a non-positive, non-finite,
276 // or at-or-past-`radius` value cannot, and the sweep meshes as a rod.
277 let inner_radius = entity
278 .get_float(2)
279 .filter(|&r| r.is_finite() && r > 0.0 && r < radius);
280
281 // StartParam / EndParam (optional IfcParameterValue), in the directrix's
282 // own IFC parametrisation. Without honoring these, files that intend e.g.
283 // only the first leg to be swept render every segment — the common rebar
284 // case where a 2 m bar reads as 12 m with hooks unfolded.
285 let start_param = entity.get_float(3);
286 let end_param = entity.get_float(4);
287
288 let directrix = decoder
289 .resolve_ref(directrix_attr)?
290 .ok_or_else(|| Error::geometry("Failed to resolve Directrix".to_string()))?;
291 let curve_points = self.profile_processor.get_directrix_points(
292 &directrix,
293 decoder,
294 start_param,
295 end_param,
296 quality,
297 )?;
298
299 if !directrix_is_sweepable(&curve_points)? {
300 return Ok(Mesh::new());
301 }
302
303 // Generate tube mesh by sweeping circle along curve
304 // 24 segments around the circle at Medium; scaled by quality.
305 let segments = scale_segments(24, 8, 96, quality);
306 let (positions, indices) = build_tube(&curve_points, radius, inner_radius, segments);
307
308 let mut mesh = Mesh {
309 positions,
310 normals: Vec::new(),
311 indices,
312 rtc_applied: false,
313 origin: [0.0; 3],
314 welded_in_object_frame: false,
315 plane_tags: None,
316 instance_meta: None, local_bounds: None, local_to_world: None };
317
318 // Ship smooth per-vertex normals, computed here in the directrix-local
319 // frame where the coordinates are small (0..directrix-length) and so
320 // precise. Without this the swept-disk mesh carried empty normals and
321 // downstream consumers recomputed them from world-space f32 positions.
322 // At a georef-scale placement (national-grid rebar sits ~6 km from the
323 // origin) the edge differences `v1 - v0` cancel catastrophically — the
324 // tube renders as a field of specular sparkles. A round tube wants
325 // smooth (area-weighted) normals, unlike the crease-heavy revolved
326 // solid which is flat-shaded. (#1164)
327 crate::calculate_normals(&mut mesh);
328
329 Ok(mesh)
330 }
331
332 fn supported_types(&self) -> Vec<IfcType> {
333 vec![IfcType::IfcSweptDiskSolid]
334 }
335}
336
337impl Default for SweptDiskSolidProcessor {
338 fn default() -> Self {
339 Self::new(IfcSchema::new())
340 }
341}
342
343#[cfg(test)]
344#[path = "disk_tests.rs"]
345mod tests;