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    let target_tf = mapped_item
270        .get(1)
271        .and_then(|a| if a.is_null() { None } else { Some(a) })
272        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
273        .and_then(|e| parse_cartesian_transformation_operator(&e, decoder).ok())
274        .unwrap_or_else(Matrix4::identity);
275
276    // Scale the target transform translation from file units to metres
277    let scaled_target = scale_translation(target_tf, unit_scale);
278    let composed = elem_transform * scaled_target;
279
280    // MappedRepresentation (attr 1 of RepresentationMap) → items
281    let mapped_rep = match source
282        .get(1)
283        .and_then(|a| if a.is_null() { None } else { Some(a) })
284        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
285    {
286        Some(r) => r,
287        None => return,
288    };
289
290    let items = match mapped_rep
291        .get(3)
292        .and_then(|a| decoder.resolve_ref_list(a).ok())
293    {
294        Some(i) => i,
295        None => return,
296    };
297
298    for sub_item in &items {
299        if is_extruded_area_solid(sub_item.ifc_type) {
300            match extract_extruded_solid(
301                element_id,
302                ifc_type,
303                sub_item,
304                &composed,
305                unit_scale,
306                profile_processor,
307                decoder,
308                model_index,
309            ) {
310                Ok(entry) => results.push(entry),
311                Err(_e) => {
312                    crate::diag::diag_debug!(
313                        { element_id, ifc_type = %ifc_type, error = %_e,
314                          "profile_extractor: skipping mapped item solid" }
315                        else {
316                            #[cfg(feature = "debug_geometry")]
317                            eprintln!("[profile_extractor] #{element_id} ({ifc_type}) mapped: {_e}");
318                        }
319                    );
320                }
321            }
322        } else if sub_item.ifc_type == IfcType::IfcMappedItem {
323            extract_mapped_item_profiles(
324                element_id,
325                ifc_type,
326                sub_item,
327                &composed,
328                unit_scale,
329                profile_processor,
330                decoder,
331                model_index,
332                depth + 1,
333                results,
334            );
335        }
336    }
337}
338
339/// Parse IfcCartesianTransformationOperator3D into a Matrix4<f64>.
340///
341/// Attributes:
342///   0: Axis1 (X direction, optional)
343///   1: Axis2 (Y direction, optional)
344///   2: LocalOrigin (IfcCartesianPoint)
345///   3: Scale (f64, default 1.0)
346///   4: Axis3 (Z direction, optional, 3D only)
347fn parse_cartesian_transformation_operator(
348    entity: &DecodedEntity,
349    decoder: &mut EntityDecoder,
350) -> Result<Matrix4<f64>> {
351    // LocalOrigin (attr 2)
352    let origin = parse_cartesian_point(entity, decoder, 2).unwrap_or(Point3::new(0.0, 0.0, 0.0));
353
354    // Scale (attr 3)
355    let scale = entity.get(3).and_then(|v| v.as_float()).unwrap_or(1.0);
356
357    // Axis1 / X direction (attr 0)
358    let x_axis = entity
359        .get(0)
360        .filter(|a| !a.is_null())
361        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
362        .and_then(|e| parse_direction_entity(&e).ok())
363        .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0))
364        .normalize();
365
366    // Axis3 / Z direction (attr 4, 3D only)
367    let z_axis = entity
368        .get(4)
369        .filter(|a| !a.is_null())
370        .and_then(|a| decoder.resolve_ref(a).ok().flatten())
371        .and_then(|e| parse_direction_entity(&e).ok())
372        .unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0))
373        .normalize();
374
375    // Derive orthogonal axes (right-hand system)
376    let y_axis = z_axis.cross(&x_axis).normalize();
377    let x_axis = y_axis.cross(&z_axis).normalize();
378
379    #[rustfmt::skip]
380    let m = Matrix4::new(
381        x_axis.x * scale, y_axis.x * scale, z_axis.x * scale, origin.x,
382        x_axis.y * scale, y_axis.y * scale, z_axis.y * scale, origin.y,
383        x_axis.z * scale, y_axis.z * scale, z_axis.z * scale, origin.z,
384        0.0,              0.0,              0.0,              1.0,
385    );
386    Ok(m)
387}
388
389// ═══════════════════════════════════════════════════════════════════════════
390// PRIVATE: SOLID EXTRACTION
391// ═══════════════════════════════════════════════════════════════════════════
392
393fn extract_extruded_solid(
394    element_id: u32,
395    ifc_type: &str,
396    solid: &DecodedEntity,
397    elem_transform: &Matrix4<f64>,
398    unit_scale: f64,
399    profile_processor: &ProfileProcessor,
400    decoder: &mut EntityDecoder,
401    model_index: u32,
402) -> Result<ExtractedProfile> {
403    // SweptArea (attr 0)
404    let profile_attr = solid
405        .get(0)
406        .ok_or_else(|| Error::geometry("ExtrudedAreaSolid missing SweptArea"))?;
407    let profile_entity = decoder
408        .resolve_ref(profile_attr)?
409        .ok_or_else(|| Error::geometry("Failed to resolve SweptArea"))?;
410    // Profile extraction feeds 2D drawing projection, not the tessellation-quality
411    // render path; sample at the historical default.
412    let profile =
413        profile_processor.process(&profile_entity, decoder, TessellationQuality::Medium)?;
414
415    if profile.outer.is_empty() {
416        return Err(Error::geometry("empty profile"));
417    }
418
419    // Position (attr 1) → solid local transform in IFC native units
420    let solid_transform = if let Some(pos_attr) = solid.get(1) {
421        if !pos_attr.is_null() {
422            if let Some(pos_ent) = decoder.resolve_ref(pos_attr)? {
423                if pos_ent.ifc_type == IfcType::IfcAxis2Placement3D {
424                    let mut t = parse_axis2_placement_3d(&pos_ent, decoder)?;
425                    // Scale translation from file units to metres
426                    t[(0, 3)] *= unit_scale;
427                    t[(1, 3)] *= unit_scale;
428                    t[(2, 3)] *= unit_scale;
429                    t
430                } else {
431                    Matrix4::identity()
432                }
433            } else {
434                Matrix4::identity()
435            }
436        } else {
437            Matrix4::identity()
438        }
439    } else {
440        Matrix4::identity()
441    };
442
443    // ExtrudedDirection (attr 2) in local solid space
444    let local_dir = parse_extrusion_direction(solid, decoder);
445
446    // Depth (attr 3) — required per IFC spec but default to 1.0 for robustness
447    // with malformed files (logged under debug_geometry feature)
448    let raw_depth = solid.get(3).and_then(|v| v.as_float());
449    #[cfg(any(feature = "debug_geometry", feature = "observability"))]
450    if raw_depth.is_none() {
451        crate::diag::diag_debug!(
452            { element_id, ifc_type = %ifc_type,
453              "profile_extractor: missing Depth, defaulting to 1.0" }
454            else {
455                #[cfg(feature = "debug_geometry")]
456                eprintln!(
457                    "[profile_extractor] #{element_id} ({ifc_type}): missing Depth, defaulting to 1.0"
458                );
459            }
460        );
461    }
462    let depth = raw_depth.unwrap_or(1.0) * unit_scale;
463
464    // Combined transform: elem_placement * solid_position  (IFC Z-up, metres)
465    let combined_ifc = elem_transform * solid_transform;
466
467    // Convert combined transform to WebGL Y-up column-major [f32; 16]
468    let transform = convert_ifc_to_webgl(&combined_ifc);
469
470    // Transform local extrusion direction to world IFC space (rotation only, no translation)
471    let world_dir_ifc = combined_ifc.transform_vector(&local_dir);
472
473    // Convert world direction to WebGL Y-up
474    let extrusion_dir = [
475        world_dir_ifc.x as f32,
476        world_dir_ifc.z as f32,  // WebGL Y = IFC Z
477        -world_dir_ifc.y as f32, // WebGL Z = -IFC Y
478    ];
479
480    // Scale profile 2D points from file units to metres
481    let outer_points: Vec<f32> = profile
482        .outer
483        .iter()
484        .flat_map(|p| [(p.x * unit_scale) as f32, (p.y * unit_scale) as f32])
485        .collect();
486
487    let hole_counts: Vec<u32> = profile.holes.iter().map(|h| h.len() as u32).collect();
488    let hole_points: Vec<f32> = profile
489        .holes
490        .iter()
491        .flat_map(|h| {
492            h.iter()
493                .flat_map(|p| [(p.x * unit_scale) as f32, (p.y * unit_scale) as f32])
494        })
495        .collect();
496
497    Ok(ExtractedProfile {
498        express_id: element_id,
499        ifc_type: ifc_type.to_string(),
500        outer_points,
501        hole_counts,
502        hole_points,
503        transform,
504        extrusion_dir,
505        extrusion_depth: depth as f32,
506        model_index,
507    })
508}
509
510// ═══════════════════════════════════════════════════════════════════════════
511// PRIVATE: PLACEMENT TRAVERSAL
512// Duplicated from router/transforms.rs (pub(super) there) to avoid coupling.
513// ═══════════════════════════════════════════════════════════════════════════
514
515/// Resolve an element's ObjectPlacement attribute to a world Matrix4 in IFC Z-up space.
516fn get_placement_transform(
517    placement_attr: Option<&AttributeValue>,
518    decoder: &mut EntityDecoder,
519) -> Matrix4<f64> {
520    let attr = match placement_attr {
521        Some(a) if !a.is_null() => a,
522        _ => return Matrix4::identity(),
523    };
524    match decoder.resolve_ref(attr) {
525        Ok(Some(p)) => get_placement_recursive(&p, decoder, 0),
526        _ => Matrix4::identity(),
527    }
528}
529
530const MAX_PLACEMENT_DEPTH: usize = 100;
531
532fn get_placement_recursive(
533    placement: &DecodedEntity,
534    decoder: &mut EntityDecoder,
535    depth: usize,
536) -> Matrix4<f64> {
537    if depth > MAX_PLACEMENT_DEPTH || placement.ifc_type != IfcType::IfcLocalPlacement {
538        return Matrix4::identity();
539    }
540
541    // PlacementRelTo (attr 0) → parent transform
542    let parent_tf = if let Some(parent_attr) = placement.get(0) {
543        if !parent_attr.is_null() {
544            match decoder.resolve_ref(parent_attr) {
545                Ok(Some(parent)) => get_placement_recursive(&parent, decoder, depth + 1),
546                _ => Matrix4::identity(),
547            }
548        } else {
549            Matrix4::identity()
550        }
551    } else {
552        Matrix4::identity()
553    };
554
555    // RelativePlacement (attr 1) → local axis placement
556    let local_tf = if let Some(rel_attr) = placement.get(1) {
557        if !rel_attr.is_null() {
558            match decoder.resolve_ref(rel_attr) {
559                Ok(Some(rel)) if rel.ifc_type == IfcType::IfcAxis2Placement3D => {
560                    parse_axis2_placement_3d(&rel, decoder).unwrap_or(Matrix4::identity())
561                }
562                _ => Matrix4::identity(),
563            }
564        } else {
565            Matrix4::identity()
566        }
567    } else {
568        Matrix4::identity()
569    };
570
571    parent_tf * local_tf
572}
573
574// ═══════════════════════════════════════════════════════════════════════════
575// PRIVATE: IFC ENTITY PARSERS
576// Duplicated from processors/helpers.rs (pub(super) there).
577// ═══════════════════════════════════════════════════════════════════════════
578
579/// Parse IfcAxis2Placement3D → Matrix4<f64> in IFC Z-up space (native units).
580fn parse_axis2_placement_3d(
581    placement: &DecodedEntity,
582    decoder: &mut EntityDecoder,
583) -> Result<Matrix4<f64>> {
584    // Location (attr 0)
585    let location =
586        parse_cartesian_point(placement, decoder, 0).unwrap_or(Point3::new(0.0, 0.0, 0.0));
587
588    // Axis/Z direction (attr 1)
589    let z_axis = if let Some(a) = placement.get(1) {
590        if !a.is_null() {
591            decoder
592                .resolve_ref(a)?
593                .map(|e| parse_direction_entity(&e))
594                .transpose()?
595                .unwrap_or(Vector3::new(0.0, 0.0, 1.0))
596        } else {
597            Vector3::new(0.0, 0.0, 1.0)
598        }
599    } else {
600        Vector3::new(0.0, 0.0, 1.0)
601    };
602
603    // RefDirection/X (attr 2)
604    let x_axis_raw = if let Some(a) = placement.get(2) {
605        if !a.is_null() {
606            decoder
607                .resolve_ref(a)?
608                .map(|e| parse_direction_entity(&e))
609                .transpose()?
610                .unwrap_or(Vector3::new(1.0, 0.0, 0.0))
611        } else {
612            Vector3::new(1.0, 0.0, 0.0)
613        }
614    } else {
615        Vector3::new(1.0, 0.0, 0.0)
616    };
617
618    // Orthonormalize + assemble via the shared builder (canonical Gram–Schmidt
619    // with the degenerate-axis fallback baked in).
620    Ok(crate::transform::build_axis2_matrix(location, z_axis, x_axis_raw))
621}
622
623/// Parse IfcCartesianPoint from a parent entity at the given attribute index.
624fn parse_cartesian_point(
625    parent: &DecodedEntity,
626    decoder: &mut EntityDecoder,
627    attr_index: usize,
628) -> Result<Point3<f64>> {
629    let pt_attr = parent
630        .get(attr_index)
631        .ok_or_else(|| Error::geometry("Missing cartesian point attr"))?;
632
633    if pt_attr.is_null() {
634        return Ok(Point3::new(0.0, 0.0, 0.0));
635    }
636
637    let pt_entity = decoder
638        .resolve_ref(pt_attr)?
639        .ok_or_else(|| Error::geometry("Failed to resolve IfcCartesianPoint"))?;
640
641    let coords = pt_entity
642        .get(0)
643        .and_then(|a| a.as_list())
644        .ok_or_else(|| Error::geometry("IfcCartesianPoint missing coordinates"))?;
645
646    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
647    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
648    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
649
650    Ok(Point3::new(x, y, z))
651}
652
653/// Parse IfcDirection entity to a Vector3.
654fn parse_direction_entity(entity: &DecodedEntity) -> Result<Vector3<f64>> {
655    let ratios = entity
656        .get(0)
657        .and_then(|a| a.as_list())
658        .ok_or_else(|| Error::geometry("IfcDirection missing ratios"))?;
659
660    let x = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
661    let y = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
662    let z = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(1.0);
663
664    Ok(Vector3::new(x, y, z).normalize())
665}
666
667/// Parse IfcExtrudedAreaSolid ExtrudedDirection (attr 2) to a local Vector3.
668fn parse_extrusion_direction(solid: &DecodedEntity, decoder: &mut EntityDecoder) -> Vector3<f64> {
669    let default = Vector3::new(0.0, 0.0, 1.0);
670    let dir_attr = match solid.get(2) {
671        Some(a) if !a.is_null() => a,
672        _ => return default,
673    };
674    let dir_ent = match decoder.resolve_ref(dir_attr) {
675        Ok(Some(e)) => e,
676        _ => return default,
677    };
678    let ratios = match dir_ent.get(0).and_then(|a| a.as_list()) {
679        Some(r) => r,
680        None => return default,
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    let v = Vector3::new(x, y, z);
686    let len = v.norm();
687    if len > 1e-10 {
688        v / len
689    } else {
690        default
691    }
692}
693
694// ═══════════════════════════════════════════════════════════════════════════
695// PRIVATE: COORDINATE CONVERSION & UTILITIES
696// ═══════════════════════════════════════════════════════════════════════════
697
698/// Scale only the translation column of a matrix (rows 0-2 of column 3).
699fn scale_translation(mut m: Matrix4<f64>, scale: f64) -> Matrix4<f64> {
700    if scale != 1.0 {
701        m[(0, 3)] *= scale;
702        m[(1, 3)] *= scale;
703        m[(2, 3)] *= scale;
704    }
705    m
706}
707
708/// Convert an IFC Z-up Matrix4 to WebGL Y-up column-major [f32; 16].
709///
710/// Conversion: new_y = old_z, new_z = -old_y (swap Y/Z, negate new Z).
711/// Applied row-wise: row 0 stays, row 1 ← row 2, row 2 ← −row 1.
712fn convert_ifc_to_webgl(m: &Matrix4<f64>) -> [f32; 16] {
713    let mut result = [0.0f32; 16];
714    for col in 0..4 {
715        result[col * 4] = m[(0, col)] as f32; // X row: unchanged
716        result[col * 4 + 1] = m[(2, col)] as f32; // Y row: was Z
717        result[col * 4 + 2] = -m[(1, col)] as f32; // Z row: was -Y
718        result[col * 4 + 3] = m[(3, col)] as f32; // homogeneous
719    }
720    result
721}
722
723/// Detect the IFC length unit scale factor from IFCPROJECT.
724fn detect_unit_scale(content: &[u8], decoder: &mut EntityDecoder) -> f64 {
725    let mut scanner = EntityScanner::new(content);
726    while let Some((id, type_name, _, _)) = scanner.next_entity() {
727        if type_name == "IFCPROJECT" {
728            if let Ok(scale) = ifc_lite_core::extract_length_unit_scale(decoder, id) {
729                return scale;
730            }
731            break;
732        }
733    }
734    1.0
735}