axiolid_mesh_compile/station/
accuracy.rs1use axiolid_contracts::{GeomError, GeomResult};
49use axiolid_core::{Point3, Scalar};
50use axiolid_model::{GeometryGraph, GeometryNode, NodeId, StationOffsets};
51use axiolid_reference::station::POINT_ROUNDING;
52
53use super::basis::Basis;
54use super::bound::reading as frame_reading;
55use super::resolve_parts;
56
57pub(crate) const UNCERTIFIED_STATION_FRAME: &str =
59 "a node placed at a station whose frame has no certified error (a frame read by quadrature \
60 on an ellipse, a B-spline or an offset, a chain, a banked curve over a chain \
61 plan, an intrinsic space curve with torsion or tilted, an elevated curve with an intrinsic \
62 profile or another plan, a skewed circle, a steep tangent, a basis placed at a station \
63 whose frame is not exact, or a station within its distance's resolution of a seam it is \
64 not read on)";
65
66const TURN_ROUNDING: Scalar = 1e-14;
71
72const MARGIN: Scalar = 1e-12;
74
75#[non_exhaustive]
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct PlacementBound {
80 pub point_error: Scalar,
83 pub axis_error: Scalar,
85}
86
87impl PlacementBound {
88 #[must_use]
91 pub fn displacement(&self, local: Point3) -> Scalar {
92 self.point_error + self.axis_error * (local.x.abs() + local.y.abs() + local.z.abs())
93 }
94}
95
96#[non_exhaustive]
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub enum PlacementAccuracy {
100 Exact,
103 Certified(PlacementBound),
105 Unbounded(&'static str),
107}
108
109pub fn placement_accuracy(
127 graph: &GeometryGraph,
128 id: NodeId,
129 tolerance: Scalar,
130) -> GeomResult<PlacementAccuracy> {
131 let Some(GeometryNode::InstanceAtStation(placed)) = graph.get(id) else {
132 return Err(GeomError::InvalidInput(format!(
133 "node {id:?} is not an instance at a station"
134 )));
135 };
136 if !(tolerance.is_finite() && tolerance > 0.0) {
137 return Err(GeomError::InvalidInput(format!(
138 "station: a certified placement needs a finite, positive tolerance, not {tolerance}"
139 )));
140 }
141 let station = &placed.station;
142 let parts = resolve_parts(graph, station, 0)?;
143 if parts.exact {
144 return Ok(PlacementAccuracy::Exact);
145 }
146 let unbounded = PlacementAccuracy::Unbounded(UNCERTIFIED_STATION_FRAME);
147 if matches!(
150 parts.basis,
151 Basis::Atomic {
152 placed_exactly: false,
153 ..
154 }
155 ) {
156 return Ok(unbounded);
157 }
158 let Some(reading) = frame_reading(station.station.frame) else {
159 return Ok(unbounded);
160 };
161 let law = parts.basis.frame_law(tolerance)?;
162 let Some(error) = law.error_at(parts.distance, station.seam, reading) else {
163 return Ok(unbounded);
164 };
165 let turn = if station.orientation.is_base() {
168 None
169 } else {
170 match station.orientation.unit_axes() {
171 Ok((axis, reference)) => Some(axis.cross(reference).length()),
172 Err(_) => return Ok(unbounded),
173 }
174 };
175 if turn.is_some_and(|sin| !(sin.is_finite() && sin > 0.0)) {
176 return Ok(unbounded);
177 }
178 let bound = compose(
179 (error.point, error.axis),
180 &station.station.station.offsets,
181 turn,
182 parts.resolved.point,
183 );
184 Ok(
185 if bound.point_error.is_finite() && bound.axis_error.is_finite() {
186 PlacementAccuracy::Certified(bound)
187 } else {
188 unbounded
189 },
190 )
191}
192
193fn compose(
198 (read_point, read_axis): (Scalar, Scalar),
199 offsets: &StationOffsets,
200 turn: Option<Scalar>,
201 point: Point3,
202) -> PlacementBound {
203 let reach = offsets.lateral.abs() + offsets.vertical.abs() + offsets.longitudinal.abs();
204 let scale = (point.abs().max_element() + reach).max(1.0);
205 let point_error = (read_point + read_axis * reach + POINT_ROUNDING * scale) * (1.0 + MARGIN);
206 let axis_error = match turn {
207 None => read_axis,
208 Some(sin) => 3.0_f64.sqrt() * read_axis + TURN_ROUNDING / sin,
210 } * (1.0 + MARGIN);
211 PlacementBound {
212 point_error,
213 axis_error,
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use axiolid_core::Vec3;
221
222 #[derive(Clone, Copy)]
224 struct Read {
225 point: Vec3,
226 axes: [Vec3; 3],
227 }
228
229 fn base() -> Read {
230 let t = Vec3::new(0.6, 0.8, 0.0);
232 let l = Vec3::new(-0.8, 0.6, 0.0);
233 Read {
234 point: Vec3::new(1000.0, 2000.0, 30.0),
235 axes: [t, l, Vec3::Z],
236 }
237 }
238
239 fn perturbed(read: Read, a: Scalar, b: Scalar, n: Vec3) -> Read {
242 Read {
243 point: read.point + a * n,
244 axes: read.axes.map(|axis| axis + b * n),
245 }
246 }
247
248 fn offsets() -> StationOffsets {
249 StationOffsets::new(2.0, 1.0, 0.5)
251 }
252
253 fn station(read: Read, offsets: &StationOffsets) -> Vec3 {
255 read.point
256 + offsets.longitudinal * read.axes[0]
257 + offsets.lateral * read.axes[1]
258 + offsets.vertical * read.axes[2]
259 }
260
261 fn placed(origin: Vec3, axes: [Vec3; 3], local: Point3) -> Vec3 {
263 origin + local.x * axes[0] + local.y * axes[1] + local.z * axes[2]
264 }
265
266 fn turned(axes: [Vec3; 3], w: Vec3) -> [Vec3; 3] {
268 let mix = |u: Vec3| u.x * axes[0] + u.y * axes[1] + u.z * axes[2];
269 [
270 mix(w),
271 mix(Vec3::new(w.y, w.z, w.x)),
272 mix(Vec3::new(w.z, w.x, w.y)),
273 ]
274 }
275
276 fn at() -> Point3 {
277 let p = station(base(), &offsets());
278 Point3::new(p.x, p.y, p.z)
279 }
280
281 const A: Scalar = 3e-7;
282 const B: Scalar = 2e-9;
283
284 fn n() -> Vec3 {
285 Vec3::new(1.0, -2.0, 2.0) / 3.0
286 }
287
288 #[test]
289 fn the_point_error_is_the_station_points_worst_displacement() {
290 let error = (A, B);
291 let offsets = offsets();
292 let exact = base();
293 let read = perturbed(exact, A, B, n());
294 let moved = (station(read, &offsets) - station(exact, &offsets)).length();
295 let reach = 3.5; assert!((moved - (A + B * reach)).abs() <= 1e-12, "{moved}");
298 let bound = compose(error, &offsets, None, at());
299 assert!(bound.point_error >= moved, "{bound:?} < {moved}");
300 assert!(
301 bound.point_error <= moved * (1.0 + 1e-9) + 1e-9,
302 "{bound:?}"
303 );
304 let flat = compose(error, &StationOffsets::ZERO, None, at());
306 assert!(flat.point_error < A + 1e-9 && flat.point_error >= A);
307 }
308
309 #[test]
310 fn a_turned_axis_moves_by_sqrt_three_of_the_axis_error() {
311 let w = Vec3::ONE / 3.0_f64.sqrt();
312 let exact = base();
313 let read = perturbed(exact, A, B, n());
314 let (e, r) = (turned(exact.axes, w), turned(read.axes, w));
315 let moved = (0..3)
316 .map(|i| (e[i] - r[i]).length())
317 .fold(0.0, Scalar::max);
318 assert!((moved - 3.0_f64.sqrt() * B).abs() <= 1e-15, "{moved}");
319 let error = (A, B);
320 let off = offsets();
321 let turned_bound = compose(error, &off, Some(1.0), at());
322 assert!(
323 turned_bound.axis_error >= moved,
324 "{turned_bound:?} < {moved}"
325 );
326 assert!(turned_bound.axis_error <= moved * (1.0 + 1e-6) + 1e-13);
327 let base_bound = compose(error, &off, None, at());
329 assert!(base_bound.axis_error >= B && base_bound.axis_error <= B * (1.0 + 1e-9));
330 assert!(turned_bound.axis_error > base_bound.axis_error);
331 }
332
333 #[test]
334 fn a_body_vertex_moves_by_the_one_norm_of_its_local_point() {
335 let off = offsets();
336 let exact = base();
337 let read = perturbed(exact, A, B, n());
338 let error = (A, B);
339 let w = Vec3::ONE / 3.0_f64.sqrt();
340 for k in [0.0, 1.0, -3.0, 6.0] {
341 let local = Point3::new(k, k, k);
342 let bound = compose(error, &off, None, at());
344 let moved = (placed(station(read, &off), read.axes, local)
345 - placed(station(exact, &off), exact.axes, local))
346 .length();
347 let shown = bound.displacement(local);
348 assert!(shown >= moved, "k {k}: {shown} < {moved}");
349 assert!(
352 k < 0.0 || shown <= moved * (1.0 + 1e-9) + 1e-9,
353 "k {k}: {shown} {moved}"
354 );
355 let bound = compose(error, &off, Some(1.0), at());
357 let moved = (placed(station(read, &off), turned(read.axes, w), local)
358 - placed(station(exact, &off), turned(exact.axes, w), local))
359 .length();
360 let shown = bound.displacement(local);
361 assert!(shown >= moved, "turned, k {k}: {shown} < {moved}");
362 assert!(
363 k < 0.0 || shown <= moved * (1.0 + 1e-6) + 1e-9,
364 "turned, k {k}: {shown} {moved}"
365 );
366 }
367 }
368}