ifc_lite_geometry/router/transforms/
linear.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
5//! IfcLinearPlacement resolution (#859): sample the basis curve at the authored distance.
6
7use super::super::GeometryRouter;
8use super::walk::PlacementWalk;
9use crate::alignment::AlignmentCurve;
10use crate::alignment_arc_length::ArcLengthMap;
11use crate::profiles::ProfileProcessor;
12use crate::{Point3, Result, TessellationQuality, Vector3};
13use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
14use nalgebra::Matrix4;
15
16impl GeometryRouter {
17    /// Resolve `IfcLinearPlacement` into a 4×4 transform: the authored
18    /// `CartesianPosition` when the exporter baked one, otherwise by
19    /// sampling the referenced basis curve at the authored `DistanceAlong`.
20    /// Falls back gracefully when neither resolves; never panics.
21    ///
22    /// Output transform: the origin is the curve sample plus
23    /// `lateral*right + vertical*up + longitudinal*tangent`. Basis is
24    /// (tangent, right, up) with `up = (0, 0, 1)` and `right = up cross tangent`.
25    /// When the tangent is (nearly) vertical the frame degenerates and falls
26    /// back to identity rotation about the sampled origin.
27    pub(super) fn resolve_linear_placement_with_depth(
28        &self,
29        placement: &DecodedEntity,
30        decoder: &mut EntityDecoder,
31        depth: usize,
32    ) -> Result<PlacementWalk> {
33        // PlacementRelTo (attr 0) composes the same way IfcLocalPlacement does,
34        // truncation included: a parent walk cut short by the depth guard makes
35        // this result depth-dependent too, so it must not be memoised. #3012
36        let parent = match placement.get(0) {
37            Some(parent_attr) if !parent_attr.is_null() => {
38                match decoder.resolve_ref(parent_attr)? {
39                    Some(p) => self.get_placement_transform_with_depth(&p, decoder, depth + 1)?,
40                    None => PlacementWalk::complete(Matrix4::identity()),
41                }
42            }
43            _ => PlacementWalk::complete(Matrix4::identity()),
44        };
45
46        // Prefer the authored CartesianPosition (attr 2) when the exporter
47        // supplied one: it is the exact pre-computed placement. Sample the
48        // curve only when no authored position exists (an `IfcGradientCurve`
49        // is then evaluated with its vertical profile, see `gradient.rs`).
50        let local = self
51            .linear_placement_local(placement, decoder)
52            .unwrap_or_else(Matrix4::identity);
53
54        Ok(PlacementWalk { transform: parent.transform * local, truncated: parent.truncated })
55    }
56
57    /// The frame an `IfcLinearPlacement` adds to its `PlacementRelTo`: the
58    /// authored `CartesianPosition`, else the sampled basis curve. File units.
59    fn linear_placement_local(
60        &self,
61        placement: &DecodedEntity,
62        decoder: &mut EntityDecoder,
63    ) -> Option<Matrix4<f64>> {
64        self.try_resolve_cartesian_position(placement, decoder)
65            .or_else(|| self.try_resolve_axis2_placement_linear(placement, decoder))
66    }
67
68    /// Strict column-major local frame of an `IfcLinearPlacement` relative to
69    /// its `PlacementRelTo`, exactly as rendering composes it, in file units.
70    /// Unlike rendering there is no identity fallback: a writer baking this
71    /// frame must refuse a placement it cannot resolve (#7335).
72    pub fn resolve_linear_placement_local_strict(
73        &self,
74        placement: &DecodedEntity,
75        decoder: &mut EntityDecoder,
76    ) -> Result<[f64; 16]> {
77        if placement.ifc_type != IfcType::IfcLinearPlacement {
78            return Err(crate::Error::geometry(format!(
79                "#{} is not an IfcLinearPlacement",
80                placement.id
81            )));
82        }
83        let local = self.linear_placement_local(placement, decoder).ok_or_else(|| {
84            crate::Error::geometry(format!(
85                "IfcLinearPlacement #{} cannot be resolved",
86                placement.id
87            ))
88        })?;
89        let mut result = [0.0; 16];
90        result.copy_from_slice(local.as_slice());
91        Ok(result)
92    }
93
94    /// Decode `IfcLinearPlacement.RelativePlacement` → sample the basis
95    /// curve → build the local transform. Returns `None` if any required
96    /// piece is missing so the caller can fall back to `CartesianPosition`.
97    fn try_resolve_axis2_placement_linear(
98        &self,
99        placement: &DecodedEntity,
100        decoder: &mut EntityDecoder,
101    ) -> Option<Matrix4<f64>> {
102        let rel_attr = placement.get(1)?;
103        if rel_attr.is_null() {
104            return None;
105        }
106        let rel = decoder.resolve_ref(rel_attr).ok().flatten()?;
107        if rel.ifc_type != IfcType::IfcAxis2PlacementLinear {
108            return None;
109        }
110
111        // IfcAxis2PlacementLinear: 0 Location (IfcPointByDistanceExpression),
112        //                          1 Axis (IfcDirection, optional, default up),
113        //                          2 RefDirection (IfcDirection, optional).
114        let location_attr = rel.get(0)?;
115        if location_attr.is_null() {
116            return None;
117        }
118        let location = decoder.resolve_ref(location_attr).ok().flatten()?;
119        if location.ifc_type != IfcType::IfcPointByDistanceExpression {
120            return None;
121        }
122
123        // IfcPointByDistanceExpression: 0 DistanceAlong (IfcLengthMeasure),
124        //                               1 OffsetLateral (optional),
125        //                               2 OffsetVertical (optional),
126        //                               3 OffsetLongitudinal (optional),
127        //                               4 BasisCurve (IfcCurve).
128        let distance_along = location.get_float(0)?;
129        let offset_lateral = location.get_float(1).unwrap_or(0.0);
130        let offset_vertical = location.get_float(2).unwrap_or(0.0);
131        let offset_longitudinal = location.get_float(3).unwrap_or(0.0);
132
133        let basis_attr = location.get(4)?;
134        if basis_attr.is_null() {
135            return None;
136        }
137        let basis_curve = decoder.resolve_ref(basis_attr).ok().flatten()?;
138
139        let (origin, tangent) = if basis_curve.ifc_type == IfcType::IfcGradientCurve {
140            // `DistanceAlong` is 3D arc length. Use the same mapping and
141            // authored-station rebase as sectioned solids and centerlines.
142            let alignment = AlignmentCurve::parse(&basis_curve, decoder).ok().flatten()?;
143            let station = ArcLengthMap::new(&alignment).horizontal_station(distance_along);
144            let station = station.max(0.0).min(alignment.horizontal_length());
145            let frame = alignment.evaluate(station);
146            (frame.origin, frame.tangent)
147        } else {
148            // Other basis curves retain the existing polyline sampler.
149            let samples = ProfileProcessor::new(IfcSchema::new())
150                .get_curve_points(&basis_curve, decoder, TessellationQuality::Medium)
151                .ok()
152                .filter(|pts| pts.len() >= 2)?;
153            sample_polyline_at_distance(&samples, distance_along)?
154        };
155
156        // Build the curve-aligned frame with world-up. Railway alignments
157        // are near-horizontal so this is well-conditioned; in the
158        // pathological vertical-tangent case we keep an identity rotation
159        // at the sampled origin rather than emit NaN axes.
160        let world_up = Vector3::new(0.0, 0.0, 1.0);
161        let tangent_horiz_norm =
162            (tangent - world_up * tangent.dot(&world_up)).norm();
163        let (x_axis, y_axis, z_axis) = if tangent_horiz_norm > 1e-9 {
164            let x = tangent.normalize();
165            let y = world_up.cross(&x).normalize();
166            let z = x.cross(&y).normalize();
167            (x, y, z)
168        } else {
169            (
170                Vector3::new(1.0, 0.0, 0.0),
171                Vector3::new(0.0, 1.0, 0.0),
172                Vector3::new(0.0, 0.0, 1.0),
173            )
174        };
175
176        let position = origin.coords
177            + x_axis * offset_longitudinal
178            + y_axis * offset_lateral
179            + z_axis * offset_vertical;
180
181        let mut m = Matrix4::<f64>::identity();
182        m.fixed_view_mut::<3, 1>(0, 0).copy_from(&x_axis);
183        m.fixed_view_mut::<3, 1>(0, 1).copy_from(&y_axis);
184        m.fixed_view_mut::<3, 1>(0, 2).copy_from(&z_axis);
185        m[(0, 3)] = position.x;
186        m[(1, 3)] = position.y;
187        m[(2, 3)] = position.z;
188        Some(m)
189    }
190
191    /// `IfcLinearPlacement.CartesianPosition` (attr 2) is an optional
192    /// pre-baked `IfcAxis2Placement3D` carrying the exporter's own answer
193    /// to the linear sampling. `None` when absent, null, or unparseable —
194    /// the caller then samples the curve itself.
195    fn try_resolve_cartesian_position(
196        &self,
197        placement: &DecodedEntity,
198        decoder: &mut EntityDecoder,
199    ) -> Option<Matrix4<f64>> {
200        let cart_attr = placement.get(2)?;
201        if cart_attr.is_null() {
202            return None;
203        }
204        let cart = decoder.resolve_ref(cart_attr).ok().flatten()?;
205        if cart.ifc_type != IfcType::IfcAxis2Placement3D {
206            return None;
207        }
208        self.parse_axis2_placement_3d(&cart, decoder).ok()
209    }
210}
211
212/// Walk a polyline-sampled curve and interpolate to a target arc length.
213///
214/// Returns the 3D position at `distance` along the polyline plus the unit
215/// tangent of the segment containing it. The caller is expected to pass a
216/// densely-sampled polyline from
217/// [`ProfileProcessor::get_curve_points`][crate::profiles::ProfileProcessor::get_curve_points]
218/// — the precision of the result is bounded by the sampler's spacing.
219///
220/// Behaviour at the extremes:
221/// - `distance <= 0`: returns the first sample with the first segment's tangent.
222/// - `distance >= total length`: returns the last sample with the last segment's tangent.
223/// - Empty / single-sample polyline: `None` (the caller should fall back).
224fn sample_polyline_at_distance(
225    samples: &[Point3<f64>],
226    distance: f64,
227) -> Option<(Point3<f64>, Vector3<f64>)> {
228    if samples.len() < 2 {
229        return None;
230    }
231
232    if distance <= 0.0 {
233        let tangent = (samples[1] - samples[0])
234            .try_normalize(1e-12)
235            .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0));
236        return Some((samples[0], tangent));
237    }
238
239    let mut acc = 0.0;
240    for window in samples.windows(2) {
241        let a = window[0];
242        let b = window[1];
243        let seg = b - a;
244        let len = seg.norm();
245        if len < 1e-12 {
246            continue;
247        }
248        if acc + len >= distance {
249            let t = ((distance - acc) / len).clamp(0.0, 1.0);
250            let position = a + seg * t;
251            let tangent = (seg / len)
252                .try_normalize(1e-12)
253                .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0));
254            return Some((position, tangent));
255        }
256        acc += len;
257    }
258
259    // distance past the end of the curve — clamp to last sample, last segment tangent.
260    let last = samples[samples.len() - 1];
261    let prev = samples[samples.len() - 2];
262    let tangent = (last - prev)
263        .try_normalize(1e-12)
264        .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0));
265    Some((last, tangent))
266}
267
268#[cfg(test)]
269#[path = "linear_tests.rs"]
270mod tests;