Skip to main content

ifc_lite_core/
georef.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//! IFC Georeferencing Support
6//!
7//! Handles IfcMapConversion and IfcProjectedCRS for coordinate transformations.
8//! Supports both IFC4 native entities and IFC2X3 ePSet_MapConversion fallback.
9
10use crate::decoder::EntityDecoder;
11use crate::error::Result;
12use crate::generated::IfcType;
13use crate::schema_gen::DecodedEntity;
14
15/// Where the georeferencing data was authored in the file.
16///
17/// Single discriminator shared (string-for-string) with the TS parser's
18/// `GeoreferenceInfo.source`, so server consumers and browser consumers see
19/// the same provenance for the same model.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum GeoRefSource {
22    /// IFC4 `IfcMapConversion` (+ optional `IfcProjectedCRS`).
23    MapConversion,
24    /// IFC2x3 `ePSet_MapConversion` property-set fallback.
25    EPSetMapConversion,
26    /// Legacy `IfcSite.RefLatitude`/`RefLongitude` (WGS84 degrees).
27    SiteLocation,
28}
29
30impl GeoRefSource {
31    /// Stable wire label (matches the TS parser's `source` union).
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::MapConversion => "mapConversion",
35            Self::EPSetMapConversion => "ePSetMapConversion",
36            Self::SiteLocation => "siteLocation",
37        }
38    }
39}
40
41/// Georeferencing information extracted from IFC model
42#[derive(Debug, Clone)]
43pub struct GeoReference {
44    /// CRS name (e.g., "EPSG:32632")
45    pub crs_name: Option<String>,
46    /// CRS description from `IfcProjectedCRS.Description`.
47    pub crs_description: Option<String>,
48    /// Geodetic datum (e.g., "WGS84")
49    pub geodetic_datum: Option<String>,
50    /// Vertical datum (e.g., "NAVD88")
51    pub vertical_datum: Option<String>,
52    /// Map projection (e.g., "UTM Zone 32N")
53    pub map_projection: Option<String>,
54    /// Map zone (e.g., "32N") from `IfcProjectedCRS.MapZone`.
55    pub map_zone: Option<String>,
56    /// Map unit name resolved from `IfcProjectedCRS.MapUnit`
57    /// (e.g. "METRE", "MILLIMETRE"). `None` when no MapUnit is authored —
58    /// per spec the project length unit then applies.
59    pub map_unit: Option<String>,
60    /// Scale factor converting MapConversion values to metres, derived from
61    /// `MapUnit` (0.001 for millimetres). `None` when no MapUnit is authored.
62    pub map_unit_scale: Option<f64>,
63    /// Where the data was authored (`IfcMapConversion`, ePSet fallback, or
64    /// legacy `IfcSite` lat/long).
65    pub source: GeoRefSource,
66    /// False easting (X offset to map CRS)
67    pub eastings: f64,
68    /// False northing (Y offset to map CRS)
69    pub northings: f64,
70    /// Orthogonal height (Z offset)
71    pub orthogonal_height: f64,
72    /// X-axis abscissa (cos of rotation angle)
73    pub x_axis_abscissa: f64,
74    /// X-axis ordinate (sin of rotation angle)
75    pub x_axis_ordinate: f64,
76    /// Scale factor (default 1.0)
77    pub scale: f64,
78}
79
80impl Default for GeoReference {
81    fn default() -> Self {
82        Self {
83            crs_name: None,
84            crs_description: None,
85            geodetic_datum: None,
86            vertical_datum: None,
87            map_projection: None,
88            map_zone: None,
89            map_unit: None,
90            map_unit_scale: None,
91            source: GeoRefSource::MapConversion,
92            eastings: 0.0,
93            northings: 0.0,
94            orthogonal_height: 0.0,
95            x_axis_abscissa: 1.0, // No rotation (cos(0) = 1)
96            x_axis_ordinate: 0.0, // No rotation (sin(0) = 0)
97            scale: 1.0,
98        }
99    }
100}
101
102impl GeoReference {
103    /// Create new georeferencing info with defaults
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Check if georeferencing is present
109    #[inline]
110    pub fn has_georef(&self) -> bool {
111        self.crs_name.is_some()
112            || self.eastings != 0.0
113            || self.northings != 0.0
114            || self.orthogonal_height != 0.0
115    }
116
117    /// Get rotation angle in radians
118    #[inline]
119    pub fn rotation(&self) -> f64 {
120        self.x_axis_ordinate.atan2(self.x_axis_abscissa)
121    }
122
123    /// Normalize the X-axis direction to a unit vector.
124    ///
125    /// `IfcMapConversion.XAxisAbscissa/Ordinate` form a DIRECTION — files may
126    /// author non-unit components. `local_to_map`/`to_matrix` use them
127    /// directly as cos/sin, so without normalization those disagreed with
128    /// [`rotation`](Self::rotation) (which `atan2`-normalizes) within one
129    /// payload, and with the TS parser's matrix (alignment audit). Called at
130    /// parse time by every extraction path.
131    fn normalize_axis(&mut self) {
132        let len = self.x_axis_abscissa.hypot(self.x_axis_ordinate);
133        if len > f64::EPSILON && (len - 1.0).abs() > f64::EPSILON {
134            self.x_axis_abscissa /= len;
135            self.x_axis_ordinate /= len;
136        }
137    }
138
139    /// Transform local coordinates to map coordinates
140    ///
141    /// Per IFC4x3 `IfcMapConversion`: "a scaling of the three axes (x,y,z),
142    /// by the same Scale, followed by an anti-clockwise rotation about the
143    /// z-axis [...] and then a translation in (x,y,z) of Eastings,
144    /// Northings, OrthogonalHeight" — note the Scale applies to z as well
145    /// ("one scale is applied equally to x, y and z, to convert units").
146    #[inline]
147    pub fn local_to_map(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
148        let cos_r = self.x_axis_abscissa;
149        let sin_r = self.x_axis_ordinate;
150        let s = self.scale;
151
152        let e = s * (cos_r * x - sin_r * y) + self.eastings;
153        let n = s * (sin_r * x + cos_r * y) + self.northings;
154        let h = s * z + self.orthogonal_height;
155
156        (e, n, h)
157    }
158
159    /// Transform map coordinates to local coordinates
160    #[inline]
161    pub fn map_to_local(&self, e: f64, n: f64, h: f64) -> (f64, f64, f64) {
162        let cos_r = self.x_axis_abscissa;
163        let sin_r = self.x_axis_ordinate;
164        // Guard against division by zero
165        let inv_scale = if self.scale.abs() < f64::EPSILON {
166            1.0
167        } else {
168            1.0 / self.scale
169        };
170
171        let dx = e - self.eastings;
172        let dy = n - self.northings;
173
174        // Inverse rotation: transpose of rotation matrix
175        let x = inv_scale * (cos_r * dx + sin_r * dy);
176        let y = inv_scale * (-sin_r * dx + cos_r * dy);
177        // Scale applies to z too (IfcMapConversion scales all three axes).
178        let z = inv_scale * (h - self.orthogonal_height);
179
180        (x, y, z)
181    }
182
183    /// Get 4x4 transformation matrix (column-major for OpenGL/WebGL)
184    pub fn to_matrix(&self) -> [f64; 16] {
185        let cos_r = self.x_axis_abscissa;
186        let sin_r = self.x_axis_ordinate;
187        let s = self.scale;
188
189        // Column-major 4x4 matrix
190        [
191            s * cos_r,
192            s * sin_r,
193            0.0,
194            0.0,
195            -s * sin_r,
196            s * cos_r,
197            0.0,
198            0.0,
199            0.0,
200            0.0,
201            // Scale applies uniformly to x, y AND z (IfcMapConversion).
202            s,
203            0.0,
204            self.eastings,
205            self.northings,
206            self.orthogonal_height,
207            1.0,
208        ]
209    }
210}
211
212/// Extract georeferencing from IFC content
213pub struct GeoRefExtractor;
214
215impl GeoRefExtractor {
216    /// Extract georeferencing from decoder
217    ///
218    /// Precedence (identical to the TS parser): `IfcMapConversion` →
219    /// `ePSet_MapConversion` (IFC2x3) → legacy `IfcSite` lat/long.
220    pub fn extract(
221        decoder: &mut EntityDecoder,
222        entity_types: &[(u32, IfcType)],
223    ) -> Result<Option<GeoReference>> {
224        // Find IfcMapConversion and IfcProjectedCRS entities. FIRST one wins
225        // (same pick as the TS parser, which reads `mapConversionIds[0]`) —
226        // last-wins silently flipped the served conversion on files with
227        // several authored conversions (alignment audit).
228        let mut map_conversion_id: Option<u32> = None;
229        let mut projected_crs_id: Option<u32> = None;
230
231        for (id, ifc_type) in entity_types {
232            match ifc_type {
233                IfcType::IfcMapConversion => {
234                    if map_conversion_id.is_none() {
235                        map_conversion_id = Some(*id);
236                    }
237                }
238                IfcType::IfcProjectedCRS => {
239                    if projected_crs_id.is_none() {
240                        projected_crs_id = Some(*id);
241                    }
242                }
243                _ => {}
244            }
245        }
246
247        // If no map conversion, try IFC2X3 property set fallback, then the
248        // legacy IfcSite lat/long fallback (TS parity).
249        if map_conversion_id.is_none() {
250            if let Some(georef) = Self::extract_from_pset(decoder, entity_types)? {
251                return Ok(Some(georef));
252            }
253            return Self::extract_from_site(decoder, entity_types);
254        }
255
256        let mut georef = GeoReference::new();
257        georef.source = GeoRefSource::MapConversion;
258
259        // Parse IfcMapConversion
260        // Attributes: SourceCRS, TargetCRS, Eastings, Northings, OrthogonalHeight,
261        //             XAxisAbscissa, XAxisOrdinate, Scale
262        if let Some(id) = map_conversion_id {
263            let entity = decoder.decode_by_id(id)?;
264            Self::parse_map_conversion(&entity, &mut georef);
265        }
266
267        // Parse IfcProjectedCRS
268        // Attributes: Name, Description, GeodeticDatum, VerticalDatum,
269        //             MapProjection, MapZone, MapUnit
270        if let Some(id) = projected_crs_id {
271            let entity = decoder.decode_by_id(id)?;
272            Self::parse_projected_crs(&entity, decoder, &mut georef);
273        }
274
275        georef.normalize_axis();
276
277        if georef.has_georef() {
278            Ok(Some(georef))
279        } else {
280            Ok(None)
281        }
282    }
283
284    /// Parse IfcMapConversion entity
285    fn parse_map_conversion(entity: &DecodedEntity, georef: &mut GeoReference) {
286        // Index 2: Eastings
287        if let Some(e) = entity.get_float(2) {
288            georef.eastings = e;
289        }
290        // Index 3: Northings
291        if let Some(n) = entity.get_float(3) {
292            georef.northings = n;
293        }
294        // Index 4: OrthogonalHeight
295        if let Some(h) = entity.get_float(4) {
296            georef.orthogonal_height = h;
297        }
298        // Index 5: XAxisAbscissa (optional)
299        if let Some(xa) = entity.get_float(5) {
300            georef.x_axis_abscissa = xa;
301        }
302        // Index 6: XAxisOrdinate (optional)
303        if let Some(xo) = entity.get_float(6) {
304            georef.x_axis_ordinate = xo;
305        }
306        // Index 7: Scale (optional, default 1.0)
307        if let Some(s) = entity.get_float(7) {
308            georef.scale = s;
309        }
310    }
311
312    /// Parse IfcProjectedCRS entity
313    fn parse_projected_crs(
314        entity: &DecodedEntity,
315        decoder: &mut EntityDecoder,
316        georef: &mut GeoReference,
317    ) {
318        // Index 0: Name (e.g., "EPSG:32632")
319        if let Some(name) = entity.get_string(0) {
320            georef.crs_name = Some(name.to_string());
321        }
322        // Index 1: Description
323        if let Some(desc) = entity.get_string(1) {
324            georef.crs_description = Some(desc.to_string());
325        }
326        // Index 2: GeodeticDatum
327        if let Some(datum) = entity.get_string(2) {
328            georef.geodetic_datum = Some(datum.to_string());
329        }
330        // Index 3: VerticalDatum
331        if let Some(vdatum) = entity.get_string(3) {
332            georef.vertical_datum = Some(vdatum.to_string());
333        }
334        // Index 4: MapProjection
335        if let Some(proj) = entity.get_string(4) {
336            georef.map_projection = Some(proj.to_string());
337        }
338        // Index 5: MapZone
339        if let Some(zone) = entity.get_string(5) {
340            georef.map_zone = Some(zone.to_string());
341        }
342        // Index 6: MapUnit (IfcNamedUnit ref). Mirrors the TS parser: when a
343        // MapUnit IS authored, default to METRE/1.0 and refine from the
344        // IFCSIUNIT prefix — a millimetre-based conversion must scale by
345        // 0.001 on the server exactly like in the browser. When absent, the
346        // project length unit applies (spec default) and both stay `None`.
347        if let Some(unit_ref) = entity.get_ref(6) {
348            let mut unit_name = "METRE".to_string();
349            let mut unit_scale = 1.0_f64;
350            if let Ok(unit_entity) = decoder.decode_by_id(unit_ref) {
351                if unit_entity.ifc_type == IfcType::IfcSIUnit {
352                    // IFCSIUNIT: [0] Dimensions, [1] UnitType, [2] Prefix, [3] Name
353                    if let Some(prefix_attr) = unit_entity.get(2) {
354                        if !prefix_attr.is_null() {
355                            if let Some(prefix) = prefix_attr.as_enum() {
356                                let multiplier = crate::units::get_si_prefix_multiplier(prefix);
357                                if (multiplier - 1.0).abs() > f64::EPSILON {
358                                    unit_scale = multiplier;
359                                    let prefix_upper = prefix.to_ascii_uppercase();
360                                    unit_name = if prefix_upper == "MILLI" {
361                                        "MILLIMETRE".to_string()
362                                    } else {
363                                        format!("{prefix_upper}METRE")
364                                    };
365                                }
366                            }
367                        }
368                    }
369                }
370            }
371            georef.map_unit = Some(unit_name);
372            georef.map_unit_scale = Some(unit_scale);
373        }
374    }
375
376    /// Extract from IFC2X3 property sets (fallback)
377    fn extract_from_pset(
378        decoder: &mut EntityDecoder,
379        entity_types: &[(u32, IfcType)],
380    ) -> Result<Option<GeoReference>> {
381        // Find IfcPropertySet with name ePSet_MapConversion
382        for (id, ifc_type) in entity_types {
383            if *ifc_type == IfcType::IfcPropertySet {
384                let entity = decoder.decode_by_id(*id)?;
385                // IfcPropertySet.Name is attribute 2 (attribute 0 is GlobalId);
386                // reading attribute 0 here never matched the ePSet, so the
387                // IFC2x3 georeferencing fallback was dead (issue #900 review).
388                if let Some(name) = entity.get_string(2) {
389                    if name == "ePSet_MapConversion" || name == "EPset_MapConversion" {
390                        return Self::parse_pset_map_conversion(decoder, &entity);
391                    }
392                }
393            }
394        }
395        Ok(None)
396    }
397
398    /// Parse ePSet_MapConversion property set
399    fn parse_pset_map_conversion(
400        decoder: &mut EntityDecoder,
401        pset: &DecodedEntity,
402    ) -> Result<Option<GeoReference>> {
403        let mut georef = GeoReference::new();
404        georef.source = GeoRefSource::EPSetMapConversion;
405
406        // HasProperties is typically at index 4
407        if let Some(props_list) = pset.get_list(4) {
408            for prop_attr in props_list {
409                if let Some(prop_id) = prop_attr.as_entity_ref() {
410                    let prop = decoder.decode_by_id(prop_id)?;
411                    // IfcPropertySingleValue: Name (0), Description (1), NominalValue (2)
412                    if let Some(name) = prop.get_string(0) {
413                        let value = prop.get_float(2);
414                        match name {
415                            "Eastings" => {
416                                if let Some(v) = value {
417                                    georef.eastings = v;
418                                }
419                            }
420                            "Northings" => {
421                                if let Some(v) = value {
422                                    georef.northings = v;
423                                }
424                            }
425                            "OrthogonalHeight" => {
426                                if let Some(v) = value {
427                                    georef.orthogonal_height = v;
428                                }
429                            }
430                            "XAxisAbscissa" => {
431                                if let Some(v) = value {
432                                    georef.x_axis_abscissa = v;
433                                }
434                            }
435                            "XAxisOrdinate" => {
436                                if let Some(v) = value {
437                                    georef.x_axis_ordinate = v;
438                                }
439                            }
440                            "Scale" => {
441                                if let Some(v) = value {
442                                    georef.scale = v;
443                                }
444                            }
445                            _ => {}
446                        }
447                    }
448                }
449            }
450        }
451
452        georef.normalize_axis();
453
454        if georef.has_georef() {
455            Ok(Some(georef))
456        } else {
457            Ok(None)
458        }
459    }
460
461    /// Legacy `IfcSite.RefLatitude`/`RefLongitude` fallback (TS parity).
462    ///
463    /// Mirrors the TS parser's `extractLegacySiteGeoreference`: WGS84
464    /// degrees land in eastings (longitude) / northings (latitude) with the
465    /// site `RefElevation` as orthogonal height, under an `EPSG:4326`
466    /// pseudo-CRS — so `hasGeoreference`/`has_georef` agree between the
467    /// browser and the server for site-only models.
468    fn extract_from_site(
469        decoder: &mut EntityDecoder,
470        entity_types: &[(u32, IfcType)],
471    ) -> Result<Option<GeoReference>> {
472        for (id, ifc_type) in entity_types {
473            if *ifc_type != IfcType::IfcSite {
474                continue;
475            }
476            let site = decoder.decode_by_id(*id)?;
477            // IfcSite: RefLatitude (9), RefLongitude (10), RefElevation (11).
478            let latitude = Self::compound_plane_angle_to_degrees(&site, 9);
479            let longitude = Self::compound_plane_angle_to_degrees(&site, 10);
480            let (Some(latitude), Some(longitude)) = (latitude, longitude) else {
481                continue;
482            };
483            let elevation = site.get_float(11).unwrap_or(0.0);
484
485            let mut georef = GeoReference::new();
486            georef.source = GeoRefSource::SiteLocation;
487            georef.crs_name = Some("EPSG:4326".to_string());
488            georef.crs_description = Some("Legacy IfcSite geolocation".to_string());
489            georef.geodetic_datum = Some("WGS84".to_string());
490            georef.map_projection = Some("Geographic".to_string());
491            georef.map_unit = Some("DEGREE".to_string());
492            georef.eastings = longitude;
493            georef.northings = latitude;
494            georef.orthogonal_height = elevation;
495            return Ok(Some(georef));
496        }
497        Ok(None)
498    }
499
500    /// Convert an `IfcCompoundPlaneAngleMeasure` attribute (list of 3-4
501    /// integers: degrees, minutes, seconds, optional millionth-seconds) to
502    /// decimal degrees. Same sign handling as the TS parser: any negative
503    /// component makes the whole angle negative.
504    fn compound_plane_angle_to_degrees(entity: &DecodedEntity, index: usize) -> Option<f64> {
505        let list = entity.get_list(index)?;
506        let mut numbers = Vec::with_capacity(4);
507        for value in list {
508            if let Some(v) = value.as_float() {
509                numbers.push(v);
510            }
511        }
512        if numbers.len() < 3 {
513            return None;
514        }
515        let millionths = numbers.get(3).copied().unwrap_or(0.0);
516        let sign = if numbers[0] < 0.0 || numbers[1] < 0.0 || numbers[2] < 0.0 || millionths < 0.0
517        {
518            -1.0
519        } else {
520            1.0
521        };
522        let degrees = numbers[0].abs();
523        let minutes = numbers[1].abs();
524        let seconds = numbers[2].abs();
525        let millionths = millionths.abs();
526        Some(sign * (degrees + minutes / 60.0 + (seconds + millionths / 1_000_000.0) / 3600.0))
527    }
528}
529
530/// RTC (Relative-To-Center) coordinate handler for large coordinates
531#[derive(Debug, Clone, Default)]
532pub struct RtcOffset {
533    /// Center offset (subtracted from all coordinates)
534    pub x: f64,
535    pub y: f64,
536    pub z: f64,
537}
538
539impl RtcOffset {
540    /// Create from centroid of positions
541    #[inline]
542    pub fn from_positions(positions: &[f32]) -> Self {
543        if positions.is_empty() {
544            return Self::default();
545        }
546
547        let count = positions.len() / 3;
548        let mut sum = (0.0f64, 0.0f64, 0.0f64);
549
550        for chunk in positions.chunks_exact(3) {
551            sum.0 += chunk[0] as f64;
552            sum.1 += chunk[1] as f64;
553            sum.2 += chunk[2] as f64;
554        }
555
556        Self {
557            x: sum.0 / count as f64,
558            y: sum.1 / count as f64,
559            z: sum.2 / count as f64,
560        }
561    }
562
563    /// Check if offset is significant (>10km from origin)
564    #[inline]
565    pub fn is_significant(&self) -> bool {
566        const THRESHOLD: f64 = 10000.0; // 10km
567        self.x.abs() > THRESHOLD || self.y.abs() > THRESHOLD || self.z.abs() > THRESHOLD
568    }
569
570    /// Apply offset to positions in-place
571    #[inline]
572    pub fn apply(&self, positions: &mut [f32]) {
573        for chunk in positions.chunks_exact_mut(3) {
574            chunk[0] = (chunk[0] as f64 - self.x) as f32;
575            chunk[1] = (chunk[1] as f64 - self.y) as f32;
576            chunk[2] = (chunk[2] as f64 - self.z) as f32;
577        }
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn test_georef_local_to_map() {
587        let mut georef = GeoReference::new();
588        georef.eastings = 500000.0;
589        georef.northings = 5000000.0;
590        georef.orthogonal_height = 100.0;
591
592        let (e, n, h) = georef.local_to_map(10.0, 20.0, 5.0);
593        assert!((e - 500010.0).abs() < 1e-10);
594        assert!((n - 5000020.0).abs() < 1e-10);
595        assert!((h - 105.0).abs() < 1e-10);
596    }
597
598    #[test]
599    fn test_georef_map_to_local() {
600        let mut georef = GeoReference::new();
601        georef.eastings = 500000.0;
602        georef.northings = 5000000.0;
603        georef.orthogonal_height = 100.0;
604
605        let (x, y, z) = georef.map_to_local(500010.0, 5000020.0, 105.0);
606        assert!((x - 10.0).abs() < 1e-10);
607        assert!((y - 20.0).abs() < 1e-10);
608        assert!((z - 5.0).abs() < 1e-10);
609    }
610
611    #[test]
612    fn test_georef_with_rotation() {
613        let mut georef = GeoReference::new();
614        georef.eastings = 0.0;
615        georef.northings = 0.0;
616        // 90 degree rotation
617        georef.x_axis_abscissa = 0.0;
618        georef.x_axis_ordinate = 1.0;
619
620        let (e, n, _) = georef.local_to_map(10.0, 0.0, 0.0);
621        // After 90 degree rotation: (10, 0) -> (0, 10)
622        assert!(e.abs() < 1e-10);
623        assert!((n - 10.0).abs() < 1e-10);
624    }
625
626    #[test]
627    fn test_rtc_offset() {
628        let positions = vec![
629            500000.0f32,
630            5000000.0,
631            0.0,
632            500010.0,
633            5000010.0,
634            10.0,
635            500020.0,
636            5000020.0,
637            20.0,
638        ];
639
640        let offset = RtcOffset::from_positions(&positions);
641        assert!(offset.is_significant());
642        assert!((offset.x - 500010.0).abs() < 1.0);
643        assert!((offset.y - 5000010.0).abs() < 1.0);
644    }
645
646    #[test]
647    fn test_rtc_apply() {
648        let mut positions = vec![500000.0f32, 5000000.0, 0.0, 500010.0, 5000010.0, 10.0];
649
650        let offset = RtcOffset {
651            x: 500000.0,
652            y: 5000000.0,
653            z: 0.0,
654        };
655
656        offset.apply(&mut positions);
657
658        assert!((positions[0] - 0.0).abs() < 1e-5);
659        assert!((positions[1] - 0.0).abs() < 1e-5);
660        assert!((positions[3] - 10.0).abs() < 1e-5);
661        assert!((positions[4] - 10.0).abs() < 1e-5);
662    }
663}