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, TessellationQuality, Vector3};
22use ifc_lite_core::{
23    build_entity_index, AttributeValue, DecodedEntity, EntityDecoder, EntityScanner, IfcSchema,
24    IfcType,
25};
26use nalgebra::Matrix4;
27
28/// Whether `t` should be picked up by the constant-profile 2D drawing
29/// extractor.
30///
31/// `IfcExtrudedAreaSolidTapered` is intentionally **not** included here even
32/// though it is a subtype of `IfcExtrudedAreaSolid`: this extractor stores a
33/// single outer polygon, and a tapered solid has two distinct cross sections
34/// (`SweptArea` and `EndSweptArea`). Treating it as constant would draw the
35/// start profile only and silently under-report the element footprint. Until
36/// `ExtractedProfile` can carry both profiles (or their union/hull), tapered
37/// solids skip this path; their 3D mesh is still rendered by
38/// `ExtrudedAreaSolidTaperedProcessor`. Tracked as a follow-up to #628.
39#[inline]
40fn is_extruded_area_solid(t: IfcType) -> bool {
41    matches!(t, IfcType::IfcExtrudedAreaSolid)
42}
43
44// ═══════════════════════════════════════════════════════════════════════════
45// PUBLIC TYPES
46// ═══════════════════════════════════════════════════════════════════════════
47
48/// A profile extracted from a single IFC building element.
49///
50/// All geometry is in **WebGL Y-up world space** (metres).
51/// Applying `transform` to a local 2D point `[x, y, 0, 1]` gives the
52/// world-space 3D position.
53#[derive(Debug, Clone)]
54pub struct ExtractedProfile {
55    /// Express ID of the building element.
56    pub express_id: u32,
57    /// IFC type name (e.g., `"IfcWall"`).
58    pub ifc_type: String,
59    /// Outer boundary: interleaved `[x0, y0, x1, y1, …]` in local profile space (metres).
60    pub outer_points: Vec<f32>,
61    /// Number of points in each hole (one entry per hole).
62    pub hole_counts: Vec<u32>,
63    /// All hole points concatenated: `[x0, y0, x1, y1, …]` in local profile space (metres).
64    pub hole_points: Vec<f32>,
65    /// 4 × 4 column-major transform **in WebGL Y-up world space**.
66    /// `M * [x_2d, y_2d, 0, 1]ᵀ` → world position.
67    pub transform: [f32; 16],
68    /// Extrusion direction in WebGL Y-up world space (unit vector).
69    pub extrusion_dir: [f32; 3],
70    /// Extrusion depth in metres.
71    pub extrusion_depth: f32,
72    /// Model index (for multi-model federation).
73    pub model_index: u32,
74}
75
76// ═══════════════════════════════════════════════════════════════════════════
77// PUBLIC ENTRY POINT
78// ═══════════════════════════════════════════════════════════════════════════
79
80/// Extract profiles for every building element in `content`.
81///
82/// Extracts `IfcExtrudedAreaSolid` representations, including those nested
83/// inside `IfcMappedItem` chains (up to 3 levels deep).
84/// Returns an empty `Vec` for models with no such elements.
85pub fn extract_profiles<T>(content: &T, model_index: u32) -> Vec<ExtractedProfile>
86where
87    T: AsRef<[u8]> + ?Sized,
88{
89    let content = content.as_ref();
90    let entity_index = build_entity_index(content);
91    let mut decoder = EntityDecoder::with_index(content, entity_index);
92
93    // Detect unit scale (same approach as GeometryRouter::with_units)
94    let unit_scale = detect_unit_scale(content, &mut decoder);
95
96    let schema = IfcSchema::new();
97    let profile_processor = ProfileProcessor::new(schema);
98
99    let mut results = Vec::new();
100    let mut scanner = EntityScanner::new(content);
101
102    while let Some((id, type_name, start, end)) = scanner.next_entity() {
103        if !ifc_lite_core::has_geometry_by_name(type_name) {
104            continue;
105        }
106
107        let entity = match decoder.decode_at_with_id(id, start, end) {
108            Ok(e) => e,
109            Err(_) => continue,
110        };
111
112        // Issue #979: feature elements (IfcOpeningElement and the rest of the
113        // void/feature family) are boolean subtraction/addition operands, not
114        // building structure — they must never emit a construction-projection
115        // profile. `is_subtype_of` walks the supertype chain, so this single
116        // check covers Opening / Voiding / Earthworks / Projection / Surface
117        // features without touching IfcDoor/IfcWindow (which descend from
118        // IfcBuiltElement, not IfcFeatureElement).
119        if entity.ifc_type.is_subtype_of(IfcType::IfcFeatureElement) {
120            continue;
121        }
122
123        // ObjectPlacement (attr 5) → element world transform (IFC Z-up, native units)
124        let element_transform = get_placement_transform(entity.get(5), &mut decoder);
125
126        // Scale the translation part from file units to metres
127        let elem_tf = scale_translation(element_transform, unit_scale);
128
129        // Representation (attr 6) → IfcProductDefinitionShape
130        let repr_attr = match entity.get(6) {
131            Some(a) if !a.is_null() => a,
132            _ => continue,
133        };
134        let repr = match decoder.resolve_ref(repr_attr) {
135            Ok(Some(r)) => r,
136            _ => continue,
137        };
138
139        // IfcProductDefinitionShape → Representations (attr 2)
140        let reprs_attr = match repr.get(2) {
141            Some(a) => a,
142            None => continue,
143        };
144        let representations = match decoder.resolve_ref_list(reprs_attr) {
145            Ok(r) => r,
146            Err(_) => continue,
147        };
148
149        let ifc_type_name = entity.ifc_type.name().to_string();
150
151        for shape_rep in representations {
152            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
153                continue;
154            }
155
156            // Accept Body and SweptSolid representations
157            let rep_id = shape_rep.get(1).and_then(|a| a.as_string()).unwrap_or("");
158            if rep_id != "Body" && rep_id != "SweptSolid" {
159                continue;
160            }
161
162            // Items (attr 3)
163            let items_attr = match shape_rep.get(3) {
164                Some(a) => a,
165                None => continue,
166            };
167            let items = match decoder.resolve_ref_list(items_attr) {
168                Ok(i) => i,
169                Err(_) => continue,
170            };
171
172            for item in &items {
173                if is_extruded_area_solid(item.ifc_type) {
174                    match extract_extruded_solid(
175                        id,
176                        &ifc_type_name,
177                        item,
178                        &elem_tf,
179                        unit_scale,
180                        &profile_processor,
181                        &mut decoder,
182                        model_index,
183                    ) {
184                        Ok(entry) => results.push(entry),
185                        Err(_e) => {
186                            crate::diag::diag_debug!(
187                                { element_id = id, ifc_type = %ifc_type_name, error = %_e,
188                                  "profile_extractor: skipping element" }
189                                else {
190                                    #[cfg(feature = "debug_geometry")]
191                                    eprintln!("[profile_extractor] Skipping #{id} ({ifc_type_name}): {_e}");
192                                }
193                            );
194                        }
195                    }
196                } else if item.ifc_type == IfcType::IfcMappedItem {
197                    extract_mapped_item_profiles(
198                        id,
199                        &ifc_type_name,
200                        item,
201                        &elem_tf,
202                        unit_scale,
203                        &profile_processor,
204                        &mut decoder,
205                        model_index,
206                        0,
207                        &mut results,
208                    );
209                }
210            }
211        }
212    }
213
214    results
215}
216
217// ═══════════════════════════════════════════════════════════════════════════
218// PRIVATE: MAPPED ITEM EXTRACTION
219// ═══════════════════════════════════════════════════════════════════════════
220
221/// Maximum recursion depth for nested IfcMappedItem chains.
222const MAX_MAPPED_DEPTH: usize = 3;
223
224/// Recursively extract profiles from an IfcMappedItem.
225///
226/// IfcMappedItem structure:
227///   attr 0: MappingSource → IfcRepresentationMap
228///     attr 0: MappingOrigin (IfcAxis2Placement) — local coordinate system of shared geometry
229///     attr 1: MappedRepresentation (IfcRepresentation) → items to extract from
230///   attr 1: MappingTarget → IfcCartesianTransformationOperator3D (instance transform)
231///
232/// The composed transform is: `elem_transform * mapping_target`.
233/// Each solid's own Position is applied inside `extract_extruded_solid`.
234fn extract_mapped_item_profiles(
235    element_id: u32,
236    ifc_type: &str,
237    mapped_item: &DecodedEntity,
238    elem_transform: &Matrix4<f64>,
239    unit_scale: f64,
240    profile_processor: &ProfileProcessor,
241    decoder: &mut EntityDecoder,
242    model_index: u32,
243    depth: usize,
244    results: &mut Vec<ExtractedProfile>,
245) {
246    if depth > MAX_MAPPED_DEPTH {
247        crate::diag::diag_debug!(
248            { element_id, ifc_type = %ifc_type, max_depth = MAX_MAPPED_DEPTH,
249              "profile_extractor: max mapped item depth exceeded" }
250            else {
251                #[cfg(feature = "debug_geometry")]
252                eprintln!("[profile_extractor] #{element_id} ({ifc_type}): max mapped item depth exceeded");
253            }
254        );
255        return;
256    }
257
258    // Attr 0: MappingSource → IfcRepresentationMap
259    let source = match mapped_item
260        .get(0)
261        .and_then(|a| if a.is_null() { None } else { Some(a) })
262        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
263    {
264        Some(s) => s,
265        None => return,
266    };
267
268    // Attr 1: MappingTarget → IfcCartesianTransformationOperator3D.
269    // A PRESENT but unparseable target/origin abandons this mapped item instead of
270    // falling back to the identity: the mesh path propagates that failure and skips
271    // the item, so silently drawing the profile in an un-transformed frame would put
272    // the drawing somewhere the model isn't. Absent/null stays the identity.
273    let target_tf = match resolve_present_ref(mapped_item.get(1), decoder) {
274        Err(()) => return,
275        Ok(resolved) => match resolved {
276            Some(e) => match parse_cartesian_transformation_operator(&e, decoder) {
277                Ok(m) => m,
278                Err(_) => return,
279            },
280            None => Matrix4::identity(),
281        },
282    };
283
284    // Attr 0 of the RepresentationMap: MappingOrigin, the placement of the mapped
285    // items INSIDE the map. It composes innermost (`MappingTarget · MappingOrigin`),
286    // exactly as the mesh path now does — dropping it put every 2D profile of a
287    // non-identity-origin map at the wrong spot. #1985
288    // A map carrying a 2D representation writes an IfcAxis2Placement2D here, which
289    // the mesh path also honours — handling only the 3D form would leave exactly
290    // the plan/footprint maps this extractor exists for unfixed.
291    let origin_tf = match resolve_present_ref(source.get(0), decoder) {
292        Err(()) => return,
293        Ok(Some(e)) => {
294            let parsed = match e.ifc_type {
295                IfcType::IfcAxis2Placement3D => parse_axis2_placement_3d(&e, decoder).ok(),
296                IfcType::IfcAxis2Placement2D => parse_axis2_placement_2d(&e, decoder).ok(),
297                _ => None,
298            };
299            let Some(m) = parsed else { return };
300            m
301        }
302        Ok(None) => Matrix4::identity(),
303    };
304
305    // Scale the composed transform's translation from file units to metres
306    let scaled_target = scale_translation(target_tf * origin_tf, unit_scale);
307    let composed = elem_transform * scaled_target;
308
309    // MappedRepresentation (attr 1 of RepresentationMap) → items
310    let mapped_rep = match source
311        .get(1)
312        .and_then(|a| if a.is_null() { None } else { Some(a) })
313        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
314    {
315        Some(r) => r,
316        None => return,
317    };
318
319    let items = match mapped_rep
320        .get(3)
321        .and_then(|a| decoder.resolve_ref_list(a).ok())
322    {
323        Some(i) => i,
324        None => return,
325    };
326
327    for sub_item in &items {
328        if is_extruded_area_solid(sub_item.ifc_type) {
329            match extract_extruded_solid(
330                element_id,
331                ifc_type,
332                sub_item,
333                &composed,
334                unit_scale,
335                profile_processor,
336                decoder,
337                model_index,
338            ) {
339                Ok(entry) => results.push(entry),
340                Err(_e) => {
341                    crate::diag::diag_debug!(
342                        { element_id, ifc_type = %ifc_type, error = %_e,
343                          "profile_extractor: skipping mapped item solid" }
344                        else {
345                            #[cfg(feature = "debug_geometry")]
346                            eprintln!("[profile_extractor] #{element_id} ({ifc_type}) mapped: {_e}");
347                        }
348                    );
349                }
350            }
351        } else if sub_item.ifc_type == IfcType::IfcMappedItem {
352            extract_mapped_item_profiles(
353                element_id,
354                ifc_type,
355                sub_item,
356                &composed,
357                unit_scale,
358                profile_processor,
359                decoder,
360                model_index,
361                depth + 1,
362                results,
363            );
364        }
365    }
366}
367
368/// Parse an `IfcCartesianTransformationOperator` (2D or 3D, uniform or not).
369///
370/// Delegates to the router's parser so the 2D drawing profiles and the 3D mesh
371/// can never disagree about the same `MappingTarget`. This file used to carry a
372/// private copy, which had drifted: it ignored the non-uniform per-axis scales,
373/// the 2D attribute layout, and `Axis2`. #1985
374fn parse_cartesian_transformation_operator(
375    entity: &DecodedEntity,
376    decoder: &mut EntityDecoder,
377) -> Result<Matrix4<f64>> {
378    crate::router::transforms::operator::parse_transformation_operator(entity, decoder)
379}
380
381// ═══════════════════════════════════════════════════════════════════════════
382// PRIVATE: SOLID EXTRACTION
383// ═══════════════════════════════════════════════════════════════════════════
384
385fn extract_extruded_solid(
386    element_id: u32,
387    ifc_type: &str,
388    solid: &DecodedEntity,
389    elem_transform: &Matrix4<f64>,
390    unit_scale: f64,
391    profile_processor: &ProfileProcessor,
392    decoder: &mut EntityDecoder,
393    model_index: u32,
394) -> Result<ExtractedProfile> {
395    // SweptArea (attr 0)
396    let profile_attr = solid
397        .get(0)
398        .ok_or_else(|| Error::geometry("ExtrudedAreaSolid missing SweptArea"))?;
399    let profile_entity = decoder
400        .resolve_ref(profile_attr)?
401        .ok_or_else(|| Error::geometry("Failed to resolve SweptArea"))?;
402    // Profile extraction feeds 2D drawing projection, not the tessellation-quality
403    // render path; sample at the historical default.
404    let profile =
405        profile_processor.process(&profile_entity, decoder, TessellationQuality::Medium)?;
406
407    if profile.outer.is_empty() {
408        return Err(Error::geometry("empty profile"));
409    }
410
411    // Position (attr 1) → solid local transform in IFC native units
412    let solid_transform = if let Some(pos_attr) = solid.get(1) {
413        if !pos_attr.is_null() {
414            if let Some(pos_ent) = decoder.resolve_ref(pos_attr)? {
415                if pos_ent.ifc_type == IfcType::IfcAxis2Placement3D {
416                    let mut t = parse_axis2_placement_3d(&pos_ent, decoder)?;
417                    // Scale translation from file units to metres
418                    t[(0, 3)] *= unit_scale;
419                    t[(1, 3)] *= unit_scale;
420                    t[(2, 3)] *= unit_scale;
421                    t
422                } else {
423                    Matrix4::identity()
424                }
425            } else {
426                Matrix4::identity()
427            }
428        } else {
429            Matrix4::identity()
430        }
431    } else {
432        Matrix4::identity()
433    };
434
435    // ExtrudedDirection (attr 2) in local solid space
436    let local_dir = parse_extrusion_direction(solid, decoder);
437
438    // Depth (attr 3) — required per IFC spec but default to 1.0 for robustness
439    // with malformed files (logged under debug_geometry feature)
440    let raw_depth = solid.get(3).and_then(|v| v.as_float());
441    #[cfg(any(feature = "debug_geometry", feature = "observability"))]
442    if raw_depth.is_none() {
443        crate::diag::diag_debug!(
444            { element_id, ifc_type = %ifc_type,
445              "profile_extractor: missing Depth, defaulting to 1.0" }
446            else {
447                #[cfg(feature = "debug_geometry")]
448                eprintln!(
449                    "[profile_extractor] #{element_id} ({ifc_type}): missing Depth, defaulting to 1.0"
450                );
451            }
452        );
453    }
454    let depth = raw_depth.unwrap_or(1.0) * unit_scale;
455
456    // Combined transform: elem_placement * solid_position  (IFC Z-up, metres)
457    let combined_ifc = elem_transform * solid_transform;
458
459    // Convert combined transform to WebGL Y-up column-major [f32; 16]
460    let transform = convert_ifc_to_webgl(&combined_ifc);
461
462    // Transform local extrusion direction to world IFC space (rotation only, no translation)
463    let world_dir_ifc = combined_ifc.transform_vector(&local_dir);
464
465    // Convert world direction to WebGL Y-up
466    let extrusion_dir = [
467        world_dir_ifc.x as f32,
468        world_dir_ifc.z as f32,  // WebGL Y = IFC Z
469        -world_dir_ifc.y as f32, // WebGL Z = -IFC Y
470    ];
471
472    // Scale profile 2D points from file units to metres
473    let outer_points: Vec<f32> = profile
474        .outer
475        .iter()
476        .flat_map(|p| [(p.x * unit_scale) as f32, (p.y * unit_scale) as f32])
477        .collect();
478
479    let hole_counts: Vec<u32> = profile.holes.iter().map(|h| h.len() as u32).collect();
480    let hole_points: Vec<f32> = profile
481        .holes
482        .iter()
483        .flat_map(|h| {
484            h.iter()
485                .flat_map(|p| [(p.x * unit_scale) as f32, (p.y * unit_scale) as f32])
486        })
487        .collect();
488
489    Ok(ExtractedProfile {
490        express_id: element_id,
491        ifc_type: ifc_type.to_string(),
492        outer_points,
493        hole_counts,
494        hole_points,
495        transform,
496        extrusion_dir,
497        extrusion_depth: depth as f32,
498        model_index,
499    })
500}
501
502// ═══════════════════════════════════════════════════════════════════════════
503// PRIVATE: PLACEMENT TRAVERSAL
504// Duplicated from router/transforms.rs (pub(super) there) to avoid coupling.
505// ═══════════════════════════════════════════════════════════════════════════
506
507/// Resolve an element's ObjectPlacement attribute to a world Matrix4 in IFC Z-up space.
508fn get_placement_transform(
509    placement_attr: Option<&AttributeValue>,
510    decoder: &mut EntityDecoder,
511) -> Matrix4<f64> {
512    let attr = match placement_attr {
513        Some(a) if !a.is_null() => a,
514        _ => return Matrix4::identity(),
515    };
516    match decoder.resolve_ref(attr) {
517        Ok(Some(p)) => get_placement_recursive(&p, decoder, 0),
518        _ => Matrix4::identity(),
519    }
520}
521
522const MAX_PLACEMENT_DEPTH: usize = 100;
523
524fn get_placement_recursive(
525    placement: &DecodedEntity,
526    decoder: &mut EntityDecoder,
527    depth: usize,
528) -> Matrix4<f64> {
529    if depth > MAX_PLACEMENT_DEPTH || placement.ifc_type != IfcType::IfcLocalPlacement {
530        return Matrix4::identity();
531    }
532
533    // PlacementRelTo (attr 0) → parent transform
534    let parent_tf = if let Some(parent_attr) = placement.get(0) {
535        if !parent_attr.is_null() {
536            match decoder.resolve_ref(parent_attr) {
537                Ok(Some(parent)) => get_placement_recursive(&parent, decoder, depth + 1),
538                _ => Matrix4::identity(),
539            }
540        } else {
541            Matrix4::identity()
542        }
543    } else {
544        Matrix4::identity()
545    };
546
547    // RelativePlacement (attr 1) → local axis placement
548    let local_tf = if let Some(rel_attr) = placement.get(1) {
549        if !rel_attr.is_null() {
550            match decoder.resolve_ref(rel_attr) {
551                Ok(Some(rel)) if rel.ifc_type == IfcType::IfcAxis2Placement3D => {
552                    parse_axis2_placement_3d(&rel, decoder).unwrap_or(Matrix4::identity())
553                }
554                _ => Matrix4::identity(),
555            }
556        } else {
557            Matrix4::identity()
558        }
559    } else {
560        Matrix4::identity()
561    };
562
563    parent_tf * local_tf
564}
565
566// ═══════════════════════════════════════════════════════════════════════════
567// PRIVATE: IFC ENTITY PARSERS
568// Duplicated from processors/helpers.rs (pub(super) there).
569// ═══════════════════════════════════════════════════════════════════════════
570
571/// Parse IfcAxis2Placement3D → Matrix4<f64> in IFC Z-up space (native units).
572/// Resolve an OPTIONAL entity reference, distinguishing the three cases a mapped
573/// item's `MappingTarget` / `MappingOrigin` can be in: absent or explicitly null
574/// (`Ok(None)` — the identity is correct), resolvable (`Ok(Some)`), or PRESENT
575/// but dangling / unreadable (`Err` — the caller must abandon the item, because
576/// the mesh path errors out on it too and silently substituting the identity
577/// would draw the profile in an un-transformed frame). #1985
578fn resolve_present_ref(
579    attr: Option<&AttributeValue>,
580    decoder: &mut EntityDecoder,
581) -> std::result::Result<Option<DecodedEntity>, ()> {
582    match attr {
583        None => Ok(None),
584        Some(a) if a.is_null() => Ok(None),
585        Some(a) => match decoder.resolve_ref(a) {
586            Ok(Some(e)) => Ok(Some(e)),
587            _ => Err(()),
588        },
589    }
590}
591
592/// Parse `IfcAxis2Placement2D` into a 4x4 acting in the XY plane. Delegates to
593/// the router's definition so the 2D drawing path and the mesh path cannot
594/// drift on a 2D `MappingOrigin`. #1985
595fn parse_axis2_placement_2d(
596    placement: &DecodedEntity,
597    decoder: &mut EntityDecoder,
598) -> Result<Matrix4<f64>> {
599    crate::router::transforms::mapped::axis2_placement_2d_matrix(placement, decoder)
600}
601
602fn parse_axis2_placement_3d(
603    placement: &DecodedEntity,
604    decoder: &mut EntityDecoder,
605) -> Result<Matrix4<f64>> {
606    // Location (attr 0)
607    let location =
608        parse_cartesian_point(placement, decoder, 0).unwrap_or(Point3::new(0.0, 0.0, 0.0));
609
610    // Axis/Z direction (attr 1)
611    let z_axis = if let Some(a) = placement.get(1) {
612        if !a.is_null() {
613            decoder
614                .resolve_ref(a)?
615                .map(|e| parse_direction_entity(&e))
616                .transpose()?
617                .unwrap_or(Vector3::new(0.0, 0.0, 1.0))
618        } else {
619            Vector3::new(0.0, 0.0, 1.0)
620        }
621    } else {
622        Vector3::new(0.0, 0.0, 1.0)
623    };
624
625    // RefDirection/X (attr 2)
626    let x_axis_raw = if let Some(a) = placement.get(2) {
627        if !a.is_null() {
628            decoder
629                .resolve_ref(a)?
630                .map(|e| parse_direction_entity(&e))
631                .transpose()?
632                .unwrap_or(Vector3::new(1.0, 0.0, 0.0))
633        } else {
634            Vector3::new(1.0, 0.0, 0.0)
635        }
636    } else {
637        Vector3::new(1.0, 0.0, 0.0)
638    };
639
640    // Orthonormalize + assemble via the shared builder (canonical Gram–Schmidt
641    // with the degenerate-axis fallback baked in).
642    Ok(crate::transform::build_axis2_matrix(location, z_axis, x_axis_raw))
643}
644
645/// Parse IfcCartesianPoint from a parent entity at the given attribute index.
646fn parse_cartesian_point(
647    parent: &DecodedEntity,
648    decoder: &mut EntityDecoder,
649    attr_index: usize,
650) -> Result<Point3<f64>> {
651    let pt_attr = parent
652        .get(attr_index)
653        .ok_or_else(|| Error::geometry("Missing cartesian point attr"))?;
654
655    if pt_attr.is_null() {
656        return Ok(Point3::new(0.0, 0.0, 0.0));
657    }
658
659    let pt_entity = decoder
660        .resolve_ref(pt_attr)?
661        .ok_or_else(|| Error::geometry("Failed to resolve IfcCartesianPoint"))?;
662
663    let coords = pt_entity
664        .get(0)
665        .and_then(|a| a.as_list())
666        .ok_or_else(|| Error::geometry("IfcCartesianPoint missing coordinates"))?;
667
668    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
669    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
670    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
671
672    Ok(Point3::new(x, y, z))
673}
674
675/// Parse IfcDirection entity to a Vector3.
676fn parse_direction_entity(entity: &DecodedEntity) -> Result<Vector3<f64>> {
677    let ratios = entity
678        .get(0)
679        .and_then(|a| a.as_list())
680        .ok_or_else(|| Error::geometry("IfcDirection missing ratios"))?;
681
682    let x = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
683    let y = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
684    let z = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(1.0);
685
686    Ok(Vector3::new(x, y, z).normalize())
687}
688
689/// Parse IfcExtrudedAreaSolid ExtrudedDirection (attr 2) to a local Vector3.
690fn parse_extrusion_direction(solid: &DecodedEntity, decoder: &mut EntityDecoder) -> Vector3<f64> {
691    let default = Vector3::new(0.0, 0.0, 1.0);
692    let dir_attr = match solid.get(2) {
693        Some(a) if !a.is_null() => a,
694        _ => return default,
695    };
696    let dir_ent = match decoder.resolve_ref(dir_attr) {
697        Ok(Some(e)) => e,
698        _ => return default,
699    };
700    let ratios = match dir_ent.get(0).and_then(|a| a.as_list()) {
701        Some(r) => r,
702        None => return default,
703    };
704    let x = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
705    let y = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
706    let z = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(1.0);
707    let v = Vector3::new(x, y, z);
708    let len = v.norm();
709    if len > 1e-10 {
710        v / len
711    } else {
712        default
713    }
714}
715
716// ═══════════════════════════════════════════════════════════════════════════
717// PRIVATE: COORDINATE CONVERSION & UTILITIES
718// ═══════════════════════════════════════════════════════════════════════════
719
720/// Scale only the translation column of a matrix (rows 0-2 of column 3).
721fn scale_translation(mut m: Matrix4<f64>, scale: f64) -> Matrix4<f64> {
722    if scale != 1.0 {
723        m[(0, 3)] *= scale;
724        m[(1, 3)] *= scale;
725        m[(2, 3)] *= scale;
726    }
727    m
728}
729
730/// Convert an IFC Z-up Matrix4 to WebGL Y-up column-major [f32; 16].
731///
732/// Conversion: new_y = old_z, new_z = -old_y (swap Y/Z, negate new Z).
733/// Applied row-wise: row 0 stays, row 1 ← row 2, row 2 ← −row 1.
734fn convert_ifc_to_webgl(m: &Matrix4<f64>) -> [f32; 16] {
735    let mut result = [0.0f32; 16];
736    for col in 0..4 {
737        result[col * 4] = m[(0, col)] as f32; // X row: unchanged
738        result[col * 4 + 1] = m[(2, col)] as f32; // Y row: was Z
739        result[col * 4 + 2] = -m[(1, col)] as f32; // Z row: was -Y
740        result[col * 4 + 3] = m[(3, col)] as f32; // homogeneous
741    }
742    result
743}
744
745/// Detect the IFC length unit scale factor from IFCPROJECT.
746fn detect_unit_scale(content: &[u8], decoder: &mut EntityDecoder) -> f64 {
747    let mut scanner = EntityScanner::new(content);
748    while let Some((id, type_name, _, _)) = scanner.next_entity() {
749        if type_name == "IFCPROJECT" {
750            if let Ok(scale) = ifc_lite_core::extract_length_unit_scale(decoder, id) {
751                return scale;
752            }
753            break;
754        }
755    }
756    1.0
757}