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