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;