ifc_lite_export/
step_map_transform.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Opt-in coordinate compatibility export: move a uniform map similarity into
3//! logical product placements and mapped body geometry, preserving map points.
4
5use std::collections::{BTreeMap, HashMap};
6use ifc_lite_core::{EntityDecoder, EntityScanner, GeoRefExtractor, IfcType};
7use ifc_lite_geometry::GeometryRouter;
8use nalgebra::Matrix4;
9use serde::Serialize;
10
11#[path = "step_map_transform_preflight.rs"]
12pub(crate) mod preflight;
13#[path = "step_map_transform_writer.rs"]
14mod writer;
15#[path = "step_map_rigid.rs"]
16mod rigid;
17#[path = "step_map_rigid_ownership.rs"]
18mod rigid_ownership;
19#[path = "step_map_rigid_contexts.rs"]
20mod rigid_contexts;
21#[path = "step_map_context_metadata.rs"]
22mod context_metadata;
23
24/// A complete replacement or newly allocated STEP entity. IDs let the host
25/// exporter settle its existing modification ledger without counting edits twice.
26#[derive(Debug, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct MapConversionEntityPatch {
29    pub express_id: u32,
30    pub line: String,
31}
32
33/// Atomic plan applied to the already mutation-resolved export pass. Warnings
34/// mean no geometry/context patches were produced; upload must refuse them.
35#[derive(Debug, Default, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct MapConversionNormalizationPlan {
38    pub replacements: Vec<MapConversionEntityPatch>,
39    pub new_entities: Vec<MapConversionEntityPatch>,
40    pub warnings: Vec<String>,
41}
42
43pub(super) struct Record<'a> { pub id: u32, pub kind: IfcType, pub line: &'a str }
44
45/// Plan a standards-valid, uniform 3D `IfcMapConversion` compatibility export.
46/// Default STEP export never calls this. For project unit u metres, physical
47/// similarity s = MapScale/u, and native map affine A = [R*s,T/u], emit
48/// P_new = A*P*S^-1 and representation scale S=s. Therefore P_new*S=A*P,
49/// while logical placement origins also represent the same physical map point.
50/// The neutral remaining conversion is offsets 0, axes identity, Scale=u.
51pub fn plan_map_conversion_normalization(content: &[u8]) -> Result<MapConversionNormalizationPlan, String> {
52    let text = std::str::from_utf8(content).map_err(|_| "map normalization requires UTF-8 STEP".to_string())?;
53    let mut scanner = EntityScanner::new(content);
54    let mut records = Vec::new();
55    let mut ids = HashMap::new();
56    while let Some((id, name, start, end)) = scanner.next_entity() {
57        if ids.insert(id, records.len()).is_some() {
58            return Err(format!("map normalization refuses duplicate entity #{id}"));
59        }
60        let line = text.get(start..end).ok_or("invalid STEP record boundary")?;
61        records.push(Record { id, kind: IfcType::from_str(name), line });
62    }
63    let maps: Vec<_> = records.iter().filter(|record| matches!(record.kind,
64        IfcType::IfcMapConversion | IfcType::IfcMapConversionScaled)).collect();
65    if maps.is_empty() { return Ok(MapConversionNormalizationPlan::default()); }
66    let attempt = || -> Result<MapConversionNormalizationPlan, String> {
67        let schema = crate::source_header::declared_schema(content)
68            .ok_or("map normalization requires a declared source schema")?.to_ascii_uppercase();
69        if schema != "IFC4" && !schema.starts_with("IFC4_ADD")
70            && schema != "IFC4X3" && !schema.starts_with("IFC4X3_ADD") {
71            return Err(format!("source schema {schema} is not supported by map normalization"));
72        }
73        if maps.len() != 1 { return Err("multiple map conversions are not supported atomically".into()); }
74        let map = maps[0];
75        let mut decoder = EntityDecoder::new(&content);
76        let operation = decoder.decode_by_id(map.id).map_err(|error| error.to_string())?;
77        preflight::operation(&operation)?;
78        let target_id = operation.get_ref(1).ok_or("map TargetCRS is missing")?;
79        let target = decoder.decode_by_id(target_id).map_err(|error| error.to_string())?;
80        if target.ifc_type != IfcType::IfcProjectedCRS { return Err("map TargetCRS is not an IfcProjectedCRS".into()); }
81        // Select the operation's actual target, not the first unrelated CRS in
82        // file order, while reusing the canonical transform/unit extraction.
83        let types = [(map.id, map.kind.clone()), (target_id, IfcType::IfcProjectedCRS)];
84        let projects: Vec<_> = records.iter().filter(|record| record.kind == IfcType::IfcProject).collect();
85        if projects.len() != 1 { return Err("exactly one IfcProject is required".into()); }
86        preflight::project_units(projects[0].id, &mut decoder)?;
87        let unit = ifc_lite_core::try_extract_length_unit_scale(&mut decoder, projects[0].id)
88            .ok_or("project length unit cannot be resolved")?;
89        if !unit.is_finite() || unit <= 0.0 { return Err("project length unit is not positive and finite".into()); }
90        // The renderer's canonical extractor is intentionally tolerant of
91        // bad unit metadata. A writer must not interpret that fallback as a
92        // permission to bake coordinates: validate the authored target first.
93        preflight::map_unit(&target, unit, &mut decoder)?;
94        let geo = GeoRefExtractor::extract(&mut decoder, &types)
95            .map_err(|error| error.to_string())?.filter(|geo| geo.has_map_conversion)
96            .ok_or_else(|| format!("map conversion #{} cannot be resolved exactly", map.id))?;
97        if geo.factor_x != 1.0 || geo.factor_y != 1.0 || geo.factor_z != 1.0 {
98            return Err("per-axis map factors are not supported by uniform mapped normalization".into());
99        }
100        let scale = geo.scale / unit;
101        let mut affine = Matrix4::from_column_slice(&geo.to_matrix());
102        for row in 0..3 { for col in 0..4 { affine[(row, col)] /= unit; } }
103        if !scale.is_finite() || scale <= 0.0 || !affine.iter().all(|value| value.is_finite()) {
104            return Err("map similarity is not positive and finite".into());
105        }
106        // Zero-angle exporters can retain a sine roundoff (for example cos(PI/2)).
107        // Recognize only machine-precision direction noise; physical scale and
108        // cosine stay exact. Preserve the authored operation, including the tiny
109        // ordinate and offsets: this returns no patches, not a snapped transform.
110        if scale == 1.0 && geo.x_axis_abscissa == 1.0 && geo.x_axis_ordinate.abs() <= f64::EPSILON {
111            return Ok(MapConversionNormalizationPlan::default());
112        }
113        let context = operation.get_ref(0).ok_or("map SourceCRS is missing")?;
114        if scale == 1.0 {
115            return rigid::plan(&records, &ids, map, context, unit, &affine, &mut decoder);
116        }
117        let type_maps = preflight::model(&records, context, &mut decoder)?;
118        let router = GeometryRouter::with_scale(unit);
119        let mut plan = MapConversionNormalizationPlan::default();
120        let mut output = writer::Writer::new(&records)?;
121        let inverse_scale = Matrix4::new_nonuniform_scaling(&nalgebra::Vector3::repeat(1.0 / scale));
122        let mut shapes = BTreeMap::new();
123        let mut validated = std::collections::HashSet::new();
124        for record in records.iter().filter(|record| record.kind.is_subtype_of(IfcType::IfcProduct)) {
125            let product = decoder.decode_by_id(record.id).map_err(|error| error.to_string())?;
126            preflight::placement(&product, &router, &mut validated, &mut decoder)?;
127            let matrix = router.resolve_scaled_placement_strict(&product, &mut decoder)
128                .map_err(|error| error.to_string())?;
129            let mut placement = Matrix4::from_column_slice(&matrix);
130            for row in 0..3 { placement[(row, 3)] /= unit; }
131            let normalized = affine * placement * inverse_scale;
132            preflight::rigid_frame(&normalized, record.id)?;
133            let new_placement = output.placement(&normalized)?;
134            plan.replacements.push(writer::replace(record, &[(5, format!("#{new_placement}"))])?);
135            if let Some(shape) = product.get_ref(6) { shapes.insert(shape, ()); }
136        }
137        for shape_id in shapes.keys() {
138            let shape = decoder.decode_by_id(*shape_id).map_err(|error| error.to_string())?;
139            let reps = shape.get_refs(2).ok_or("ProductDefinitionShape has no Representations")?;
140            let mut mapped = Vec::new();
141            for rep_id in reps {
142                let record = &records[*ids.get(&rep_id).ok_or("representation record is absent")?];
143                mapped.push(output.mapped_representation(record, context, scale)?);
144            }
145            let record = &records[*ids.get(shape_id).ok_or("ProductDefinitionShape record is absent")?];
146            plan.replacements.push(writer::replace(record, &[(2, writer::refs(&mapped))])?);
147        }
148        // Type geometry no product instantiates gets the same similarity at
149        // type level: the map keeps its ID and owner, its authored origin and
150        // representation move into a scaled inner map, so an instantiation
151        // T yields T*S*O*g, the logical frame's view of the authored T*O*g.
152        for map_id in type_maps {
153            let record = &records[*ids.get(&map_id).ok_or("type map record is absent")?];
154            let origin = crate::step_text::attribute_of(record.line, 0).ok_or("type map MappingOrigin is invalid")?.trim().to_string();
155            let rep_id = decoder.decode_by_id(map_id).map_err(|error| error.to_string())?.get_ref(1).ok_or("type map has no MappedRepresentation")?;
156            let rep = &records[*ids.get(&rep_id).ok_or("type map representation record is absent")?];
157            let wrapped = output.scaled_wrapper(rep, &origin, scale)?;
158            let identity = output.identity_frame()?;
159            plan.replacements.push(writer::replace(record, &[(0, format!("#{identity}")), (1, format!("#{wrapped}"))])?);
160        }
161        let mut edits = vec![(2, "0.".into()), (3, "0.".into()), (4, "0.".into()),
162            (5, "1.".into()), (6, "0.".into()), (7, writer::real(unit)?)];
163        if map.kind == IfcType::IfcMapConversionScaled {
164            edits.extend([(8, "1.".into()), (9, "1.".into()), (10, "1.".into())]);
165        }
166        plan.replacements.push(writer::replace(map, &edits)?);
167        context_metadata::transform_north(&records, &ids, &std::collections::HashSet::from([context]), &affine, &mut decoder, &mut output, &mut plan)?;
168        plan.new_entities = output.finish();
169        Ok(plan)
170    };
171    Ok(attempt().unwrap_or_else(|error| MapConversionNormalizationPlan {
172        warnings: vec![format!("Map geometry normalization refused: {error}. No context geometry was changed.")],
173        ..Default::default()
174    }))
175}
176
177#[cfg(test)]
178#[path = "step_map_transform_tests.rs"]
179mod tests;
180
181#[cfg(test)]
182#[path = "step_map_rigid_tests.rs"]
183mod rigid_tests;
184
185#[cfg(test)]
186#[path = "step_map_rigid_fixture_tests.rs"]
187mod rigid_fixture_tests;