Skip to main content

ifc_lite_geometry/
profile_extractor.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//! Profile extraction for architectural 2D drawing projection.
6//!
7//! Extracts raw profile polygons from IfcExtrudedAreaSolid building elements,
8//! enabling clean 2D projection without tessellation artifacts from EdgeExtractor.
9//!
10//! # Coverage
11//! - `IfcExtrudedAreaSolid` with any profile type (rectangle, circle, arbitrary)
12//! - `IfcMappedItem` — recurses into representation maps with composed transforms
13//! - Full element placement chain (IfcLocalPlacement hierarchy)
14//! - Direct and nested representations
15//!
16//! # Coordinate system
17//! All output is in WebGL Y-up space (IFC Z-up converted: new_y = old_z, new_z = -old_y).
18//! Lengths are in metres (unit scale applied).
19
20use crate::profiles::ProfileProcessor;
21use crate::{Error, Point3, Result, Vector3};
22use ifc_lite_core::{
23    build_entity_index, AttributeValue, DecodedEntity, EntityDecoder, EntityScanner, IfcSchema,
24    IfcType,
25};
26use nalgebra::Matrix4;
27
28// ═══════════════════════════════════════════════════════════════════════════
29// PUBLIC TYPES
30// ═══════════════════════════════════════════════════════════════════════════
31
32/// A profile extracted from a single IFC building element.
33///
34/// All geometry is in **WebGL Y-up world space** (metres).
35/// Applying `transform` to a local 2D point `[x, y, 0, 1]` gives the
36/// world-space 3D position.
37#[derive(Debug, Clone)]
38pub struct ExtractedProfile {
39    /// Express ID of the building element.
40    pub express_id: u32,
41    /// IFC type name (e.g., `"IfcWall"`).
42    pub ifc_type: String,
43    /// Outer boundary: interleaved `[x0, y0, x1, y1, …]` in local profile space (metres).
44    pub outer_points: Vec<f32>,
45    /// Number of points in each hole (one entry per hole).
46    pub hole_counts: Vec<u32>,
47    /// All hole points concatenated: `[x0, y0, x1, y1, …]` in local profile space (metres).
48    pub hole_points: Vec<f32>,
49    /// 4 × 4 column-major transform **in WebGL Y-up world space**.
50    /// `M * [x_2d, y_2d, 0, 1]ᵀ` → world position.
51    pub transform: [f32; 16],
52    /// Extrusion direction in WebGL Y-up world space (unit vector).
53    pub extrusion_dir: [f32; 3],
54    /// Extrusion depth in metres.
55    pub extrusion_depth: f32,
56    /// Model index (for multi-model federation).
57    pub model_index: u32,
58}
59
60// ═══════════════════════════════════════════════════════════════════════════
61// PUBLIC ENTRY POINT
62// ═══════════════════════════════════════════════════════════════════════════
63
64/// Extract profiles for every building element in `content`.
65///
66/// Extracts `IfcExtrudedAreaSolid` representations, including those nested
67/// inside `IfcMappedItem` chains (up to 3 levels deep).
68/// Returns an empty `Vec` for models with no such elements.
69pub fn extract_profiles(content: &str, model_index: u32) -> Vec<ExtractedProfile> {
70    let entity_index = build_entity_index(content);
71    let mut decoder = EntityDecoder::with_index(content, entity_index);
72
73    // Detect unit scale (same approach as GeometryRouter::with_units)
74    let unit_scale = detect_unit_scale(content, &mut decoder);
75
76    let schema = IfcSchema::new();
77    let profile_processor = ProfileProcessor::new(schema);
78
79    let mut results = Vec::new();
80    let mut scanner = EntityScanner::new(content);
81
82    while let Some((id, type_name, start, end)) = scanner.next_entity() {
83        if !ifc_lite_core::has_geometry_by_name(type_name) {
84            continue;
85        }
86
87        let entity = match decoder.decode_at_with_id(id, start, end) {
88            Ok(e) => e,
89            Err(_) => continue,
90        };
91
92        // ObjectPlacement (attr 5) → element world transform (IFC Z-up, native units)
93        let element_transform = get_placement_transform(entity.get(5), &mut decoder);
94
95        // Scale the translation part from file units to metres
96        let elem_tf = scale_translation(element_transform, unit_scale);
97
98        // Representation (attr 6) → IfcProductDefinitionShape
99        let repr_attr = match entity.get(6) {
100            Some(a) if !a.is_null() => a,
101            _ => continue,
102        };
103        let repr = match decoder.resolve_ref(repr_attr) {
104            Ok(Some(r)) => r,
105            _ => continue,
106        };
107
108        // IfcProductDefinitionShape → Representations (attr 2)
109        let reprs_attr = match repr.get(2) {
110            Some(a) => a,
111            None => continue,
112        };
113        let representations = match decoder.resolve_ref_list(reprs_attr) {
114            Ok(r) => r,
115            Err(_) => continue,
116        };
117
118        let ifc_type_name = entity.ifc_type.name().to_string();
119
120        for shape_rep in representations {
121            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
122                continue;
123            }
124
125            // Accept Body and SweptSolid representations
126            let rep_id = shape_rep.get(1).and_then(|a| a.as_string()).unwrap_or("");
127            if rep_id != "Body" && rep_id != "SweptSolid" {
128                continue;
129            }
130
131            // Items (attr 3)
132            let items_attr = match shape_rep.get(3) {
133                Some(a) => a,
134                None => continue,
135            };
136            let items = match decoder.resolve_ref_list(items_attr) {
137                Ok(i) => i,
138                Err(_) => continue,
139            };
140
141            for item in &items {
142                if item.ifc_type == IfcType::IfcExtrudedAreaSolid {
143                    match extract_extruded_solid(
144                        id,
145                        &ifc_type_name,
146                        item,
147                        &elem_tf,
148                        unit_scale,
149                        &profile_processor,
150                        &mut decoder,
151                        model_index,
152                    ) {
153                        Ok(entry) => results.push(entry),
154                        Err(_e) => {
155                            #[cfg(feature = "debug_geometry")]
156                            eprintln!("[profile_extractor] Skipping #{id} ({ifc_type_name}): {_e}");
157                        }
158                    }
159                } else if item.ifc_type == IfcType::IfcMappedItem {
160                    extract_mapped_item_profiles(
161                        id,
162                        &ifc_type_name,
163                        item,
164                        &elem_tf,
165                        unit_scale,
166                        &profile_processor,
167                        &mut decoder,
168                        model_index,
169                        0,
170                        &mut results,
171                    );
172                }
173            }
174        }
175    }
176
177    results
178}
179
180// ═══════════════════════════════════════════════════════════════════════════
181// PRIVATE: MAPPED ITEM EXTRACTION
182// ═══════════════════════════════════════════════════════════════════════════
183
184/// Maximum recursion depth for nested IfcMappedItem chains.
185const MAX_MAPPED_DEPTH: usize = 3;
186
187/// Recursively extract profiles from an IfcMappedItem.
188///
189/// IfcMappedItem structure:
190///   attr 0: MappingSource → IfcRepresentationMap
191///     attr 0: MappingOrigin (IfcAxis2Placement) — local coordinate system of shared geometry
192///     attr 1: MappedRepresentation (IfcRepresentation) → items to extract from
193///   attr 1: MappingTarget → IfcCartesianTransformationOperator3D (instance transform)
194///
195/// The composed transform is: `elem_transform * mapping_target`.
196/// Each solid's own Position is applied inside `extract_extruded_solid`.
197fn extract_mapped_item_profiles(
198    element_id: u32,
199    ifc_type: &str,
200    mapped_item: &DecodedEntity,
201    elem_transform: &Matrix4<f64>,
202    unit_scale: f64,
203    profile_processor: &ProfileProcessor,
204    decoder: &mut EntityDecoder,
205    model_index: u32,
206    depth: usize,
207    results: &mut Vec<ExtractedProfile>,
208) {
209    if depth > MAX_MAPPED_DEPTH {
210        #[cfg(feature = "debug_geometry")]
211        eprintln!("[profile_extractor] #{element_id} ({ifc_type}): max mapped item depth exceeded");
212        return;
213    }
214
215    // Attr 0: MappingSource → IfcRepresentationMap
216    let source = match mapped_item
217        .get(0)
218        .and_then(|a| if a.is_null() { None } else { Some(a) })
219        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
220    {
221        Some(s) => s,
222        None => return,
223    };
224
225    // Attr 1: MappingTarget → IfcCartesianTransformationOperator3D
226    let target_tf = mapped_item
227        .get(1)
228        .and_then(|a| if a.is_null() { None } else { Some(a) })
229        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
230        .and_then(|e| parse_cartesian_transformation_operator(&e, decoder).ok())
231        .unwrap_or_else(Matrix4::identity);
232
233    // Scale the target transform translation from file units to metres
234    let scaled_target = scale_translation(target_tf, unit_scale);
235    let composed = elem_transform * scaled_target;
236
237    // MappedRepresentation (attr 1 of RepresentationMap) → items
238    let mapped_rep = match source
239        .get(1)
240        .and_then(|a| if a.is_null() { None } else { Some(a) })
241        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
242    {
243        Some(r) => r,
244        None => return,
245    };
246
247    let items = match mapped_rep
248        .get(3)
249        .and_then(|a| decoder.resolve_ref_list(a).ok())
250    {
251        Some(i) => i,
252        None => return,
253    };
254
255    for sub_item in &items {
256        if sub_item.ifc_type == IfcType::IfcExtrudedAreaSolid {
257            match extract_extruded_solid(
258                element_id,
259                ifc_type,
260                sub_item,
261                &composed,
262                unit_scale,
263                profile_processor,
264                decoder,
265                model_index,
266            ) {
267                Ok(entry) => results.push(entry),
268                Err(_e) => {
269                    #[cfg(feature = "debug_geometry")]
270                    eprintln!("[profile_extractor] #{element_id} ({ifc_type}) mapped: {_e}");
271                }
272            }
273        } else if sub_item.ifc_type == IfcType::IfcMappedItem {
274            extract_mapped_item_profiles(
275                element_id,
276                ifc_type,
277                sub_item,
278                &composed,
279                unit_scale,
280                profile_processor,
281                decoder,
282                model_index,
283                depth + 1,
284                results,
285            );
286        }
287    }
288}
289
290/// Parse IfcCartesianTransformationOperator3D into a Matrix4<f64>.
291///
292/// Attributes:
293///   0: Axis1 (X direction, optional)
294///   1: Axis2 (Y direction, optional)
295///   2: LocalOrigin (IfcCartesianPoint)
296///   3: Scale (f64, default 1.0)
297///   4: Axis3 (Z direction, optional, 3D only)
298fn parse_cartesian_transformation_operator(
299    entity: &DecodedEntity,
300    decoder: &mut EntityDecoder,
301) -> Result<Matrix4<f64>> {
302    // LocalOrigin (attr 2)
303    let origin = parse_cartesian_point(entity, decoder, 2).unwrap_or(Point3::new(0.0, 0.0, 0.0));
304
305    // Scale (attr 3)
306    let scale = entity.get(3).and_then(|v| v.as_float()).unwrap_or(1.0);
307
308    // Axis1 / X direction (attr 0)
309    let x_axis = entity
310        .get(0)
311        .filter(|a| !a.is_null())
312        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
313        .and_then(|e| parse_direction_entity(&e).ok())
314        .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0))
315        .normalize();
316
317    // Axis3 / Z direction (attr 4, 3D only)
318    let z_axis = entity
319        .get(4)
320        .filter(|a| !a.is_null())
321        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
322        .and_then(|e| parse_direction_entity(&e).ok())
323        .unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0))
324        .normalize();
325
326    // Derive orthogonal axes (right-hand system)
327    let y_axis = z_axis.cross(&x_axis).normalize();
328    let x_axis = y_axis.cross(&z_axis).normalize();
329
330    #[rustfmt::skip]
331    let m = Matrix4::new(
332        x_axis.x * scale, y_axis.x * scale, z_axis.x * scale, origin.x,
333        x_axis.y * scale, y_axis.y * scale, z_axis.y * scale, origin.y,
334        x_axis.z * scale, y_axis.z * scale, z_axis.z * scale, origin.z,
335        0.0,              0.0,              0.0,              1.0,
336    );
337    Ok(m)
338}
339
340// ═══════════════════════════════════════════════════════════════════════════
341// PRIVATE: SOLID EXTRACTION
342// ═══════════════════════════════════════════════════════════════════════════
343
344fn extract_extruded_solid(
345    element_id: u32,
346    ifc_type: &str,
347    solid: &DecodedEntity,
348    elem_transform: &Matrix4<f64>,
349    unit_scale: f64,
350    profile_processor: &ProfileProcessor,
351    decoder: &mut EntityDecoder,
352    model_index: u32,
353) -> Result<ExtractedProfile> {
354    // SweptArea (attr 0)
355    let profile_attr = solid
356        .get(0)
357        .ok_or_else(|| Error::geometry("ExtrudedAreaSolid missing SweptArea"))?;
358    let profile_entity = decoder
359        .resolve_ref(profile_attr)?
360        .ok_or_else(|| Error::geometry("Failed to resolve SweptArea"))?;
361    let profile = profile_processor.process(&profile_entity, decoder)?;
362
363    if profile.outer.is_empty() {
364        return Err(Error::geometry("empty profile"));
365    }
366
367    // Position (attr 1) → solid local transform in IFC native units
368    let solid_transform = if let Some(pos_attr) = solid.get(1) {
369        if !pos_attr.is_null() {
370            if let Some(pos_ent) = decoder.resolve_ref(pos_attr)? {
371                if pos_ent.ifc_type == IfcType::IfcAxis2Placement3D {
372                    let mut t = parse_axis2_placement_3d(&pos_ent, decoder)?;
373                    // Scale translation from file units to metres
374                    t[(0, 3)] *= unit_scale;
375                    t[(1, 3)] *= unit_scale;
376                    t[(2, 3)] *= unit_scale;
377                    t
378                } else {
379                    Matrix4::identity()
380                }
381            } else {
382                Matrix4::identity()
383            }
384        } else {
385            Matrix4::identity()
386        }
387    } else {
388        Matrix4::identity()
389    };
390
391    // ExtrudedDirection (attr 2) in local solid space
392    let local_dir = parse_extrusion_direction(solid, decoder);
393
394    // Depth (attr 3) — required per IFC spec but default to 1.0 for robustness
395    // with malformed files (logged under debug_geometry feature)
396    let raw_depth = solid.get(3).and_then(|v| v.as_float());
397    #[cfg(feature = "debug_geometry")]
398    if raw_depth.is_none() {
399        eprintln!(
400            "[profile_extractor] #{element_id} ({ifc_type}): missing Depth, defaulting to 1.0"
401        );
402    }
403    let depth = raw_depth.unwrap_or(1.0) * unit_scale;
404
405    // Combined transform: elem_placement * solid_position  (IFC Z-up, metres)
406    let combined_ifc = elem_transform * solid_transform;
407
408    // Convert combined transform to WebGL Y-up column-major [f32; 16]
409    let transform = convert_ifc_to_webgl(&combined_ifc);
410
411    // Transform local extrusion direction to world IFC space (rotation only, no translation)
412    let world_dir_ifc = combined_ifc.transform_vector(&local_dir);
413
414    // Convert world direction to WebGL Y-up
415    let extrusion_dir = [
416        world_dir_ifc.x as f32,
417        world_dir_ifc.z as f32,  // WebGL Y = IFC Z
418        -world_dir_ifc.y as f32, // WebGL Z = -IFC Y
419    ];
420
421    // Scale profile 2D points from file units to metres
422    let outer_points: Vec<f32> = profile
423        .outer
424        .iter()
425        .flat_map(|p| [(p.x * unit_scale) as f32, (p.y * unit_scale) as f32])
426        .collect();
427
428    let hole_counts: Vec<u32> = profile.holes.iter().map(|h| h.len() as u32).collect();
429    let hole_points: Vec<f32> = profile
430        .holes
431        .iter()
432        .flat_map(|h| {
433            h.iter()
434                .flat_map(|p| [(p.x * unit_scale) as f32, (p.y * unit_scale) as f32])
435        })
436        .collect();
437
438    Ok(ExtractedProfile {
439        express_id: element_id,
440        ifc_type: ifc_type.to_string(),
441        outer_points,
442        hole_counts,
443        hole_points,
444        transform,
445        extrusion_dir,
446        extrusion_depth: depth as f32,
447        model_index,
448    })
449}
450
451// ═══════════════════════════════════════════════════════════════════════════
452// PRIVATE: PLACEMENT TRAVERSAL
453// Duplicated from router/transforms.rs (pub(super) there) to avoid coupling.
454// ═══════════════════════════════════════════════════════════════════════════
455
456/// Resolve an element's ObjectPlacement attribute to a world Matrix4 in IFC Z-up space.
457fn get_placement_transform(
458    placement_attr: Option<&AttributeValue>,
459    decoder: &mut EntityDecoder,
460) -> Matrix4<f64> {
461    let attr = match placement_attr {
462        Some(a) if !a.is_null() => a,
463        _ => return Matrix4::identity(),
464    };
465    match decoder.resolve_ref(attr) {
466        Ok(Some(p)) => get_placement_recursive(&p, decoder, 0),
467        _ => Matrix4::identity(),
468    }
469}
470
471const MAX_PLACEMENT_DEPTH: usize = 100;
472
473fn get_placement_recursive(
474    placement: &DecodedEntity,
475    decoder: &mut EntityDecoder,
476    depth: usize,
477) -> Matrix4<f64> {
478    if depth > MAX_PLACEMENT_DEPTH || placement.ifc_type != IfcType::IfcLocalPlacement {
479        return Matrix4::identity();
480    }
481
482    // PlacementRelTo (attr 0) → parent transform
483    let parent_tf = if let Some(parent_attr) = placement.get(0) {
484        if !parent_attr.is_null() {
485            match decoder.resolve_ref(parent_attr) {
486                Ok(Some(parent)) => get_placement_recursive(&parent, decoder, depth + 1),
487                _ => Matrix4::identity(),
488            }
489        } else {
490            Matrix4::identity()
491        }
492    } else {
493        Matrix4::identity()
494    };
495
496    // RelativePlacement (attr 1) → local axis placement
497    let local_tf = if let Some(rel_attr) = placement.get(1) {
498        if !rel_attr.is_null() {
499            match decoder.resolve_ref(rel_attr) {
500                Ok(Some(rel)) if rel.ifc_type == IfcType::IfcAxis2Placement3D => {
501                    parse_axis2_placement_3d(&rel, decoder).unwrap_or(Matrix4::identity())
502                }
503                _ => Matrix4::identity(),
504            }
505        } else {
506            Matrix4::identity()
507        }
508    } else {
509        Matrix4::identity()
510    };
511
512    parent_tf * local_tf
513}
514
515// ═══════════════════════════════════════════════════════════════════════════
516// PRIVATE: IFC ENTITY PARSERS
517// Duplicated from processors/helpers.rs (pub(super) there).
518// ═══════════════════════════════════════════════════════════════════════════
519
520/// Parse IfcAxis2Placement3D → Matrix4<f64> in IFC Z-up space (native units).
521fn parse_axis2_placement_3d(
522    placement: &DecodedEntity,
523    decoder: &mut EntityDecoder,
524) -> Result<Matrix4<f64>> {
525    // Location (attr 0)
526    let location =
527        parse_cartesian_point(placement, decoder, 0).unwrap_or(Point3::new(0.0, 0.0, 0.0));
528
529    // Axis/Z direction (attr 1)
530    let z_axis = if let Some(a) = placement.get(1) {
531        if !a.is_null() {
532            decoder
533                .resolve_ref(a)?
534                .map(|e| parse_direction_entity(&e))
535                .transpose()?
536                .unwrap_or(Vector3::new(0.0, 0.0, 1.0))
537        } else {
538            Vector3::new(0.0, 0.0, 1.0)
539        }
540    } else {
541        Vector3::new(0.0, 0.0, 1.0)
542    };
543
544    // RefDirection/X (attr 2)
545    let x_axis_raw = if let Some(a) = placement.get(2) {
546        if !a.is_null() {
547            decoder
548                .resolve_ref(a)?
549                .map(|e| parse_direction_entity(&e))
550                .transpose()?
551                .unwrap_or(Vector3::new(1.0, 0.0, 0.0))
552        } else {
553            Vector3::new(1.0, 0.0, 0.0)
554        }
555    } else {
556        Vector3::new(1.0, 0.0, 0.0)
557    };
558
559    let z = z_axis.normalize();
560
561    // Gram–Schmidt: ensure X is orthogonal to Z
562    let dot = x_axis_raw.dot(&z);
563    let x_orth = x_axis_raw - z * dot;
564    let x = if x_orth.norm() > 1e-6 {
565        x_orth.normalize()
566    } else {
567        // Fallback if X and Z are nearly parallel
568        if z.z.abs() < 0.9 {
569            Vector3::new(0.0, 0.0, 1.0).cross(&z).normalize()
570        } else {
571            Vector3::new(1.0, 0.0, 0.0).cross(&z).normalize()
572        }
573    };
574    let y = z.cross(&x).normalize();
575
576    // Column-major construction: columns = [x | y | z | loc]
577    #[rustfmt::skip]
578    let m = Matrix4::new(
579        x.x, y.x, z.x, location.x,
580        x.y, y.y, z.y, location.y,
581        x.z, y.z, z.z, location.z,
582        0.0, 0.0, 0.0, 1.0,
583    );
584    Ok(m)
585}
586
587/// Parse IfcCartesianPoint from a parent entity at the given attribute index.
588fn parse_cartesian_point(
589    parent: &DecodedEntity,
590    decoder: &mut EntityDecoder,
591    attr_index: usize,
592) -> Result<Point3<f64>> {
593    let pt_attr = parent
594        .get(attr_index)
595        .ok_or_else(|| Error::geometry("Missing cartesian point attr"))?;
596
597    if pt_attr.is_null() {
598        return Ok(Point3::new(0.0, 0.0, 0.0));
599    }
600
601    let pt_entity = decoder
602        .resolve_ref(pt_attr)?
603        .ok_or_else(|| Error::geometry("Failed to resolve IfcCartesianPoint"))?;
604
605    let coords = pt_entity
606        .get(0)
607        .and_then(|a| a.as_list())
608        .ok_or_else(|| Error::geometry("IfcCartesianPoint missing coordinates"))?;
609
610    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
611    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
612    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
613
614    Ok(Point3::new(x, y, z))
615}
616
617/// Parse IfcDirection entity to a Vector3.
618fn parse_direction_entity(entity: &DecodedEntity) -> Result<Vector3<f64>> {
619    let ratios = entity
620        .get(0)
621        .and_then(|a| a.as_list())
622        .ok_or_else(|| Error::geometry("IfcDirection missing ratios"))?;
623
624    let x = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
625    let y = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
626    let z = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(1.0);
627
628    Ok(Vector3::new(x, y, z).normalize())
629}
630
631/// Parse IfcExtrudedAreaSolid ExtrudedDirection (attr 2) to a local Vector3.
632fn parse_extrusion_direction(solid: &DecodedEntity, decoder: &mut EntityDecoder) -> Vector3<f64> {
633    let default = Vector3::new(0.0, 0.0, 1.0);
634    let dir_attr = match solid.get(2) {
635        Some(a) if !a.is_null() => a,
636        _ => return default,
637    };
638    let dir_ent = match decoder.resolve_ref(dir_attr) {
639        Ok(Some(e)) => e,
640        _ => return default,
641    };
642    let ratios = match dir_ent.get(0).and_then(|a| a.as_list()) {
643        Some(r) => r,
644        None => return default,
645    };
646    let x = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
647    let y = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
648    let z = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(1.0);
649    let v = Vector3::new(x, y, z);
650    let len = v.norm();
651    if len > 1e-10 {
652        v / len
653    } else {
654        default
655    }
656}
657
658// ═══════════════════════════════════════════════════════════════════════════
659// PRIVATE: COORDINATE CONVERSION & UTILITIES
660// ═══════════════════════════════════════════════════════════════════════════
661
662/// Scale only the translation column of a matrix (rows 0-2 of column 3).
663fn scale_translation(mut m: Matrix4<f64>, scale: f64) -> Matrix4<f64> {
664    if scale != 1.0 {
665        m[(0, 3)] *= scale;
666        m[(1, 3)] *= scale;
667        m[(2, 3)] *= scale;
668    }
669    m
670}
671
672/// Convert an IFC Z-up Matrix4 to WebGL Y-up column-major [f32; 16].
673///
674/// Conversion: new_y = old_z, new_z = -old_y (swap Y/Z, negate new Z).
675/// Applied row-wise: row 0 stays, row 1 ← row 2, row 2 ← −row 1.
676fn convert_ifc_to_webgl(m: &Matrix4<f64>) -> [f32; 16] {
677    let mut result = [0.0f32; 16];
678    for col in 0..4 {
679        result[col * 4 + 0] = m[(0, col)] as f32; // X row: unchanged
680        result[col * 4 + 1] = m[(2, col)] as f32; // Y row: was Z
681        result[col * 4 + 2] = -m[(1, col)] as f32; // Z row: was -Y
682        result[col * 4 + 3] = m[(3, col)] as f32; // homogeneous
683    }
684    result
685}
686
687/// Detect the IFC length unit scale factor from IFCPROJECT.
688fn detect_unit_scale(content: &str, decoder: &mut EntityDecoder) -> f64 {
689    let mut scanner = EntityScanner::new(content);
690    while let Some((id, type_name, _, _)) = scanner.next_entity() {
691        if type_name == "IFCPROJECT" {
692            if let Ok(scale) = ifc_lite_core::extract_length_unit_scale(decoder, id) {
693                return scale;
694            }
695            break;
696        }
697    }
698    1.0
699}