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/// Build a rotation-minimising frame (RMF) for sweeping a circular cross-section
14/// along `curve_points`. Returns `(tangents, perp1s, perp2s)`, each of length
15/// `curve_points.len()`.
16///
17/// The previous implementation re-picked the cross-section's `up` vector at
18/// every sample based on `tangent.x.abs() < 0.9`. When two consecutive tangents
19/// straddled that threshold, `up` flipped, swapping the sign of `perp1` between
20/// rings — visible as a twisted / flat-ribbon tube at sharp bends.
21///
22/// RMF instead picks `up` ONCE for the first sample, then propagates the frame
23/// by rotating it from `tangents[i-1]` onto `tangents[i]` (the minimum rotation
24/// that aligns them). When consecutive tangents are parallel the frame stays
25/// untouched.
26pub(crate) fn build_tube_rmf(
27 curve_points: &[Point3<f64>],
28) -> (Vec<Vector3<f64>>, Vec<Vector3<f64>>, Vec<Vector3<f64>>) {
29 let n = curve_points.len();
30 let mut tangents = Vec::with_capacity(n);
31 let mut perp1s = Vec::with_capacity(n);
32 let mut perp2s = Vec::with_capacity(n);
33 if n < 2 {
34 return (tangents, perp1s, perp2s);
35 }
36
37 for i in 0..n {
38 let t = if i == 0 {
39 (curve_points[1] - curve_points[0]).normalize()
40 } else if i == n - 1 {
41 (curve_points[i] - curve_points[i - 1]).normalize()
42 } else {
43 ((curve_points[i + 1] - curve_points[i - 1]) / 2.0).normalize()
44 };
45 tangents.push(t);
46 }
47
48 let up0 = if tangents[0].x.abs() < 0.9 {
49 Vector3::new(1.0, 0.0, 0.0)
50 } else {
51 Vector3::new(0.0, 1.0, 0.0)
52 };
53 let mut perp1 = tangents[0].cross(&up0).normalize();
54 let mut perp2 = tangents[0].cross(&perp1).normalize();
55 perp1s.push(perp1);
56 perp2s.push(perp2);
57
58 for i in 1..n {
59 let prev = tangents[i - 1];
60 let curr = tangents[i];
61 let cos_a = prev.dot(&curr).clamp(-1.0, 1.0);
62 let axis = prev.cross(&curr);
63 let axis_norm = axis.norm();
64 // Skip rotation when tangents are (nearly) parallel — frame is preserved.
65 // Anti-parallel (cos_a ≈ -1) leaves axis ill-defined, but a 180° turn
66 // between consecutive samples on a swept-disk directrix is physically
67 // implausible; we keep the previous frame and accept the degraded case.
68 if axis_norm > 1e-9 && cos_a < 1.0 - 1e-12 {
69 let axis = axis / axis_norm;
70 let sin_a = (1.0 - cos_a * cos_a).max(0.0).sqrt();
71 // Rodrigues' rotation of `perp1` around `axis` by angle = acos(cos_a)
72 perp1 = perp1 * cos_a
73 + axis.cross(&perp1) * sin_a
74 + axis * axis.dot(&perp1) * (1.0 - cos_a);
75 perp1 = perp1.normalize();
76 perp2 = curr.cross(&perp1).normalize();
77 }
78 perp1s.push(perp1);
79 perp2s.push(perp2);
80 }
81
82 (tangents, perp1s, perp2s)
83}
84
85/// SweptDiskSolid processor
86/// Handles IfcSweptDiskSolid - sweeps a circular profile along a curve
87pub struct SweptDiskSolidProcessor {
88 profile_processor: ProfileProcessor,
89}
90
91impl SweptDiskSolidProcessor {
92 pub fn new(schema: IfcSchema) -> Self {
93 Self {
94 profile_processor: ProfileProcessor::new(schema),
95 }
96 }
97}
98
99impl GeometryProcessor for SweptDiskSolidProcessor {
100 fn process(
101 &self,
102 entity: &DecodedEntity,
103 decoder: &mut EntityDecoder,
104 _schema: &IfcSchema,
105 quality: TessellationQuality,
106 ) -> Result<Mesh> {
107 // IfcSweptDiskSolid attributes:
108 // 0: Directrix (IfcCurve) - the path to sweep along
109 // 1: Radius (IfcPositiveLengthMeasure) - outer radius
110 // 2: InnerRadius (optional) - inner radius for hollow tubes
111 // 3: StartParam (optional)
112 // 4: EndParam (optional)
113
114 let directrix_attr = entity
115 .get(0)
116 .ok_or_else(|| Error::geometry("SweptDiskSolid missing Directrix".to_string()))?;
117
118 let radius = entity
119 .get_float(1)
120 .ok_or_else(|| Error::geometry("SweptDiskSolid missing Radius".to_string()))?;
121
122 // Get inner radius if hollow
123 let _inner_radius = entity.get_float(2);
124
125 // StartParam / EndParam (optional IfcParameterValue). Per IFC spec, when the
126 // directrix is an IfcCompositeCurve the curve is parameterised so that segment
127 // index `i` covers parameter range [i, i+1]. Without honoring these, files that
128 // intend e.g. only the first segment to be swept render every segment — the
129 // common rebar case where a 2 m bar reads as 12 m with hooks unfolded.
130 let start_param = entity.get_float(3);
131 let end_param = entity.get_float(4);
132
133 // Resolve the directrix curve
134 let directrix = decoder
135 .resolve_ref(directrix_attr)?
136 .ok_or_else(|| Error::geometry("Failed to resolve Directrix".to_string()))?;
137
138 // Get points along the curve, honoring trim parameters where the directrix's
139 // parameterisation is well-defined and obvious from the entity:
140 // - IfcCompositeCurve (and IfcCompositeCurveOnSurface): segment-index based,
141 // each segment contributes 1.0 to the parameter.
142 // - IfcPolyline: point-index based, each segment between consecutive points
143 // contributes 1.0 to the parameter.
144 // - IfcLine: linearly parameterised P(u) = Pnt + u·V, so StartParam/EndParam
145 // map straight onto the segment endpoints.
146 // Other directrix types (IfcCircle, IfcBSplineCurve) have angle-/knot-based
147 // parameterisations and fall back to the full sampler. An IfcTrimmedCurve
148 // directrix is sampled over its own Trim1/Trim2 by get_curve_points (a
149 // trimmed IfcLine retains full 3D); a file's redundant solid-level
150 // StartParam/EndParam are then a no-op. Files using a raw circle/spline
151 // directrix with explicit StartParam/EndParam still render the full curve —
152 // flagged as a known limitation.
153 // The lower-level trimmed samplers below don't take a `quality`
154 // argument; set it on the profile processor so any arcs they sample
155 // honour the requested detail level.
156 self.profile_processor.set_tessellation_quality(quality);
157 let has_trim = start_param.is_some() || end_param.is_some();
158 let curve_points = if has_trim
159 && directrix.ifc_type.is_subtype_of(IfcType::IfcCompositeCurve)
160 {
161 self.profile_processor
162 .get_composite_curve_points_trimmed(
163 &directrix,
164 decoder,
165 start_param,
166 end_param,
167 )?
168 } else if has_trim && directrix.ifc_type == IfcType::IfcPolyline {
169 self.profile_processor
170 .get_polyline_points_trimmed(&directrix, decoder, start_param, end_param)?
171 } else if has_trim && directrix.ifc_type == IfcType::IfcLine {
172 // A bare IfcLine directrix is parameterised as P(u) = Pnt + u·V, so the
173 // solid's StartParam/EndParam map straight onto the segment endpoints.
174 // Without this the line samples over its unit range [0,1] only and the
175 // swept extent collapses to the (tool-emitted) vector magnitude.
176 self.profile_processor.get_line_points_3d(
177 &directrix,
178 decoder,
179 start_param.unwrap_or(0.0),
180 end_param.unwrap_or(1.0),
181 )?
182 } else {
183 self.profile_processor
184 .get_curve_points(&directrix, decoder, quality)?
185 };
186
187 if curve_points.len() < 2 {
188 return Ok(Mesh::new()); // Not enough points
189 }
190
191 // Generate tube mesh by sweeping circle along curve
192 // 24 segments around the circle at Medium; scaled by quality.
193 let segments = scale_segments(24, 8, 96, quality);
194 let mut positions = Vec::new();
195 let mut indices = Vec::new();
196
197 // Build a rotation-minimising frame across all sample points up-front.
198 // (Per-iteration `up` selection caused frame flips at sharp bends.)
199 let (_, perp1s, perp2s) = build_tube_rmf(&curve_points);
200
201 // For each point on the curve, create a ring of vertices
202 for i in 0..curve_points.len() {
203 let p = curve_points[i];
204 let perp1 = perp1s[i];
205 let perp2 = perp2s[i];
206
207 // Create ring of vertices
208 for j in 0..segments {
209 let angle = 2.0 * std::f64::consts::PI * j as f64 / segments as f64;
210 let offset = perp1 * (radius * angle.cos()) + perp2 * (radius * angle.sin());
211 let vertex = p + offset;
212
213 positions.push(vertex.x as f32);
214 positions.push(vertex.y as f32);
215 positions.push(vertex.z as f32);
216 }
217
218 // Create triangles connecting this ring to the next
219 if i < curve_points.len() - 1 {
220 let base = (i * segments) as u32;
221 let next_base = ((i + 1) * segments) as u32;
222
223 for j in 0..segments {
224 let j_next = (j + 1) % segments;
225
226 // Two triangles per quad
227 indices.push(base + j as u32);
228 indices.push(next_base + j as u32);
229 indices.push(next_base + j_next as u32);
230
231 indices.push(base + j as u32);
232 indices.push(next_base + j_next as u32);
233 indices.push(base + j_next as u32);
234 }
235 }
236 }
237
238 // Add end caps
239 // Start cap
240 let center_idx = (positions.len() / 3) as u32;
241 let start = curve_points[0];
242 positions.push(start.x as f32);
243 positions.push(start.y as f32);
244 positions.push(start.z as f32);
245
246 for j in 0..segments {
247 let j_next = (j + 1) % segments;
248 indices.push(center_idx);
249 indices.push(j_next as u32);
250 indices.push(j as u32);
251 }
252
253 // End cap
254 let end_center_idx = (positions.len() / 3) as u32;
255 let end_base = ((curve_points.len() - 1) * segments) as u32;
256 let end = curve_points[curve_points.len() - 1];
257 positions.push(end.x as f32);
258 positions.push(end.y as f32);
259 positions.push(end.z as f32);
260
261 for j in 0..segments {
262 let j_next = (j + 1) % segments;
263 indices.push(end_center_idx);
264 indices.push(end_base + j as u32);
265 indices.push(end_base + j_next as u32);
266 }
267
268 let mut mesh = Mesh {
269 positions,
270 normals: Vec::new(),
271 indices,
272 rtc_applied: false,
273 origin: [0.0; 3],
274 instance_meta: None, local_bounds: None, local_to_world: None };
275
276 // Ship smooth per-vertex normals, computed here in the directrix-local
277 // frame where the coordinates are small (0..directrix-length) and so
278 // precise. Without this the swept-disk mesh carried empty normals and
279 // downstream consumers recomputed them from world-space f32 positions.
280 // At a georef-scale placement (national-grid rebar sits ~6 km from the
281 // origin) the edge differences `v1 - v0` cancel catastrophically — the
282 // tube renders as a field of specular sparkles. A round tube wants
283 // smooth (area-weighted) normals, unlike the crease-heavy revolved
284 // solid which is flat-shaded. (#1164)
285 crate::calculate_normals(&mut mesh);
286
287 Ok(mesh)
288 }
289
290 fn supported_types(&self) -> Vec<IfcType> {
291 vec![IfcType::IfcSweptDiskSolid]
292 }
293}
294
295impl Default for SweptDiskSolidProcessor {
296 fn default() -> Self {
297 Self::new(IfcSchema::new())
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn rmf_is_constant_on_a_straight_line() {
307 // Three collinear samples → tangents identical → frame must not change.
308 let pts = vec![
309 Point3::new(0.0, 0.0, 0.0),
310 Point3::new(1.0, 0.0, 0.0),
311 Point3::new(2.0, 0.0, 0.0),
312 ];
313 let (tangents, perp1s, perp2s) = build_tube_rmf(&pts);
314 assert_eq!(tangents.len(), 3);
315 for i in 1..3 {
316 assert!((tangents[i] - tangents[0]).norm() < 1e-9);
317 assert!((perp1s[i] - perp1s[0]).norm() < 1e-9);
318 assert!((perp2s[i] - perp2s[0]).norm() < 1e-9);
319 }
320 }
321
322 #[test]
323 fn rmf_does_not_flip_at_sharp_bends() {
324 // L-shape (0,0,0) → (1,0,0) → (1,1,0). The previous implementation
325 // re-picked `up` per cross-section based on `tangent.x.abs() < 0.9`:
326 // at i=0 tangent is +X (|x|=1, picks up=Y) → perp1 = +Z; at i=1 the
327 // midpoint tangent is (1/√2, 1/√2, 0) (|x|≈0.71 < 0.9, picks up=X)
328 // → perp1 = -Z. The sign flip mirrors the cross-section ring and
329 // produces a twisted/flat-ribbon tube. RMF must propagate +Z through.
330 let pts = vec![
331 Point3::new(0.0, 0.0, 0.0),
332 Point3::new(1.0, 0.0, 0.0),
333 Point3::new(1.0, 1.0, 0.0),
334 ];
335 let (_, perp1s, _) = build_tube_rmf(&pts);
336 assert_eq!(perp1s.len(), 3);
337 for (i, p) in perp1s.iter().enumerate() {
338 assert!(
339 p.z > 0.5,
340 "perp1 at i={i} flipped or rotated out of +Z half-space: {p:?}"
341 );
342 }
343 }
344
345 #[test]
346 fn rmf_handles_degenerate_inputs() {
347 let empty: Vec<Point3<f64>> = Vec::new();
348 let (t, p1, p2) = build_tube_rmf(&empty);
349 assert!(t.is_empty() && p1.is_empty() && p2.is_empty());
350
351 let single = vec![Point3::new(0.0, 0.0, 0.0)];
352 let (t, p1, p2) = build_tube_rmf(&single);
353 assert!(t.is_empty() && p1.is_empty() && p2.is_empty());
354 }
355}