From 0fdff6043a3968b598b13f262575228e14617738 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:03:23 +0000 Subject: [PATCH 01/13] feat(ifc-geometry): add unit refusal variants Four typed refusals for a model whose units have no usable scale (#585). --- crates/ifc-geometry/src/error.rs | 85 +++++++++++++++++++ .../openbim-ifc-binding-core/src/geometry.rs | 6 +- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/ifc-geometry/src/error.rs b/crates/ifc-geometry/src/error.rs index bd2129fc..17a7b23e 100644 --- a/crates/ifc-geometry/src/error.rs +++ b/crates/ifc-geometry/src/error.rs @@ -577,6 +577,46 @@ pub enum GeometryError { #[error("unit resolution failed: {0}")] Units(String), + /// The model declares no length unit, so no coordinate has a scale (#585). + #[error("the model declares no length unit (no IfcProject, no UnitsInContext, or no LENGTHUNIT in it), so its coordinates have no scale")] + LengthUnitUndeclared { + /// The `IfcProject`, when the model has one. + project: Option, + /// The `IfcUnitAssignment`, when the project names one. + assignment: Option, + }, + + /// A plane angle was read, and the project declares no `PLANEANGLEUNIT` + /// (#585). + #[error("{entity} holds a plane angle, and the project declares no PLANEANGLEUNIT")] + AngleUnitUndeclared { + /// The entity whose angle has no unit. + entity: EntityId, + }, + + /// A declared project unit cannot be read as an exact factor (#585). + #[error("{unit} ({unit_type}) cannot be read: {detail}")] + UnitUnreadable { + /// The unit entity. + unit: EntityId, + /// Its `IfcUnitEnum` value, `LENGTHUNIT` or `PLANEANGLEUNIT`. + unit_type: &'static str, + /// Why it cannot be read. + detail: String, + }, + + /// Two candidates for one project unit (#585). + #[error("{first} and {second} are both the model's {kind}; IFC allows one")] + UnitsAmbiguous { + /// The first candidate. + first: EntityId, + /// The second candidate. + second: EntityId, + /// What there should be one of: `IFCPROJECT`, `LENGTHUNIT` or + /// `PLANEANGLEUNIT`. + kind: &'static str, + }, + /// An authored value was rejected before anything was staged (ADR 0011). /// /// Distinct from [`Self::Degenerate`], which describes an entity that @@ -665,6 +705,13 @@ impl GeometryError { } // The opening is what failed; `host` stays readable on the variant. Self::OpeningNotSubtracted { opening, .. } => Some(*opening), + Self::LengthUnitUndeclared { + project, + assignment, + } => assignment.or(*project), + Self::AngleUnitUndeclared { entity } => Some(*entity), + Self::UnitUnreadable { unit, .. } => Some(*unit), + Self::UnitsAmbiguous { second, .. } => Some(*second), Self::Units(_) | Self::InvalidAuthoredValue { .. } | Self::AuthoringSchemaUnbound { .. } @@ -743,4 +790,42 @@ mod tests { assert!(unsupported.is_unsupported()); assert!(!broken.is_unsupported()); } + + #[test] + fn unit_refusals_name_the_entity_and_are_not_unsupported() { + let undeclared = GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: Some(EntityId(2)), + }; + assert_eq!(undeclared.entity(), Some(EntityId(2))); + let no_assignment = GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: None, + }; + assert_eq!(no_assignment.entity(), Some(EntityId(1))); + let no_project = GeometryError::LengthUnitUndeclared { + project: None, + assignment: None, + }; + assert_eq!(no_project.entity(), None); + let angle = GeometryError::AngleUnitUndeclared { + entity: EntityId(3), + }; + assert_eq!(angle.entity(), Some(EntityId(3))); + let unreadable = GeometryError::UnitUnreadable { + unit: EntityId(4), + unit_type: "LENGTHUNIT", + detail: "x".into(), + }; + assert_eq!(unreadable.entity(), Some(EntityId(4))); + let ambiguous = GeometryError::UnitsAmbiguous { + first: EntityId(5), + second: EntityId(6), + kind: "LENGTHUNIT", + }; + assert_eq!(ambiguous.entity(), Some(EntityId(6))); + for e in [undeclared, angle, unreadable, ambiguous] { + assert!(!e.is_unsupported()); + } + } } diff --git a/crates/openbim-ifc-binding-core/src/geometry.rs b/crates/openbim-ifc-binding-core/src/geometry.rs index d5f90296..0e89b427 100644 --- a/crates/openbim-ifc-binding-core/src/geometry.rs +++ b/crates/openbim-ifc-binding-core/src/geometry.rs @@ -342,7 +342,11 @@ fn refusal(error: &ifc::geometry::GeometryError) -> GeometryRefusal { | E::WrongValueKind { .. } | E::WrongEntityType { .. } | E::Degenerate { .. } - | E::Units(_) => "invalid-model", + | E::Units(_) + | E::LengthUnitUndeclared { .. } + | E::AngleUnitUndeclared { .. } + | E::UnitUnreadable { .. } + | E::UnitsAmbiguous { .. } => "invalid-model", // A refusal added to the bridge after this binding, or one only an // evaluator raises: still typed, never dropped. other if other.is_unsupported() => "unsupported", From af93521171c27b25e4c53b30a4a1527b424cc8f8 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:08:50 +0000 Subject: [PATCH 02/13] feat(ifc-geometry)!: refuse models without a declared length unit IFC defines no default length or plane angle unit and UnitsInContext is optional on IfcContext in IFC4, so resolve assumed a scale the file never stated. resolve now returns a typed refusal; a missing angle unit is refused only where an angle is read. IfcMeasureWithUnit values are read in their UnitComponent; unknown SI prefixes and misnamed SI units are refused. UnitScale::default is replaced by UnitScale::SI. Refs #585. --- crates/ifc-geometry/src/compile.rs | 8 +- crates/ifc-geometry/src/compile/output.rs | 2 +- .../src/constraint/grid/curved.rs | 3 +- crates/ifc-geometry/src/constraint/local.rs | 2 +- .../src/constraint/placement/derive.rs | 6 +- .../src/constraint/placement/linear.rs | 2 +- .../src/constraint/placement/tests.rs | 14 +- crates/ifc-geometry/src/input/body/mod.rs | 5 +- crates/ifc-geometry/src/input/body/sweep.rs | 2 +- .../src/input/profile/open_cross.rs | 5 +- .../ifc-geometry/src/input/profile/section.rs | 21 +- crates/ifc-geometry/src/lower/csg/tests.rs | 2 +- crates/ifc-geometry/src/lower/curve.rs | 45 +- .../src/lower/curve/composite_range.rs | 11 +- .../ifc-geometry/src/lower/curve/fixture.rs | 2 +- .../ifc-geometry/src/lower/curve/gradient.rs | 2 +- .../src/lower/curve/parameter_space/tests.rs | 2 +- .../src/lower/curve/spiral/tests.rs | 4 +- crates/ifc-geometry/src/lower/curve/tests.rs | 24 +- .../ifc-geometry/src/lower/halfspace/tests.rs | 18 +- crates/ifc-geometry/src/lower/point.rs | 4 +- crates/ifc-geometry/src/lower/point/tests.rs | 8 +- .../src/lower/profile/composite.rs | 8 +- .../ifc-geometry/src/lower/station/seams.rs | 2 +- crates/ifc-geometry/src/lower/surface.rs | 9 +- .../ifc-geometry/src/lower/surface/tests.rs | 28 +- crates/ifc-geometry/src/lower/swept.rs | 4 +- .../src/lower/tessellated/tests.rs | 12 +- .../src/lower/tessellated/texture/tests.rs | 4 +- crates/ifc-geometry/src/units.rs | 479 ++++++++++-------- crates/ifc-geometry/src/units/tests.rs | 417 +++++++++++++++ 31 files changed, 827 insertions(+), 328 deletions(-) create mode 100644 crates/ifc-geometry/src/units/tests.rs diff --git a/crates/ifc-geometry/src/compile.rs b/crates/ifc-geometry/src/compile.rs index 3dfcf807..28ef8ab9 100644 --- a/crates/ifc-geometry/src/compile.rs +++ b/crates/ifc-geometry/src/compile.rs @@ -160,7 +160,7 @@ pub fn compile_product_mesh_with( product: EntityId, tolerance: Tolerance, ) -> GeometryResult> { - let scale = units::resolve(model); + let scale = units::resolve(model)?; let mut session = LoweringSession::new(model, &scale); let Some(root) = lower_product_representation(&mut session, product, RepresentationPurpose::Body)? @@ -251,7 +251,7 @@ pub fn compile_product_mesh_reported_with( product: EntityId, tolerance: Tolerance, ) -> GeometryResult> { - let scale = units::resolve(model); + let scale = units::resolve(model)?; let mut session = LoweringSession::new(model, &scale); let Some(root) = lower_product_representation(&mut session, product, RepresentationPurpose::Body)? @@ -327,7 +327,7 @@ pub fn compile_product_mesh_net_with( product: EntityId, tolerance: Tolerance, ) -> GeometryResult> { - let scale = units::resolve(model); + let scale = units::resolve(model)?; let mut session = LoweringSession::new(model, &scale); let Some(net) = lower_product_net(&mut session, product)? else { return Ok(None); @@ -450,7 +450,7 @@ pub fn product_bounds_with( product: EntityId, tolerance: Tolerance, ) -> GeometryResult> { - let scale = units::resolve(model); + let scale = units::resolve(model)?; let mut session = LoweringSession::new(model, &scale); let Some(root) = lower_product_representation(&mut session, product, RepresentationPurpose::Body)? diff --git a/crates/ifc-geometry/src/compile/output.rs b/crates/ifc-geometry/src/compile/output.rs index 8b4b36e5..38154acb 100644 --- a/crates/ifc-geometry/src/compile/output.rs +++ b/crates/ifc-geometry/src/compile/output.rs @@ -354,7 +354,7 @@ fn lower_split( purpose: RepresentationPurpose, leave_out_non_curves: bool, ) -> GeometryResult, LeftOut)>> { - let scale = units::resolve(model); + let scale = units::resolve(model)?; let mut session = LoweringSession::new(model, &scale); let (root, left_out) = if leave_out_non_curves { let Some((root, left_out)) = diff --git a/crates/ifc-geometry/src/constraint/grid/curved.rs b/crates/ifc-geometry/src/constraint/grid/curved.rs index 16c98ddb..867f853d 100644 --- a/crates/ifc-geometry/src/constraint/grid/curved.rs +++ b/crates/ifc-geometry/src/constraint/grid/curved.rs @@ -222,7 +222,8 @@ fn carrier(model: &Model, units: &UnitScale, curve: EntityId) -> GeometryResult< for its intersection", )); }; - let [t1, t2] = [units.angle(t1), units.angle(t2)]; + let factor = units.radians_per_unit(curve)?; + let [t1, t2] = [t1 * factor, t2 * factor]; let agrees = view.sense_agreement()?; // ISO 10303-42: with SenseAgreement the arc runs from Trim1 the // positive way round to Trim2; without, the negative way. diff --git a/crates/ifc-geometry/src/constraint/local.rs b/crates/ifc-geometry/src/constraint/local.rs index 2ed610b9..1784f4f7 100644 --- a/crates/ifc-geometry/src/constraint/local.rs +++ b/crates/ifc-geometry/src/constraint/local.rs @@ -167,7 +167,7 @@ impl PlacementResolver { // in file units unchanged. self.resolve( model, - &UnitScale::default(), + &UnitScale::SI, placement, LinearResolution::cache_only(), ) diff --git a/crates/ifc-geometry/src/constraint/placement/derive.rs b/crates/ifc-geometry/src/constraint/placement/derive.rs index 420521e7..a9d2e392 100644 --- a/crates/ifc-geometry/src/constraint/placement/derive.rs +++ b/crates/ifc-geometry/src/constraint/placement/derive.rs @@ -327,7 +327,7 @@ pub(crate) fn resolve_in_file_units( ) -> GeometryResult { let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: units.angle_to_radians, + angle_to_radians: units.radians_per_unit(placement)?, }; ifc_alignment::resolve_linear_placement(model, placement, file_units).map_err(|_error| { GeometryError::Unsupported { @@ -554,7 +554,7 @@ fn model_precision(model: &Model, units: &UnitScale, placement: EntityId) -> Geo model, AlignmentUnits { length_to_metres: units.length_to_metres, - angle_to_radians: units.angle_to_radians, + angle_to_radians: units.radians_per_unit(placement)?, }, ) .map_err(|_| GeometryError::Degenerate { @@ -645,7 +645,7 @@ fn basis_curve( })?; let alignment_units = AlignmentUnits { length_to_metres: units.length_to_metres, - angle_to_radians: units.angle_to_radians, + angle_to_radians: units.radians_per_unit(basis)?, }; // IFC lets the BasisCurve be the alignment itself or its curve // representation. Both name the same centreline, so both resolve; a diff --git a/crates/ifc-geometry/src/constraint/placement/linear.rs b/crates/ifc-geometry/src/constraint/placement/linear.rs index b403fd81..b7db5a88 100644 --- a/crates/ifc-geometry/src/constraint/placement/linear.rs +++ b/crates/ifc-geometry/src/constraint/placement/linear.rs @@ -115,7 +115,7 @@ pub(crate) fn basis_curve( ) -> GeometryResult { let file_units = ifc_alignment::AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: units.angle_to_radians, + angle_to_radians: units.radians_per_unit(placement)?, }; ifc_alignment::resolve_linear_placement(model, placement, file_units) .map(|resolved| resolved.relative_placement.basis_curve) diff --git a/crates/ifc-geometry/src/constraint/placement/tests.rs b/crates/ifc-geometry/src/constraint/placement/tests.rs index d399146b..f44eceb9 100644 --- a/crates/ifc-geometry/src/constraint/placement/tests.rs +++ b/crates/ifc-geometry/src/constraint/placement/tests.rs @@ -59,7 +59,7 @@ fn nested_placement_composes_outermost_last() { local_placement(&mut model, 40, Some(30), 20); product(&mut model, 50, Some(40)); - let world = product_world_transform(&model, &UnitScale::default(), EntityId(50)).unwrap(); + let world = product_world_transform(&model, &UnitScale::SI, EntityId(50)).unwrap(); assert_eq!(world.origin, [0.0, 0.0, 5.0]); } @@ -79,7 +79,7 @@ fn unit_scale_applies_once_not_per_link() { let millimetres = UnitScale { length_to_metres: 0.001, - ..UnitScale::default() + ..UnitScale::SI }; let world = product_world_transform(&model, &millimetres, EntityId(50)).unwrap(); @@ -92,7 +92,7 @@ fn missing_object_placement_is_identity() { let mut model = Model::new(); product(&mut model, 50, None); - let world = product_world_transform(&model, &UnitScale::default(), EntityId(50)).unwrap(); + let world = product_world_transform(&model, &UnitScale::SI, EntityId(50)).unwrap(); assert!(world.is_identity(1e-12)); } @@ -106,7 +106,7 @@ fn cyclic_chain_is_reported() { local_placement(&mut model, 40, Some(30), 10); product(&mut model, 50, Some(40)); - let err = product_world_transform(&model, &UnitScale::default(), EntityId(50)).unwrap_err(); + let err = product_world_transform(&model, &UnitScale::SI, EntityId(50)).unwrap_err(); assert!( matches!(err, GeometryError::CyclicChain { .. }), @@ -120,7 +120,7 @@ fn cyclic_chain_is_reported() { fn missing_product_is_an_error() { let model = Model::new(); - let err = product_world_transform(&model, &UnitScale::default(), EntityId(99)).unwrap_err(); + let err = product_world_transform(&model, &UnitScale::SI, EntityId(99)).unwrap_err(); assert!(matches!(err, GeometryError::MissingEntity { .. })); } @@ -140,7 +140,7 @@ fn batch_matches_single_and_isolates_failures() { local_placement(&mut model, 80, Some(70), 60); product(&mut model, 90, Some(80)); - let units = UnitScale::default(); + let units = UnitScale::SI; let results = products_world_transforms(&model, &units, [EntityId(50), EntityId(90)]); assert_eq!(results.len(), 2); @@ -170,7 +170,7 @@ fn shared_cache_agrees_with_independent_resolution() { product(&mut model, base + 3, Some(base + 2)); } - let units = UnitScale::default(); + let units = UnitScale::SI; let ids = [EntityId(103), EntityId(113), EntityId(123)]; let batched = products_world_transforms(&model, &units, ids); diff --git a/crates/ifc-geometry/src/input/body/mod.rs b/crates/ifc-geometry/src/input/body/mod.rs index 6258f084..f63cb44f 100644 --- a/crates/ifc-geometry/src/input/body/mod.rs +++ b/crates/ifc-geometry/src/input/body/mod.rs @@ -180,8 +180,8 @@ pub enum SweepPath { /// ```no_run /// # use ifc_model::{EntityId, Model}; /// # use ifc_geometry::{body_description, units, BodyKind, SweepPath}; -/// # fn demo(model: &Model, beam: EntityId) { -/// let scale = units::resolve(model); +/// # fn demo(model: &Model, beam: EntityId) -> ifc_geometry::GeometryResult<()> { +/// let scale = units::resolve(model)?; /// let body = body_description(model, &scale, beam).unwrap().expect("a body"); /// let item = body.sole_item().expect("one item"); /// if item.kind == BodyKind::Extrusion { @@ -190,6 +190,7 @@ pub enum SweepPath { /// let _ = (swept.profile.type_name.as_str(), direction_world, depth); /// } /// } +/// # Ok(()) /// # } /// ``` pub fn body_description( diff --git a/crates/ifc-geometry/src/input/body/sweep.rs b/crates/ifc-geometry/src/input/body/sweep.rs index c5dbd5f6..32f621d9 100644 --- a/crates/ifc-geometry/src/input/body/sweep.rs +++ b/crates/ifc-geometry/src/input/body/sweep.rs @@ -158,7 +158,7 @@ fn revolution( ) -> GeometryResult { let view = RevolvedAreaSolid::new(id, entity); // Angle is an IfcPlaneAngleMeasure in the file's unit, very often degrees. - let angle = units.angle(view.angle_raw()?); + let angle = units.angle(id, view.angle_raw()?)?; if angle <= 0.0 { return Err(Slots::new(id, entity).degenerate("revolution angle is not positive")); } diff --git a/crates/ifc-geometry/src/input/profile/open_cross.rs b/crates/ifc-geometry/src/input/profile/open_cross.rs index 51893391..2f171114 100644 --- a/crates/ifc-geometry/src/input/profile/open_cross.rs +++ b/crates/ifc-geometry/src/input/profile/open_cross.rs @@ -69,7 +69,10 @@ pub(super) fn read( return Err(slots.degenerate(format!("slope {index} is not finite"))); } let widths: Vec = widths.into_iter().map(|w| units.length(w)).collect(); - let slopes: Vec = slopes.into_iter().map(|s| units.angle(s)).collect(); + let slopes: Vec = slopes + .into_iter() + .map(|s| units.angle(slots.id(), s)) + .collect::>()?; if horizontal_widths { if let Some(index) = slopes.iter().position(|s| is_vertical(*s)) { return Err(slots.degenerate(format!( diff --git a/crates/ifc-geometry/src/input/profile/section.rs b/crates/ifc-geometry/src/input/profile/section.rs index 5bb93804..446b50a4 100644 --- a/crates/ifc-geometry/src/input/profile/section.rs +++ b/crates/ifc-geometry/src/input/profile/section.rs @@ -31,8 +31,11 @@ fn opt_len(slots: &Slots<'_>, index: usize, units: &UnitScale) -> Option { /// Slopes are angles, not lengths: scaling one by the length factor turns a /// 2 degree flange taper into radians-times-millimetres and silently deforms /// the section. -fn opt_angle(slots: &Slots<'_>, index: usize, units: &UnitScale) -> Option { - slots.opt_f64(index).map(|value| units.angle(value)) +fn opt_angle(slots: &Slots<'_>, index: usize, units: &UnitScale) -> GeometryResult> { + slots + .opt_f64(index) + .map(|value| units.angle(slots.id(), value)) + .transpose() } /// `IfcRectangleProfileDef` and `IfcRoundedRectangleProfileDef`. @@ -136,7 +139,7 @@ pub(super) fn i_shape(slots: &Slots<'_>, units: &UnitScale) -> GeometryResult, units: &UnitScale) -> GeometryResult, units: &UnitScale) -> GeometryResult, units: &UnitScale) -> GeometryResult UnitScale { UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-geometry/src/lower/curve.rs b/crates/ifc-geometry/src/lower/curve.rs index 58b29077..93018ab0 100644 --- a/crates/ifc-geometry/src/lower/curve.rs +++ b/crates/ifc-geometry/src/lower/curve.rs @@ -44,7 +44,7 @@ use crate::curve::offset::{ }; use crate::curve::polyline::Polyline; use crate::curve::trimmed::{TrimmedCurve, TrimmingPreference}; -use crate::error::GeometryResult; +use crate::error::{GeometryError, GeometryResult}; use crate::lower::session::LoweringSession; use crate::lower::station::offset::offset_curve_by_distances; use crate::lower::surface::lower_surface_node; @@ -117,12 +117,14 @@ pub(crate) fn lower_sweep_directrix( } else { kind }; - let converted = range.map(|(start, end)| { - ( - scale_parameter(session, ¶meter_kind, start), - scale_parameter(session, ¶meter_kind, end), - ) - }); + let converted = range + .map(|(start, end)| { + Ok::<_, GeometryError>(( + scale_parameter(session, sweep, ¶meter_kind, start)?, + scale_parameter(session, sweep, ¶meter_kind, end)?, + )) + }) + .transpose()?; Ok((node, converted)) } @@ -488,8 +490,8 @@ fn selectors( let mut out = Vec::new(); if let Some(raw) = trim.parameter { out.push(TrimSelector::Parameter(scale_parameter( - session, basis_kind, raw, - ))); + session, owner, basis_kind, raw, + )?)); } if let Some(point_ref) = trim.cartesian { let placed = world_point(session, owner, point_ref, frame)?; @@ -507,11 +509,28 @@ fn selectors( /// dimensionless. A polyline parameter is a point/segment ordinal. /// - A composite curve parameter is cumulative path LENGTH and converts. /// - Other non-conic families parameterise by length and convert. -pub(crate) fn scale_parameter(session: &LoweringSession<'_>, basis_kind: &str, raw: f64) -> f64 { +pub(crate) fn scale_parameter( + session: &LoweringSession<'_>, + entity: EntityId, + basis_kind: &str, + raw: f64, +) -> GeometryResult { + Ok(raw * parameter_factor(session, entity, basis_kind)?) +} + +/// The factor [`scale_parameter`] applies for one basis curve kind. +/// +/// `entity` names the refusal when a conic needs an angle unit the project +/// does not declare. +pub(crate) fn parameter_factor( + session: &LoweringSession<'_>, + entity: EntityId, + basis_kind: &str, +) -> GeometryResult { match basis_kind { - "IFCCIRCLE" | "IFCELLIPSE" => session.units().angle(raw), - "IFCLINE" | "IFCPOLYLINE" => raw, - _ => session.units().length(raw), + "IFCCIRCLE" | "IFCELLIPSE" => session.units().radians_per_unit(entity), + "IFCLINE" | "IFCPOLYLINE" => Ok(1.0), + _ => Ok(session.units().length_to_metres), } } diff --git a/crates/ifc-geometry/src/lower/curve/composite_range.rs b/crates/ifc-geometry/src/lower/curve/composite_range.rs index c707978a..ba739632 100644 --- a/crates/ifc-geometry/src/lower/curve/composite_range.rs +++ b/crates/ifc-geometry/src/lower/curve/composite_range.rs @@ -33,7 +33,7 @@ use axiolid_model::{ }; use ifc_model::EntityId; -use super::{lower_curve_node, scale_parameter, transition, world_point}; +use super::{lower_curve_node, parameter_factor, transition, world_point}; use crate::curve::composite::{CompositeCurve, CompositeCurveSegment}; use crate::curve::polyline::Polyline; use crate::curve::trimmed::TrimmedCurve; @@ -300,7 +300,7 @@ fn trimmed_span( let (span, period) = match basis_kind.as_str() { "IFCCIRCLE" | "IFCELLIPSE" => { // One turn in the file's own angle unit. - let period = TAU / session.units().angle(1.0); + let period = TAU / session.units().radians_per_unit(parent)?; let span = if travelled > 0.0 && travelled <= period * (1.0 + RELATIVE_SLACK) { travelled } else { @@ -414,14 +414,11 @@ fn cut( // reverses when `sense_agreement` is false, so a piece's sense is // exactly its traversal direction along the basis parameter. let sense = *direction > 0.0; + let factor = parameter_factor(session, sweep, basis_kind)?; ranges .into_iter() .map(|(a, b)| { - let parameter = |raw| { - vec![TrimSelector::Parameter(scale_parameter( - session, basis_kind, raw, - ))] - }; + let parameter = |raw| vec![TrimSelector::Parameter(raw * factor)]; let relation = CurveRelation::Trimmed { basis: basis_node, start: parameter(a), diff --git a/crates/ifc-geometry/src/lower/curve/fixture.rs b/crates/ifc-geometry/src/lower/curve/fixture.rs index b570d5da..ddb06c75 100644 --- a/crates/ifc-geometry/src/lower/curve/fixture.rs +++ b/crates/ifc-geometry/src/lower/curve/fixture.rs @@ -7,7 +7,7 @@ use crate::units::UnitScale; pub(super) const METRES: UnitScale = UnitScale { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; /// Builder for small segment models; ids are handed out in order. diff --git a/crates/ifc-geometry/src/lower/curve/gradient.rs b/crates/ifc-geometry/src/lower/curve/gradient.rs index c3954295..8016e09d 100644 --- a/crates/ifc-geometry/src/lower/curve/gradient.rs +++ b/crates/ifc-geometry/src/lower/curve/gradient.rs @@ -189,7 +189,7 @@ impl Tolerance { fn for_session(session: &LoweringSession<'_>, id: EntityId) -> GeometryResult { let units = AlignmentUnits { length_to_metres: session.units().length_to_metres, - angle_to_radians: session.units().angle_to_radians, + angle_to_radians: session.units().radians_per_unit(id)?, }; SeamTolerance::for_model(session.model(), units) .map(|t| Self(t.length())) diff --git a/crates/ifc-geometry/src/lower/curve/parameter_space/tests.rs b/crates/ifc-geometry/src/lower/curve/parameter_space/tests.rs index c771bc74..ac0174e0 100644 --- a/crates/ifc-geometry/src/lower/curve/parameter_space/tests.rs +++ b/crates/ifc-geometry/src/lower/curve/parameter_space/tests.rs @@ -20,7 +20,7 @@ use axiolid_model::TrimSelector; fn millimetres() -> UnitScale { UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-geometry/src/lower/curve/spiral/tests.rs b/crates/ifc-geometry/src/lower/curve/spiral/tests.rs index 04eedf8e..a5b37ada 100644 --- a/crates/ifc-geometry/src/lower/curve/spiral/tests.rs +++ b/crates/ifc-geometry/src/lower/curve/spiral/tests.rs @@ -15,7 +15,7 @@ use crate::units::UnitScale; const METRES: UnitScale = UnitScale { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; /// A model whose `#1` is `kind` with the given term slots after `Position`. @@ -92,7 +92,7 @@ fn a_clothoid_is_linear_curvature_with_the_sign_of_its_constant() { fn spiral_terms_convert_to_metres_before_the_law_is_formed() { let millimetres = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let law = law( &spiral("IFCCLOTHOID", &[Some(100_000.0)]), diff --git a/crates/ifc-geometry/src/lower/curve/tests.rs b/crates/ifc-geometry/src/lower/curve/tests.rs index 7dc1992f..73d6ddca 100644 --- a/crates/ifc-geometry/src/lower/curve/tests.rs +++ b/crates/ifc-geometry/src/lower/curve/tests.rs @@ -19,7 +19,7 @@ use crate::units::UnitScale; fn millimetres() -> UnitScale { UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -329,10 +329,22 @@ fn parameter_units_follow_curve_parameterisation() { let scale = millimetres(); let session = LoweringSession::new(&model, &scale); - assert_eq!(scale_parameter(&session, "IFCLINE", 2_000.0), 2_000.0); - assert_eq!(scale_parameter(&session, "IFCPOLYLINE", 2.0), 2.0); - assert_eq!(scale_parameter(&session, "IFCCOMPOSITECURVE", 2_000.0), 2.0); - assert_eq!(scale_parameter(&session, "IFCCIRCLE", 0.5), 0.5); + assert_eq!( + scale_parameter(&session, EntityId(1), "IFCLINE", 2_000.0).unwrap(), + 2_000.0 + ); + assert_eq!( + scale_parameter(&session, EntityId(1), "IFCPOLYLINE", 2.0).unwrap(), + 2.0 + ); + assert_eq!( + scale_parameter(&session, EntityId(1), "IFCCOMPOSITECURVE", 2_000.0).unwrap(), + 2.0 + ); + assert_eq!( + scale_parameter(&session, EntityId(1), "IFCCIRCLE", 0.5).unwrap(), + 0.5 + ); } #[test] @@ -524,7 +536,7 @@ fn indexed_arc_rejects_finite_points_when_derived_circle_values_overflow() { ); let scale = UnitScale { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let mut session = LoweringSession::new(&model, &scale); diff --git a/crates/ifc-geometry/src/lower/halfspace/tests.rs b/crates/ifc-geometry/src/lower/halfspace/tests.rs index a37c6a68..fb692f99 100644 --- a/crates/ifc-geometry/src/lower/halfspace/tests.rs +++ b/crates/ifc-geometry/src/lower/halfspace/tests.rs @@ -68,7 +68,7 @@ fn half_space(agreement: bool, z_offset: f64) -> Model { } fn lower(model: &Model, frame: Transform) -> axiolid_primitive::HalfSpace { - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(model, &scale); let node = lower_half_space_node(&mut session, EntityId(5), frame).expect("the half space must lower"); @@ -184,7 +184,7 @@ fn the_plane_origin_is_converted_to_metres() { let model = half_space(true, 2500.0); let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let mut session = LoweringSession::new(&model, &scale); let node = @@ -236,7 +236,7 @@ fn a_polygonal_bound_is_never_dropped() { ), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_half_space_node(&mut session, EntityId(5), Transform::identity()).expect("lowers"); @@ -334,7 +334,7 @@ fn boundary_points_convert_to_metres_and_drop_the_closing_duplicate() { ]); let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let points = lower_boundary(&model, &scale).expect("lowers"); assert_eq!( @@ -354,7 +354,7 @@ fn a_boundary_point_with_zero_z_is_accepted() { point3(1.0, 1.0, 0.0), point3(0.0, 1.0, 0.0), ]); - let points = lower_boundary(&model, &UnitScale::default()).expect("lowers"); + let points = lower_boundary(&model, &UnitScale::SI).expect("lowers"); assert_eq!(points[2], [1.0, 1.0]); } @@ -368,7 +368,7 @@ fn a_boundary_point_off_the_plane_is_refused_by_name() { point3(1.0, 1.0, 0.25), point3(0.0, 1.0, 0.0), ]); - let text = lower_boundary(&model, &UnitScale::default()) + let text = lower_boundary(&model, &UnitScale::SI) .expect_err("an off-plane boundary point must not lower"); assert!(text.contains("Degenerate"), "got {text}"); assert!( @@ -397,7 +397,7 @@ fn an_empty_composite_boundary_is_refused_as_degenerate() { vec![Value::List(vec![]), Value::Bool(false)], ), ); - let text = lower_boundary(&model, &UnitScale::default()) + let text = lower_boundary(&model, &UnitScale::SI) .expect_err("an empty composite boundary must not lower"); assert!(text.contains("Degenerate"), "got {text}"); assert!(text.contains("EntityId(6)"), "got {text}"); @@ -416,7 +416,7 @@ fn a_non_planar_base_surface_is_reported_as_unsupported() { entity("IFCCYLINDRICALSURFACE", vec![r(3), n(1.0)]), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_half_space_node(&mut session, EntityId(5), Transform::identity()) .expect_err("a cylindrical base surface must not lower"); @@ -452,7 +452,7 @@ fn lower_bounded( EntityId(7), entity("IFCAXIS2PLACEMENT3D", vec![r(8), r(9), Value::Null]), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_half_space_node(&mut session, EntityId(5), frame)?; let lowered = session.finish(node).expect("finishes"); diff --git a/crates/ifc-geometry/src/lower/point.rs b/crates/ifc-geometry/src/lower/point.rs index a15f4ba1..df1e2993 100644 --- a/crates/ifc-geometry/src/lower/point.rs +++ b/crates/ifc-geometry/src/lower/point.rs @@ -65,7 +65,7 @@ fn build_point_on_curve( let basis_kind = session.type_name(basis_ref)?; let curve = lower_curve_node(session, basis_ref, frame)?; - let parameter = scale_parameter(session, &basis_kind, raw_parameter); + let parameter = scale_parameter(session, id, &basis_kind, raw_parameter)?; session.node_for( id, @@ -109,7 +109,7 @@ fn build_point_on_surface( let basis_kind = session.type_name(basis_ref)?; let surface = lower_surface_node(session, basis_ref, frame)?; - let convert = crate::lower::surface::trim_converter(session, &basis_kind); + let convert = crate::lower::surface::trim_converter(session, id, &basis_kind)?; let (u, v) = (convert(raw_u), convert(raw_v)); session.node_for( diff --git a/crates/ifc-geometry/src/lower/point/tests.rs b/crates/ifc-geometry/src/lower/point/tests.rs index 713a0818..c7516bb0 100644 --- a/crates/ifc-geometry/src/lower/point/tests.rs +++ b/crates/ifc-geometry/src/lower/point/tests.rs @@ -85,7 +85,7 @@ fn model_with_polyline_and_circle() -> (Model, EntityId, EntityId) { } fn scale() -> UnitScale { - UnitScale::default() + UnitScale::SI } fn session<'a>(model: &'a Model, scale: &'a UnitScale) -> LoweringSession<'a> { @@ -153,7 +153,7 @@ fn point_on_curve_on_a_circle_converts_the_parameter_as_an_angle() { let units = UnitScale { length_to_metres: 1.0, - angle_to_radians: std::f64::consts::PI / 180.0, + angle_to_radians: Some(std::f64::consts::PI / 180.0), }; let mut sess = LoweringSession::new(&model, &units); let node = @@ -203,7 +203,7 @@ fn point_on_surface_preserves_basis_and_both_parameters() { vec![Value::Ref(EntityId(3)), Value::Real(2.0), Value::Real(3.0)], ), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut sess = LoweringSession::new(&model, &scale); let node = lower_point_on_surface_node(&mut sess, EntityId(4), Transform::identity()).expect("lowers"); @@ -226,7 +226,7 @@ fn a_missing_basis_curve_reports_the_dangling_reference() { vec![Value::Ref(EntityId(99)), Value::Real(1.0)], ), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut sess = LoweringSession::new(&model, &scale); let err = lower_point_on_curve_node(&mut sess, EntityId(1), Transform::identity()).unwrap_err(); assert_eq!(err.entity(), Some(EntityId(99))); diff --git a/crates/ifc-geometry/src/lower/profile/composite.rs b/crates/ifc-geometry/src/lower/profile/composite.rs index 8db23ec6..fc149db8 100644 --- a/crates/ifc-geometry/src/lower/profile/composite.rs +++ b/crates/ifc-geometry/src/lower/profile/composite.rs @@ -283,7 +283,7 @@ impl Walk<'_> { let resolve = |trim: crate::curve::trimmed::Trim| -> GeometryResult { match trim.preferred(preference) { - Some(TrimPoint::Parameter(raw)) => Ok(basis.parameter(raw, self.units)), + Some(TrimPoint::Parameter(raw)) => basis.parameter(id, raw, self.units), Some(TrimPoint::Cartesian(point)) => { let c = self.planar_point(id, point)?; let at = Point2::new(self.units.length(c[0]), self.units.length(c[1])); @@ -477,10 +477,10 @@ impl Basis { /// A circle parameter is an angle in the project's plane-angle unit. A /// line parameter counts multiples of its `IfcVector`, which already /// carries the length, so it is dimensionless and passes through. - fn parameter(self, raw: f64, units: &UnitScale) -> f64 { + fn parameter(self, entity: EntityId, raw: f64, units: &UnitScale) -> GeometryResult { match self { - Self::Circle(_) => units.angle(raw), - Self::Line(_) => raw, + Self::Circle(_) => units.angle(entity, raw), + Self::Line(_) => Ok(raw), } } diff --git a/crates/ifc-geometry/src/lower/station/seams.rs b/crates/ifc-geometry/src/lower/station/seams.rs index b45bcae9..3f296c63 100644 --- a/crates/ifc-geometry/src/lower/station/seams.rs +++ b/crates/ifc-geometry/src/lower/station/seams.rs @@ -97,7 +97,7 @@ pub(crate) fn precision( ) -> GeometryResult { let units = AlignmentUnits { length_to_metres: session.units().length_to_metres, - angle_to_radians: session.units().angle_to_radians, + angle_to_radians: session.units().radians_per_unit(owner)?, }; SeamTolerance::for_model(session.model(), units) .map(|tolerance| tolerance.length()) diff --git a/crates/ifc-geometry/src/lower/surface.rs b/crates/ifc-geometry/src/lower/surface.rs index a4a523e9..5f47761b 100644 --- a/crates/ifc-geometry/src/lower/surface.rs +++ b/crates/ifc-geometry/src/lower/surface.rs @@ -209,8 +209,9 @@ fn to_metres(session: &LoweringSession<'_>, value: f64) -> f64 { /// basis type, instead of leaking a bool to every caller. pub(crate) fn trim_converter( session: &LoweringSession<'_>, + entity: EntityId, basis_kind: &str, -) -> impl Fn(f64) -> f64 { +) -> GeometryResult f64> { let angular = matches!( basis_kind, "IFCCYLINDRICALSURFACE" @@ -219,11 +220,11 @@ pub(crate) fn trim_converter( | "IFCSURFACEOFREVOLUTION" ); let factor = if angular { - session.units().angle_to_radians + session.units().radians_per_unit(entity)? } else { session.units().length_to_metres }; - move |value: f64| value * factor + Ok(move |value: f64| value * factor) } /// Read an `IfcCartesianPoint`'s three coordinates, as written. @@ -512,7 +513,7 @@ pub fn lower_rectangular_trimmed( let rect = view.rectangle()?; let basis_kind = session.type_name(basis_id)?.to_ascii_uppercase(); - let convert = trim_converter(session, &basis_kind); + let convert = trim_converter(session, id, &basis_kind)?; session.node_for( id, diff --git a/crates/ifc-geometry/src/lower/surface/tests.rs b/crates/ifc-geometry/src/lower/surface/tests.rs index eb56fedc..5495db31 100644 --- a/crates/ifc-geometry/src/lower/surface/tests.rs +++ b/crates/ifc-geometry/src/lower/surface/tests.rs @@ -74,7 +74,7 @@ fn a_plane_keeps_the_placement_axes_that_fix_its_parameterisation() { // Z = +Y (world), explicit X = +Z (world). A normal-only lowering cannot // recover this X: it would derive some other perpendicular. let model = plane_model([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]); - let surface = lower_plane_surface(&model, &UnitScale::default(), Transform::identity()); + let surface = lower_plane_surface(&model, &UnitScale::SI, Transform::identity()); let Surface::Plane(plane) = surface else { panic!("expected a plane"); }; @@ -99,7 +99,7 @@ fn a_plane_origin_is_scaled_to_metres_but_its_axes_stay_unit() { let model = plane_model([1000.0, 0.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]); let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let surface = lower_plane_surface(&model, &scale, Transform::identity()); let Surface::Plane(plane) = surface else { @@ -127,7 +127,7 @@ fn a_plane_origin_is_scaled_to_metres_but_its_axes_stay_unit() { #[test] fn a_non_unit_authored_axis_is_normalized_into_the_frame() { let model = plane_model([0.0, 0.0, 0.0], [0.0, 0.0, 7.0], [4.0, 0.0, 0.0]); - let surface = lower_plane_surface(&model, &UnitScale::default(), Transform::identity()); + let surface = lower_plane_surface(&model, &UnitScale::SI, Transform::identity()); let Surface::Plane(plane) = surface else { panic!("expected a plane"); }; @@ -158,7 +158,7 @@ fn an_unlowered_surface_family_is_reported_by_name() { // lower. IfcPcurve is a surface by the schema but denotes a curve, so it // is refused here and handled by the curve lowerer instead. model.insert(EntityId(6), entity("IFCPCURVE", vec![r(5), Value::Null])); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_surface_node(&mut session, EntityId(6), Transform::identity()) .expect_err("a p-curve is not a surface; the curve lowerer owns it"); @@ -207,7 +207,7 @@ fn a_linear_extrusion_references_its_swept_curve_and_direction() { ), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_linear_extrusion(&mut session, EntityId(5), Transform::identity()) .expect("the extrusion must lower"); @@ -280,7 +280,7 @@ fn the_depth_hint_never_scales_the_extrusion_direction() { ), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_linear_extrusion(&mut session, EntityId(5), Transform::identity()).expect("lowers"); @@ -341,7 +341,7 @@ fn a_placed_extrusion_rotates_its_direction_but_never_translates_it() { ), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let frame = Transform::translation([100.0, -50.0, 25.0]); let node = lower_linear_extrusion(&mut session, EntityId(5), frame).expect("lowers"); @@ -477,7 +477,7 @@ fn a_cylinder_converts_its_radius_but_not_its_axes() { let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let mut session = LoweringSession::new(&model, &scale); let node = @@ -527,7 +527,7 @@ fn a_torus_preserves_a_self_intersecting_spindle() { let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let mut session = LoweringSession::new(&model, &scale); let node = @@ -584,7 +584,7 @@ fn a_trim_parameter_on_a_curved_basis_uses_the_angle_unit() { // orders of magnitude, so a swapped factor cannot pass by luck. let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 0.017453292519943295, + angle_to_radians: Some(0.017453292519943295), }; let mut session = LoweringSession::new(&model, &scale); let node = @@ -607,7 +607,7 @@ fn a_trim_parameter_on_a_curved_basis_uses_the_angle_unit() { #[test] fn a_curve_bounded_plane_orders_outer_then_inner() { let model = curve_bounded_model(); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_surface_node(&mut session, EntityId(20), Transform::identity()).expect("lowers"); @@ -630,7 +630,7 @@ fn a_curve_bounded_plane_orders_outer_then_inner() { #[test] fn a_curve_bounded_surface_preserves_boundary_order_and_implicit_outer() { let model = curve_bounded_model(); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let root = lower_surface_node(&mut session, EntityId(21), Transform::identity()) .expect("curve-bounded surface lowers"); @@ -720,7 +720,7 @@ fn a_bspline_patch_keeps_its_directions_distinct() { let scale = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let mut session = LoweringSession::new(&model, &scale); let node = lower_surface_node(&mut session, surface_id, Transform::identity()).expect("lowers"); @@ -765,7 +765,7 @@ fn self_referential_surface_is_a_typed_cycle_error() { entity("IFCBOUNDARYCURVE", vec![Value::List(vec![])]), ); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_surface_node(&mut session, EntityId(1), Transform::identity()) .expect_err("surface cycle must be bounded"); diff --git a/crates/ifc-geometry/src/lower/swept.rs b/crates/ifc-geometry/src/lower/swept.rs index 8ee066c5..8e98fcb2 100644 --- a/crates/ifc-geometry/src/lower/swept.rs +++ b/crates/ifc-geometry/src/lower/swept.rs @@ -317,7 +317,7 @@ fn revolution_node( let slots = Slots::new(id, entity); let profile_ref = slots.req_ref(slot::SWEPT_AREA, "SweptArea")?; - let angle = units.angle(slots.req_f64(slot::ANGLE, "Angle")?); + let angle = units.angle(id, slots.req_f64(slot::ANGLE, "Angle")?)?; if angle <= 0.0 { return Err(slots.degenerate("revolution angle is not positive")); } @@ -472,7 +472,7 @@ fn tapered_revolution_node( let end_ref = view.end_swept_area()?; // Angle is an IfcPlaneAngleMeasure in the file's declared unit, very often // degrees. units.angle applies that conversion exactly once. - let angle = units.angle(slots.req_f64(slot::ANGLE, "Angle")?); + let angle = units.angle(id, slots.req_f64(slot::ANGLE, "Angle")?)?; if angle <= 0.0 { return Err(slots.degenerate("revolution angle is not positive")); } diff --git a/crates/ifc-geometry/src/lower/tessellated/tests.rs b/crates/ifc-geometry/src/lower/tessellated/tests.rs index 2f0fecb8..18fb4b5d 100644 --- a/crates/ifc-geometry/src/lower/tessellated/tests.rs +++ b/crates/ifc-geometry/src/lower/tessellated/tests.rs @@ -42,7 +42,7 @@ fn tetrahedron(pn: Option<&[i64]>) -> ifc_model::Model { } fn lower_tri(model: &ifc_model::Model, frame: Transform) -> axiolid_mesh::TriMesh { - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(model, &scale); let node = lower_triangulated_face_set_node(&mut session, ifc_model::EntityId(2), frame) .expect("the face set must lower"); @@ -174,7 +174,7 @@ fn an_index_past_the_end_of_coordinates_is_rejected() { ], ); let model = model(vec![(1, coords), (2, face_set)]); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_triangulated_face_set_node( @@ -214,7 +214,7 @@ fn polygonal_faces_keep_their_authored_n_gons() { ); let model = model(vec![(1, coords), (3, face), (2, face_set)]); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_polygonal_face_set_node(&mut session, ifc_model::EntityId(2), Transform::identity()) @@ -269,7 +269,7 @@ fn a_face_with_voids_keeps_its_inner_loops() { ); let model = model(vec![(1, coords), (3, face), (2, face_set)]); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_polygonal_face_set_node(&mut session, ifc_model::EntityId(2), Transform::identity()) @@ -320,7 +320,7 @@ fn a_set_level_pn_index_remaps_polygonal_face_indices() { ], ); let model = model(vec![(1, coords), (3, face), (2, face_set)]); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_polygonal_face_set_node( &mut session, @@ -357,7 +357,7 @@ fn a_set_level_pn_index_remaps_polygonal_face_indices() { /// `Unsupported`. This routes through `lower_representation_item` instead. #[test] fn both_face_set_families_route_through_the_dispatcher() { - let scale = UnitScale::default(); + let scale = UnitScale::SI; let tri = tetrahedron(None); let mut session = LoweringSession::new(&tri, &scale); diff --git a/crates/ifc-geometry/src/lower/tessellated/texture/tests.rs b/crates/ifc-geometry/src/lower/tessellated/texture/tests.rs index abfcd6bf..24cc75a7 100644 --- a/crates/ifc-geometry/src/lower/tessellated/texture/tests.rs +++ b/crates/ifc-geometry/src/lower/tessellated/texture/tests.rs @@ -150,7 +150,7 @@ fn untextured() -> Model { } fn lower(model: &Model) -> Result { - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(model, &scale); let node = lower_triangulated_face_set_node(&mut session, EntityId(FACE_SET), Transform::identity())?; @@ -286,7 +286,7 @@ fn a_second_map_gets_its_own_channel() { #[test] fn the_frame_does_not_move_texture_coordinates() { let model = figure_415(Some(rows(&TEX_INDEX)), vec![]); - let scale = UnitScale::default(); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let frame = Transform::translation([10.0, 20.0, 30.0]); let node = lower_triangulated_face_set_node(&mut session, EntityId(FACE_SET), frame).unwrap(); diff --git a/crates/ifc-geometry/src/units.rs b/crates/ifc-geometry/src/units.rs index 4e81541d..798c8db8 100644 --- a/crates/ifc-geometry/src/units.rs +++ b/crates/ifc-geometry/src/units.rs @@ -9,50 +9,75 @@ //! millimetres renders a speck. //! //! The resolved [`UnitScale`] is therefore an input to every lowering -//! operation, not an afterthought. +//! operation, not an afterthought. IFC defines no default length or plane +//! angle unit, and `UnitsInContext` is optional on `IfcContext` in IFC4 and +//! IFC4X3, so a model that does not declare its length unit has no scale to +//! resolve: [`resolve`] refuses it (#585). //! //! # What is resolved //! -//! - `IfcSIUnit` with an `IfcSIPrefix` (`MILLI`, `CENTI`, `KILO`, ...) -//! - `IfcConversionBasedUnit` (inch, foot) via its conversion factor +//! - `IfcSIUnit` with an `IfcSIPrefix` (`MILLI`, `CENTI`, `KILO`, ...); a +//! length unit must be the `METRE` and an angle unit the `RADIAN` +//! (`IfcNamedUnit` WR1) +//! - `IfcConversionBasedUnit` (inch, foot, degree): its `ValueComponent` +//! stated in its `UnitComponent`, so an inch given as 25.4 millimetres reads +//! as 0.0254 metres //! - Angle units, because `IfcPlaneAngleMeasure` may be degrees or radians and -//! every rotation depends on knowing which +//! every rotation depends on knowing which. A missing angle unit is refused +//! only where an angle is read ([`UnitScale::angle`]) +//! +//! # What is refused +//! +//! No project, no `UnitsInContext`, no `LENGTHUNIT`; two projects; two units +//! of one type; an unknown prefix; a misnamed SI unit; a context-dependent or +//! offset unit; a conversion factor that is not a positive finite number. +//! A conversion-based unit defined through another one is +//! [`GeometryError::Unsupported`]. use crate::error::{GeometryError, GeometryResult}; use crate::slots::Slots; -use ifc_model::{EntityId, Model}; +use ifc_model::{EntityId, Model, Value}; /// Multipliers converting file units into SI base units. #[derive(Debug, Clone, Copy, PartialEq)] pub struct UnitScale { /// Multiply a stored length by this to get metres. pub length_to_metres: f64, - /// Multiply a stored plane angle by this to get radians. - pub angle_to_radians: f64, -} - -impl Default for UnitScale { - /// SI defaults: metres and radians, both factor 1. - /// - /// Used only when a file declares no units at all. That is malformed, but - /// refusing to load such a file is worse than assuming SI and saying so. - fn default() -> Self { - Self { - length_to_metres: 1.0, - angle_to_radians: 1.0, - } - } + /// Multiply a stored plane angle by this to get radians; `None` when the + /// project declares no `PLANEANGLEUNIT`. + pub angle_to_radians: Option, } impl UnitScale { + /// Metres and radians, both factors of one; never a fallback for a + /// model's missing units. + pub const SI: Self = Self { + length_to_metres: 1.0, + angle_to_radians: Some(1.0), + }; + /// Convert a stored length to metres. pub fn length(&self, value: f64) -> f64 { value * self.length_to_metres } /// Convert a stored plane angle to radians. - pub fn angle(&self, value: f64) -> f64 { - value * self.angle_to_radians + /// + /// `entity` is the entity holding the angle; it names the refusal when the + /// project declares no angle unit. + pub fn angle(&self, entity: EntityId, value: f64) -> GeometryResult { + Ok(value * self.radians_per_unit(entity)?) + } + + /// Radians per stored plane angle unit. + /// + /// # Errors + /// + /// [`GeometryError::AngleUnitUndeclared`] naming `entity` when the project + /// declares no angle unit. + pub fn radians_per_unit(&self, entity: EntityId) -> GeometryResult { + self.angle_to_radians + .ok_or(GeometryError::AngleUnitUndeclared { entity }) } /// Are lengths already in metres? @@ -63,242 +88,262 @@ impl UnitScale { /// Resolve the project's unit assignment. /// -/// Finds `IfcProject.UnitsInContext` and reads the length and angle units from -/// it. A file with no project, or no unit assignment, yields -/// [`UnitScale::default`] rather than an error: geometry is still readable, it -/// is merely unscaled, and the caller can check -/// [`UnitScale::is_metric_identity`]. -pub fn resolve(model: &Model) -> UnitScale { - let Some(assignment) = find_unit_assignment(model) else { - return UnitScale::default(); +/// Reads the length and angle units from `IfcProject.UnitsInContext`. Any +/// `Err` means the model has no usable scale; nothing is assumed. +/// +/// # Errors +/// +/// [`GeometryError::LengthUnitUndeclared`] when there is no project, no +/// assignment or no `LENGTHUNIT`; [`GeometryError::UnitsAmbiguous`] for two +/// projects or two units of one type; [`GeometryError::UnitUnreadable`] for a +/// declared length or angle unit without an exact factor. A missing angle unit +/// is not an error here: see [`UnitScale::angle`]. +pub fn resolve(model: &Model) -> GeometryResult { + let project = match model.ids_of_type("IFCPROJECT") { + [] => { + return Err(GeometryError::LengthUnitUndeclared { + project: None, + assignment: None, + }); + } + [project] => *project, + [first, second, ..] => { + return Err(GeometryError::UnitsAmbiguous { + first: *first, + second: *second, + kind: "IFCPROJECT", + }); + } }; - - let mut scale = UnitScale::default(); - let Some(entity) = model.get(assignment) else { - return scale; + let project_entity = model + .get(project) + .ok_or(GeometryError::LengthUnitUndeclared { + project: Some(project), + assignment: None, + })?; + let project_slots = Slots::new(project, project_entity); + let Some(value) = project_slots.opt(8) else { + return Err(GeometryError::LengthUnitUndeclared { + project: Some(project), + assignment: None, + }); }; - let slots = Slots::new(assignment, entity); + let assignment = value + .as_ref_id() + .ok_or_else(|| project_slots.kind_error("UnitsInContext", "an entity reference", value))?; + let assignment_entity = project_slots.resolve(model, assignment)?; + if &*assignment_entity.type_name != "IFCUNITASSIGNMENT" { + return Err(GeometryError::WrongEntityType { + entity: assignment, + actual: assignment_entity.type_name.to_string(), + expected: "IFCUNITASSIGNMENT", + }); + } + let slots = Slots::new(assignment, assignment_entity); - for unit_id in slots.opt_ref_list(0) { - let Some(unit) = model.get(unit_id) else { - continue; - }; + let mut length: Option<(EntityId, f64)> = None; + let mut angle: Option<(EntityId, f64)> = None; + for unit_id in slots.req_ref_list(0, "Units")? { + let unit = slots.resolve(model, unit_id)?; let unit_slots = Slots::new(unit_id, unit); - match unit_type(&unit_slots) { Some("LENGTHUNIT") => { - if let Ok(factor) = length_factor(model, unit_id, &unit_slots) { - scale.length_to_metres = factor; + if let Some((first, _)) = length { + return Err(ambiguous(first, unit_id, "LENGTHUNIT")); } + length = Some((unit_id, factor(model, &unit_slots, "LENGTHUNIT")?)); } Some("PLANEANGLEUNIT") => { - if let Ok(factor) = angle_factor(model, unit_id, &unit_slots) { - scale.angle_to_radians = factor; + if let Some((first, _)) = angle { + return Err(ambiguous(first, unit_id, "PLANEANGLEUNIT")); } + angle = Some((unit_id, factor(model, &unit_slots, "PLANEANGLEUNIT")?)); } _ => {} } } - scale + + let Some((_, length_to_metres)) = length else { + return Err(GeometryError::LengthUnitUndeclared { + project: Some(project), + assignment: Some(assignment), + }); + }; + Ok(UnitScale { + length_to_metres, + angle_to_radians: angle.map(|(_, factor)| factor), + }) } -/// Locate `IfcProject.UnitsInContext` (attribute 8). -fn find_unit_assignment(model: &Model) -> Option { - let (id, project) = model.of_type("IFCPROJECT").next()?; - Slots::new(id, project).opt_ref(8) +fn ambiguous(first: EntityId, second: EntityId, kind: &'static str) -> GeometryError { + GeometryError::UnitsAmbiguous { + first, + second, + kind, + } } -/// The `UnitType` enum, present on both `IfcSIUnit` and `IfcDerivedUnit`. +/// The `UnitType` enum of an `IfcNamedUnit` (attribute 1). fn unit_type<'m>(slots: &Slots<'m>) -> Option<&'m str> { match slots.type_name() { // IfcSIUnit: (Dimensions, UnitType, Prefix, Name) - "IFCSIUNIT" => slots.opt_enum(1), - // IfcConversionBasedUnit: (Dimensions, UnitType, Name, ConversionFactor) - "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => slots.opt_enum(1), + // IfcConversionBasedUnit[WithOffset]: (Dimensions, UnitType, Name, ConversionFactor[, ConversionOffset]) + // IfcContextDependentUnit: (Dimensions, UnitType, Name) + "IFCSIUNIT" + | "IFCCONVERSIONBASEDUNIT" + | "IFCCONVERSIONBASEDUNITWITHOFFSET" + | "IFCCONTEXTDEPENDENTUNIT" => slots.opt_enum(1), _ => None, } } -/// Metres per stored length unit. -fn length_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult { - match slots.type_name() { - "IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))), +/// Metres per stored length unit, or radians per stored angle unit. +fn factor(model: &Model, slots: &Slots<'_>, kind: &'static str) -> GeometryResult { + let unreadable = |detail: String| GeometryError::UnitUnreadable { + unit: slots.id(), + unit_type: kind, + detail, + }; + let factor = match slots.type_name() { + "IFCSIUNIT" => si_factor(slots, kind)?, "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => { - conversion_factor(model, id, slots) + if slots.type_name() == "IFCCONVERSIONBASEDUNITWITHOFFSET" { + match slots.opt(4) { + None => {} + Some(offset) if offset.unwrap_typed().as_f64() == Some(0.0) => {} + Some(_) => { + return Err(unreadable( + "an offset unit has no linear factor".to_string(), + )); + } + } + } + conversion_factor(model, slots, kind)? } - other => Err(GeometryError::Units(format!( - "{id} is {other}, which is not a length unit this build understands" - ))), + "IFCCONTEXTDEPENDENTUNIT" => { + return Err(unreadable( + "a context-dependent unit states no conversion".to_string(), + )); + } + other => { + return Err(unreadable(format!( + "{other} is not a unit this build reads" + ))) + } + }; + if factor.is_finite() && factor > 0.0 { + Ok(factor) + } else { + Err(unreadable(format!( + "its factor {factor} is not a positive finite number" + ))) } } -/// Radians per stored angle unit. -/// -/// `IfcSIUnit` for an angle is the radian, so the prefix (almost always absent) -/// is the only scaling. Degrees arrive as an `IfcConversionBasedUnit` whose -/// factor is pi/180. -fn angle_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult { - match slots.type_name() { - "IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))), - "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => { - conversion_factor(model, id, slots) - } - other => Err(GeometryError::Units(format!( - "{id} is {other}, which is not an angle unit this build understands" - ))), +/// `IfcSIUnit`: the base unit's prefix factor, after checking its name. +fn si_factor(slots: &Slots<'_>, kind: &'static str) -> GeometryResult { + let unreadable = |detail: String| GeometryError::UnitUnreadable { + unit: slots.id(), + unit_type: kind, + detail, + }; + let expected = if kind == "LENGTHUNIT" { + "METRE" + } else { + "RADIAN" + }; + if slots.opt_enum(3) != Some(expected) { + return Err(unreadable(format!( + "a {kind} IfcSIUnit must be named {expected} (IfcNamedUnit WR1)" + ))); + } + match slots.opt(2) { + None => Ok(1.0), + Some(Value::Enum(prefix)) => prefix_factor(prefix) + .ok_or_else(|| unreadable(format!("{prefix} is not an IfcSIPrefix"))), + Some(_) => Err(unreadable("its prefix is not an enumeration".to_string())), } } -/// Read `IfcConversionBasedUnit.ConversionFactor` (attribute 3). -/// -/// The factor is an `IfcMeasureWithUnit`, whose attribute 0 is the value. -fn conversion_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult { - let measure_id = slots.req_ref(3, "ConversionFactor")?; - let measure = slots.resolve(model, measure_id)?; +/// `IfcConversionBasedUnit`: `ConversionFactor.ValueComponent` stated in its +/// `UnitComponent`. +fn conversion_factor(model: &Model, slots: &Slots<'_>, kind: &'static str) -> GeometryResult { + let unreadable = |detail: String| GeometryError::UnitUnreadable { + unit: slots.id(), + unit_type: kind, + detail, + }; + let measure_id = slots + .req_ref(3, "ConversionFactor") + .map_err(|e| unreadable(e.to_string()))?; + let measure = slots + .resolve(model, measure_id) + .map_err(|e| unreadable(e.to_string()))?; + if &*measure.type_name != "IFCMEASUREWITHUNIT" { + return Err(unreadable(format!( + "its ConversionFactor {measure_id} is {}, not IFCMEASUREWITHUNIT", + measure.type_name + ))); + } let measure_slots = Slots::new(measure_id, measure); - measure_slots + let value = measure_slots .req_f64(0, "ValueComponent") - .map_err(|_| GeometryError::Units(format!("{id} has an unreadable conversion factor"))) + .map_err(|e| unreadable(e.to_string()))?; + let component_id = measure_slots + .req_ref(1, "UnitComponent") + .map_err(|e| unreadable(e.to_string()))?; + let component = measure_slots + .resolve(model, component_id) + .map_err(|e| unreadable(e.to_string()))?; + let component_slots = Slots::new(component_id, component); + match &*component.type_name { + "IFCSIUNIT" => { + if component_slots.opt_enum(1) != Some(kind) { + return Err(unreadable(format!( + "its UnitComponent {component_id} is not a {kind}" + ))); + } + Ok(value * si_factor(&component_slots, kind)?) + } + "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => { + Err(GeometryError::Unsupported { + entity: component_id, + type_name: component.type_name.to_string(), + detail: "a conversion-based unit defined through another conversion-based unit is not resolved", + }) + } + other => Err(unreadable(format!( + "its UnitComponent {component_id} is {other}, not an IfcSIUnit" + ))), + } } -/// Multiplier for an `IfcSIPrefix`. +/// Multiplier for an `IfcSIPrefix`, or `None` for an unknown token. /// /// Values from ISO 80000; `EXA` through `ATTO` are all legal in IFC even where /// nonsensical for a building, so the whole table is present rather than the /// three prefixes that occur in practice. -fn prefix_factor(prefix: Option<&str>) -> f64 { - match prefix { - None => 1.0, - Some(p) => match p { - "EXA" => 1e18, - "PETA" => 1e15, - "TERA" => 1e12, - "GIGA" => 1e9, - "MEGA" => 1e6, - "KILO" => 1e3, - "HECTO" => 1e2, - "DECA" => 1e1, - "DECI" => 1e-1, - "CENTI" => 1e-2, - "MILLI" => 1e-3, - "MICRO" => 1e-6, - "NANO" => 1e-9, - "PICO" => 1e-12, - "FEMTO" => 1e-15, - "ATTO" => 1e-18, - _ => 1.0, - }, - } +fn prefix_factor(prefix: &str) -> Option { + Some(match prefix { + "EXA" => 1e18, + "PETA" => 1e15, + "TERA" => 1e12, + "GIGA" => 1e9, + "MEGA" => 1e6, + "KILO" => 1e3, + "HECTO" => 1e2, + "DECA" => 1e1, + "DECI" => 1e-1, + "CENTI" => 1e-2, + "MILLI" => 1e-3, + "MICRO" => 1e-6, + "NANO" => 1e-9, + "PICO" => 1e-12, + "FEMTO" => 1e-15, + "ATTO" => 1e-18, + _ => return None, + }) } #[cfg(test)] -mod tests { - use super::*; - use ifc_model::{Entity, Value}; - - fn model_with_length_unit(prefix: Option<&str>) -> Model { - let mut model = Model::new(); - let prefix_value = match prefix { - Some(p) => Value::Enum(p.into()), - None => Value::Null, - }; - model.insert( - EntityId(1), - Entity::new( - "IFCSIUNIT", - vec![ - Value::Derived, - Value::Enum("LENGTHUNIT".into()), - prefix_value, - Value::Enum("METRE".into()), - ], - ), - ); - model.insert( - EntityId(2), - Entity::new( - "IFCUNITASSIGNMENT", - vec![Value::List(vec![Value::Ref(EntityId(1))])], - ), - ); - let mut project = vec![Value::Null; 9]; - project[8] = Value::Ref(EntityId(2)); - model.insert(EntityId(3), Entity::new("IFCPROJECT", project)); - model - } - - #[test] - fn metres_are_the_identity() { - let scale = resolve(&model_with_length_unit(None)); - assert_eq!(scale.length_to_metres, 1.0); - assert!(scale.is_metric_identity()); - } - - /// The case that silently breaks viewers: a 3000 mm wall is 3 m. - #[test] - fn millimetres_scale_coordinates_down_by_a_thousand() { - let scale = resolve(&model_with_length_unit(Some("MILLI"))); - assert_eq!(scale.length_to_metres, 1e-3); - assert_eq!(scale.length(3000.0), 3.0); - assert!(!scale.is_metric_identity()); - } - - #[test] - fn handles_the_whole_si_prefix_table() { - assert_eq!(prefix_factor(Some("KILO")), 1e3); - assert_eq!(prefix_factor(Some("CENTI")), 1e-2); - assert_eq!(prefix_factor(Some("MICRO")), 1e-6); - assert_eq!(prefix_factor(Some("UNRECOGNIZED")), 1.0); - } - - /// A file without units still loads; it is merely unscaled. - #[test] - fn missing_unit_assignment_falls_back_to_si_rather_than_failing() { - let scale = resolve(&Model::new()); - assert_eq!(scale, UnitScale::default()); - } - - /// Degrees arrive as a conversion-based unit of pi/180. - #[test] - fn resolves_conversion_based_angle_units() { - let mut model = Model::new(); - model.insert( - EntityId(1), - Entity::new( - "IFCMEASUREWITHUNIT", - vec![ - Value::Typed { - type_name: "IFCPLANEANGLEMEASURE".into(), - value: Box::new(Value::Real(0.017453292519943295)), - }, - Value::Null, - ], - ), - ); - model.insert( - EntityId(2), - Entity::new( - "IFCCONVERSIONBASEDUNIT", - vec![ - Value::Null, - Value::Enum("PLANEANGLEUNIT".into()), - Value::Text("DEGREE".into()), - Value::Ref(EntityId(1)), - ], - ), - ); - model.insert( - EntityId(3), - Entity::new( - "IFCUNITASSIGNMENT", - vec![Value::List(vec![Value::Ref(EntityId(2))])], - ), - ); - let mut project = vec![Value::Null; 9]; - project[8] = Value::Ref(EntityId(3)); - model.insert(EntityId(4), Entity::new("IFCPROJECT", project)); - - let scale = resolve(&model); - assert!((scale.angle(90.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12); - } -} +mod tests; diff --git a/crates/ifc-geometry/src/units/tests.rs b/crates/ifc-geometry/src/units/tests.rs new file mode 100644 index 00000000..6d2243de --- /dev/null +++ b/crates/ifc-geometry/src/units/tests.rs @@ -0,0 +1,417 @@ +use super::*; +use ifc_model::Entity; + +fn id(n: u32) -> EntityId { + EntityId(n.into()) +} + +fn si(unit_type: &str, prefix: Option<&str>, name: &str) -> Entity { + Entity::new( + "IFCSIUNIT", + vec![ + Value::Derived, + Value::Enum(unit_type.into()), + prefix.map_or(Value::Null, |p| Value::Enum(p.into())), + Value::Enum(name.into()), + ], + ) +} + +fn measure(value: Value, component: Value) -> Entity { + Entity::new("IFCMEASUREWITHUNIT", vec![value, component]) +} + +fn real(type_name: &str, value: f64) -> Value { + Value::Typed { + type_name: type_name.into(), + value: Box::new(Value::Real(value)), + } +} + +fn conversion(type_name: &str, unit_type: &str, name: &str, factor: u32) -> Entity { + Entity::new( + type_name, + vec![ + Value::Null, + Value::Enum(unit_type.into()), + Value::Text(name.into()), + Value::Ref(id(factor)), + ], + ) +} + +fn assignment(units: &[u32]) -> Entity { + Entity::new( + "IFCUNITASSIGNMENT", + vec![Value::List( + units.iter().map(|u| Value::Ref(id(*u))).collect(), + )], + ) +} + +fn project(assignment: Option) -> Entity { + let mut slots = vec![Value::Null; 9]; + if let Some(a) = assignment { + slots[8] = Value::Ref(id(a)); + } + Entity::new("IFCPROJECT", slots) +} + +/// `#10` is the unit assignment, `#11` the project. +fn model_with(entities: Vec<(u32, Entity)>, units: &[u32]) -> Model { + let mut model = Model::new(); + for (n, entity) in entities { + model.insert(id(n), entity); + } + model.insert(id(10), assignment(units)); + model.insert(id(11), project(Some(10))); + model +} + +fn model_with_length_unit(prefix: Option<&str>) -> Model { + model_with(vec![(1, si("LENGTHUNIT", prefix, "METRE"))], &[1]) +} + +#[test] +fn metres_are_the_identity() { + let scale = resolve(&model_with_length_unit(None)).expect("units"); + assert_eq!(scale.length_to_metres, 1.0); + assert_eq!(scale.angle_to_radians, None); + assert!(scale.is_metric_identity()); +} + +/// The case that silently breaks viewers: a 3000 mm wall is 3 m. +#[test] +fn millimetres_scale_coordinates_down_by_a_thousand() { + let scale = resolve(&model_with_length_unit(Some("MILLI"))).expect("units"); + assert_eq!(scale.length_to_metres, 1e-3); + assert_eq!(scale.length(3000.0), 3.0); + assert!(!scale.is_metric_identity()); +} + +#[test] +fn handles_the_whole_si_prefix_table() { + assert_eq!(prefix_factor("KILO"), Some(1e3)); + assert_eq!(prefix_factor("CENTI"), Some(1e-2)); + assert_eq!(prefix_factor("MICRO"), Some(1e-6)); + assert_eq!(prefix_factor("UNRECOGNIZED"), None); +} + +/// Degrees arrive as a conversion-based unit of pi/180 radians. +#[test] +fn resolves_conversion_based_angle_units() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", None, "METRE")), + (2, si("PLANEANGLEUNIT", None, "RADIAN")), + ( + 3, + measure( + real("IFCPLANEANGLEMEASURE", 0.017453292519943295), + Value::Ref(id(2)), + ), + ), + ( + 4, + conversion("IFCCONVERSIONBASEDUNIT", "PLANEANGLEUNIT", "DEGREE", 3), + ), + ], + &[1, 4], + ); + let scale = resolve(&model).expect("units"); + let radians = scale.angle(id(99), 90.0).expect("angle unit"); + assert!((radians - std::f64::consts::FRAC_PI_2).abs() < 1e-12); +} + +#[test] +fn no_project_refuses() { + assert_eq!( + resolve(&Model::new()), + Err(GeometryError::LengthUnitUndeclared { + project: None, + assignment: None + }) + ); +} + +#[test] +fn project_without_units_in_context_refuses() { + let mut model = Model::new(); + model.insert(id(1), project(None)); + assert_eq!( + resolve(&model), + Err(GeometryError::LengthUnitUndeclared { + project: Some(id(1)), + assignment: None + }) + ); +} + +#[test] +fn assignment_without_length_unit_refuses() { + let model = model_with(vec![(1, si("PLANEANGLEUNIT", None, "RADIAN"))], &[1]); + assert_eq!( + resolve(&model), + Err(GeometryError::LengthUnitUndeclared { + project: Some(id(11)), + assignment: Some(id(10)) + }) + ); +} + +#[test] +fn two_projects_refuse() { + let mut model = model_with_length_unit(None); + model.insert(id(12), project(Some(10))); + assert_eq!( + resolve(&model), + Err(GeometryError::UnitsAmbiguous { + first: id(11), + second: id(12), + kind: "IFCPROJECT" + }) + ); +} + +#[test] +fn two_length_units_refuse() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", None, "METRE")), + (2, si("LENGTHUNIT", Some("MILLI"), "METRE")), + ], + &[1, 2], + ); + assert_eq!( + resolve(&model), + Err(GeometryError::UnitsAmbiguous { + first: id(1), + second: id(2), + kind: "LENGTHUNIT" + }) + ); +} + +#[test] +fn unknown_prefix_refuses() { + let model = model_with_length_unit(Some("BOGUS")); + assert!(matches!( + resolve(&model), + Err(GeometryError::UnitUnreadable { + unit, + unit_type: "LENGTHUNIT", + .. + }) if unit == id(1) + )); +} + +#[test] +fn si_length_unit_not_named_metre_refuses() { + let model = model_with(vec![(1, si("LENGTHUNIT", None, "GRAM"))], &[1]); + match resolve(&model) { + Err(GeometryError::UnitUnreadable { unit, detail, .. }) => { + assert_eq!(unit, id(1)); + assert!(detail.contains("WR1"), "{detail}"); + } + other => panic!("expected UnitUnreadable, got {other:?}"), + } +} + +#[test] +fn conversion_factor_without_a_number_refuses() { + let model = model_with( + vec![ + ( + 2, + measure( + Value::Typed { + type_name: "IFCLABEL".into(), + value: Box::new(Value::Text("x".into())), + }, + Value::Null, + ), + ), + ( + 3, + conversion("IFCCONVERSIONBASEDUNIT", "LENGTHUNIT", "INCH", 2), + ), + ], + &[3], + ); + assert!(matches!( + resolve(&model), + Err(GeometryError::UnitUnreadable { + unit_type: "LENGTHUNIT", + .. + }) + )); +} + +/// The factor is stated in its unit component: 25.4 millimetres. +#[test] +fn inch_stated_in_millimetres_reads_the_unit_component() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", Some("MILLI"), "METRE")), + ( + 2, + measure(real("IFCLENGTHMEASURE", 25.4), Value::Ref(id(1))), + ), + ( + 3, + conversion("IFCCONVERSIONBASEDUNIT", "LENGTHUNIT", "INCH", 2), + ), + ], + &[3], + ); + let scale = resolve(&model).expect("units"); + assert!((scale.length_to_metres - 25.4 * 1e-3).abs() < 1e-15); +} + +#[test] +fn nested_conversion_unit_is_unsupported() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", None, "METRE")), + ( + 2, + measure(real("IFCLENGTHMEASURE", 0.0254), Value::Ref(id(1))), + ), + ( + 3, + conversion("IFCCONVERSIONBASEDUNIT", "LENGTHUNIT", "INCH", 2), + ), + ( + 4, + measure(real("IFCLENGTHMEASURE", 12.0), Value::Ref(id(3))), + ), + ( + 5, + conversion("IFCCONVERSIONBASEDUNIT", "LENGTHUNIT", "FOOT", 4), + ), + ], + &[5], + ); + match resolve(&model) { + Err(error @ GeometryError::Unsupported { .. }) => { + assert_eq!(error.entity(), Some(id(3))); + assert!(error.is_unsupported()); + } + other => panic!("expected Unsupported, got {other:?}"), + } +} + +#[test] +fn context_dependent_length_unit_refuses() { + let model = model_with( + vec![( + 1, + Entity::new( + "IFCCONTEXTDEPENDENTUNIT", + vec![ + Value::Null, + Value::Enum("LENGTHUNIT".into()), + Value::Text("PIXEL".into()), + ], + ), + )], + &[1], + ); + assert!(matches!( + resolve(&model), + Err(GeometryError::UnitUnreadable { + unit_type: "LENGTHUNIT", + .. + }) + )); +} + +#[test] +fn offset_length_unit_refuses() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", None, "METRE")), + (2, measure(real("IFCLENGTHMEASURE", 1.0), Value::Ref(id(1)))), + ( + 3, + Entity::new( + "IFCCONVERSIONBASEDUNITWITHOFFSET", + vec![ + Value::Null, + Value::Enum("LENGTHUNIT".into()), + Value::Text("SHIFTED".into()), + Value::Ref(id(2)), + Value::Real(32.0), + ], + ), + ), + ], + &[3], + ); + assert!(matches!( + resolve(&model), + Err(GeometryError::UnitUnreadable { + unit_type: "LENGTHUNIT", + .. + }) + )); +} + +#[test] +fn dangling_unit_reference_refuses() { + let model = model_with(vec![(1, si("LENGTHUNIT", None, "METRE"))], &[1, 77]); + assert_eq!( + resolve(&model), + Err(GeometryError::MissingEntity { + referrer: id(10), + missing: id(77) + }) + ); +} + +#[test] +fn missing_angle_unit_is_none_and_reading_an_angle_refuses() { + let scale = resolve(&model_with_length_unit(None)).expect("units"); + assert_eq!(scale.angle_to_radians, None); + assert_eq!( + scale.angle(id(5), 1.0), + Err(GeometryError::AngleUnitUndeclared { entity: id(5) }) + ); + assert_eq!( + scale.radians_per_unit(id(6)), + Err(GeometryError::AngleUnitUndeclared { entity: id(6) }) + ); +} + +#[test] +fn unreadable_angle_unit_refuses_resolve() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", None, "METRE")), + (2, si("PLANEANGLEUNIT", None, "METRE")), + ], + &[1, 2], + ); + assert!(matches!( + resolve(&model), + Err(GeometryError::UnitUnreadable { + unit, + unit_type: "PLANEANGLEUNIT", + .. + }) if unit == id(2) + )); +} + +#[test] +fn error_entity_names_the_unit() { + let model = model_with_length_unit(Some("BOGUS")); + let error = resolve(&model).expect_err("unknown prefix"); + assert_eq!(error.entity(), Some(id(1))); + assert!(error.to_string().contains("#1")); +} + +#[test] +fn si_constant_is_metres_and_radians() { + assert_eq!(UnitScale::SI.length(2.0), 2.0); + assert_eq!(UnitScale::SI.angle(id(1), 2.0), Ok(2.0)); +} From a5c19c6a54ecdf18de363e80bda3eae9dc8fb47f Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:09:59 +0000 Subject: [PATCH 03/13] feat(openbim-ifc): refuse each product of a model without resolvable units Product geometry entry points carry the unit refusal on every requested product instead of placing the model in metres. Refs #585. --- crates/openbim-ifc/src/operation/read.rs | 5 +++-- crates/openbim-ifc/src/product_geometry.rs | 17 +++++++++++++++-- .../openbim-ifc/src/product_geometry/curves.rs | 10 +++++++++- .../openbim-ifc/src/product_geometry/graph.rs | 10 +++++++++- crates/openbim-ifc/src/product_geometry/mesh.rs | 10 +++++++++- 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/openbim-ifc/src/operation/read.rs b/crates/openbim-ifc/src/operation/read.rs index e382a10b..eac01347 100644 --- a/crates/openbim-ifc/src/operation/read.rs +++ b/crates/openbim-ifc/src/operation/read.rs @@ -131,8 +131,9 @@ fn length_scale(model: &Model) -> Result { /// The product's world transform in metres, scaled by the exact length /// unit rather than the permissive one. pub(crate) fn world_transform(model: &Model, product: EntityId) -> Result { - let mut scale = units::resolve(model); - scale.length_to_metres = length_scale(model)?; + let length = length_scale(model)?; + let mut scale = units::resolve(model).map_err(ReadError::Placement)?; + scale.length_to_metres = length; product_world_transform(model, &scale, product).map_err(ReadError::Placement) } diff --git a/crates/openbim-ifc/src/product_geometry.rs b/crates/openbim-ifc/src/product_geometry.rs index 383a0639..7fa17bc0 100644 --- a/crates/openbim-ifc/src/product_geometry.rs +++ b/crates/openbim-ifc/src/product_geometry.rs @@ -89,10 +89,23 @@ pub struct SelectedContext { /// /// Placements share one resolver, so a storey's chain is walked once. An /// id the model lacks is reported with [`GeometryError::MissingEntity`] in -/// both halves. +/// both halves. A model whose units do not resolve refuses every product +/// with that error (#585). pub fn product_placements(model: &Model, products: Option<&[EntityId]>) -> Vec { let products = products.map_or_else(|| geometric_products(model), <[EntityId]>::to_vec); - let scale = units::resolve(model); + let scale = match units::resolve(model) { + Ok(scale) => scale, + Err(error) => { + return products + .into_iter() + .map(|product| ProductPlacement { + product, + world: Err(error.clone()), + body: selected_body(model, product), + }) + .collect(); + } + }; products_world_transforms(model, &scale, products) .into_iter() .map(|(product, world)| ProductPlacement { diff --git a/crates/openbim-ifc/src/product_geometry/curves.rs b/crates/openbim-ifc/src/product_geometry/curves.rs index 005508e6..e777eafa 100644 --- a/crates/openbim-ifc/src/product_geometry/curves.rs +++ b/crates/openbim-ifc/src/product_geometry/curves.rs @@ -89,7 +89,15 @@ pub fn product_curves( purpose: RepresentationPurpose, ) -> Vec<(EntityId, GeometryResult)> { let products = products.map_or_else(|| geometric_products(model), <[EntityId]>::to_vec); - let scale = units::resolve(model); + let scale = match units::resolve(model) { + Ok(scale) => scale, + Err(error) => { + return products + .into_iter() + .map(|product| (product, Err(error.clone()))) + .collect(); + } + }; let backend = default_backend(); products_world_transforms(model, &scale, products) .into_iter() diff --git a/crates/openbim-ifc/src/product_geometry/graph.rs b/crates/openbim-ifc/src/product_geometry/graph.rs index 9873e4ab..d340fe2f 100644 --- a/crates/openbim-ifc/src/product_geometry/graph.rs +++ b/crates/openbim-ifc/src/product_geometry/graph.rs @@ -66,7 +66,15 @@ pub fn product_graphs( products: Option<&[EntityId]>, ) -> Vec<(EntityId, GeometryResult)> { let products = products.map_or_else(|| geometric_products(model), <[EntityId]>::to_vec); - let scale = units::resolve(model); + let scale = match units::resolve(model) { + Ok(scale) => scale, + Err(error) => { + return products + .into_iter() + .map(|product| (product, Err(error.clone()))) + .collect(); + } + }; products_world_transforms(model, &scale, products) .into_iter() .map(|(product, world)| { diff --git a/crates/openbim-ifc/src/product_geometry/mesh.rs b/crates/openbim-ifc/src/product_geometry/mesh.rs index 2963255c..e72424ab 100644 --- a/crates/openbim-ifc/src/product_geometry/mesh.rs +++ b/crates/openbim-ifc/src/product_geometry/mesh.rs @@ -100,7 +100,15 @@ pub fn product_meshes_for( purpose: RepresentationPurpose, ) -> Vec<(EntityId, GeometryResult)> { let products = products.map_or_else(|| geometric_products(model), <[EntityId]>::to_vec); - let scale = units::resolve(model); + let scale = match units::resolve(model) { + Ok(scale) => scale, + Err(error) => { + return products + .into_iter() + .map(|product| (product, Err(error.clone()))) + .collect(); + } + }; let backend = default_backend(); let resolver = ColourResolver::new(model); let colour = |product| match (&resolver, purpose) { From 42ebc461347cc0604838f627f6b388aae7f7c652 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:17:09 +0000 Subject: [PATCH 04/13] test(ifc-geometry): migrate tests to refusing unit resolution Models and fixtures that declare no units read in UnitScale::SI or through tests/support/assume_units.rs; the committed fixtures are unchanged. Adds the #585 repro and reworks units_on_fixtures to count resolved and refused fixtures. --- .../tests/advanced_brep_3d_edges_compile.rs | 2 +- .../ifc-geometry/tests/authoring_roundtrip.rs | 6 +- .../tests/authoring_std_profiles.rs | 2 +- crates/ifc-geometry/tests/body_description.rs | 117 +++++++++++++----- .../tests/body_description/profiles.rs | 26 ++-- .../tests/body_description/refusals.rs | 30 ++++- .../tests/bounded_halfspace_depth.rs | 2 +- .../tests/bounded_halfspace_prism_axis.rs | 2 +- crates/ifc-geometry/tests/compile_pairing.rs | 10 +- .../tests/composite_sweep_parameters.rs | 4 +- .../tests/connection_surface_frames.rs | 8 +- .../tests/curve_bounded_composite_compile.rs | 2 +- .../tests/degenerate_face_policy.rs | 14 ++- .../tests/derive_bounded_paths.rs | 16 +-- .../ifc-geometry/tests/derive_open_paths.rs | 12 +- crates/ifc-geometry/tests/derive_paths.rs | 12 +- crates/ifc-geometry/tests/derive_placement.rs | 4 +- .../tests/derive_placement_polyline.rs | 4 +- crates/ifc-geometry/tests/derive_seams.rs | 8 +- crates/ifc-geometry/tests/grid_placement.rs | 14 ++- .../ifc-geometry/tests/halfspace_boundary.rs | 4 +- .../ifc-geometry/tests/indexed_curve_arcs.rs | 2 +- .../tests/indexed_profile_boundary.rs | 6 +- .../ifc-geometry/tests/kernel_free_build.rs | 4 +- crates/ifc-geometry/tests/linear_placement.rs | 6 +- .../tests/linear_placement_evaluator.rs | 24 ++-- .../ifc-geometry/tests/lower_advanced_brep.rs | 4 +- crates/ifc-geometry/tests/lower_brep.rs | 21 ++-- .../tests/lower_collections_and_primitives.rs | 4 +- .../tests/lower_connection_surface.rs | 14 ++- crates/ifc-geometry/tests/lower_csg_swept.rs | 6 +- .../tests/lower_dispatch_corpus.rs | 18 +-- crates/ifc-geometry/tests/lower_fixtures.rs | 10 +- crates/ifc-geometry/tests/lower_halfspace.rs | 8 +- .../ifc-geometry/tests/lower_ifc4x3_curves.rs | 4 +- .../tests/lower_ifc4x3_sweeps_profiles.rs | 9 +- crates/ifc-geometry/tests/lower_mapped.rs | 23 ++-- .../ifc-geometry/tests/lower_open_profile.rs | 10 +- crates/ifc-geometry/tests/lower_product.rs | 12 +- .../tests/lower_profile_families.rs | 6 +- crates/ifc-geometry/tests/lower_properties.rs | 4 +- crates/ifc-geometry/tests/lower_provenance.rs | 9 +- .../tests/lower_representation_selection.rs | 2 +- crates/ifc-geometry/tests/lower_session.rs | 16 +-- crates/ifc-geometry/tests/lower_subedge.rs | 6 +- crates/ifc-geometry/tests/lower_surface.rs | 9 +- .../tests/lower_synthetic_surfaces.rs | 6 +- .../tests/lower_tapered_sweeps.rs | 4 +- .../ifc-geometry/tests/lower_tessellated.rs | 10 +- crates/ifc-geometry/tests/meshing_coverage.rs | 4 +- crates/ifc-geometry/tests/net_host_frame.rs | 6 +- .../ifc-geometry/tests/net_reference_view.rs | 4 +- crates/ifc-geometry/tests/nurbs_lowering.rs | 2 +- crates/ifc-geometry/tests/placement_frames.rs | 12 +- crates/ifc-geometry/tests/profile_outline.rs | 2 +- crates/ifc-geometry/tests/stations/common.rs | 4 +- .../tests/stations/offset_bases.rs | 2 +- .../tests/stations/offset_run_bases.rs | 8 +- crates/ifc-geometry/tests/stations/offsets.rs | 2 +- crates/ifc-geometry/tests/stations/points.rs | 2 +- .../ifc-geometry/tests/stations/relations.rs | 2 +- crates/ifc-geometry/tests/stub_kernel.rs | 12 +- .../tests/support/assume_units.rs | 70 +++++++++++ crates/ifc-geometry/tests/sweep_trim_kind.rs | 7 +- .../ifc-geometry/tests/units_on_fixtures.rs | 63 ++++++---- crates/ifc-geometry/tests/units_refusal.rs | 97 +++++++++++++++ 66 files changed, 584 insertions(+), 271 deletions(-) create mode 100644 crates/ifc-geometry/tests/support/assume_units.rs create mode 100644 crates/ifc-geometry/tests/units_refusal.rs diff --git a/crates/ifc-geometry/tests/advanced_brep_3d_edges_compile.rs b/crates/ifc-geometry/tests/advanced_brep_3d_edges_compile.rs index 6ecc3c4b..b9e022d1 100644 --- a/crates/ifc-geometry/tests/advanced_brep_3d_edges_compile.rs +++ b/crates/ifc-geometry/tests/advanced_brep_3d_edges_compile.rs @@ -157,7 +157,7 @@ fn assert_bounds(name: &str, mesh: &TriMesh, lo: [f64; 3], hi: [f64; 3], slack: /// The deviation report of `name`'s body, compiled as the product entry /// points compile it. fn report(model: &Model, name: &str) -> axiolid_mesh_compile::DeviationReport { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_product_representation( &mut session, diff --git a/crates/ifc-geometry/tests/authoring_roundtrip.rs b/crates/ifc-geometry/tests/authoring_roundtrip.rs index 8145449a..7e6136ba 100644 --- a/crates/ifc-geometry/tests/authoring_roundtrip.rs +++ b/crates/ifc-geometry/tests/authoring_roundtrip.rs @@ -10,6 +10,7 @@ use ifc_geometry::authoring::{ rectangle_profile, }; use ifc_geometry::solid::boolean::IfcBooleanOperator; +use ifc_geometry::UnitScale; use ifc_model::{Model, Transaction}; /// A 6m x 0.3m wall extruded 2.4m up, authored from plain numbers. @@ -48,7 +49,6 @@ fn an_authored_extrusion_is_read_back_by_the_same_crate() { fn an_authored_extrusion_lowers_into_the_neutral_graph() { use ifc_geometry::lower::{lower_representation_item, LoweringSession}; use ifc_geometry::transform::Transform; - use ifc_geometry::units; let model = Model::default(); let mut tx = Transaction::new(&model); @@ -56,7 +56,7 @@ fn an_authored_extrusion_lowers_into_the_neutral_graph() { let mut model = model; tx.commit(&mut model).expect("commit"); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, solid, Transform::identity()) .expect("authored solid lowers"); @@ -126,7 +126,7 @@ fn an_authored_extrusion_survives_step_text_and_lowers() { let view = ifc_geometry::solid::swept::ExtrudedAreaSolid::new(solid, entity); assert_eq!(view.depth().expect("depth"), 2.4); - let units = ifc_geometry::units::UnitScale::default(); + let units = ifc_geometry::units::UnitScale::SI; let mut session = LoweringSession::new(&reparsed, &units); lower_representation_item(&mut session, solid, Transform::identity()) .expect("reparsed solid lowers into the neutral graph"); diff --git a/crates/ifc-geometry/tests/authoring_std_profiles.rs b/crates/ifc-geometry/tests/authoring_std_profiles.rs index c88b65db..bbf656ce 100644 --- a/crates/ifc-geometry/tests/authoring_std_profiles.rs +++ b/crates/ifc-geometry/tests/authoring_std_profiles.rs @@ -568,7 +568,7 @@ fn authored_sections_lower_with_their_numbers_intact() { ) .expect("I section") }); - let profile = lower_profile(&model, id, &UnitScale::default()).expect("lowers"); + let profile = lower_profile(&model, id, &UnitScale::SI).expect("lowers"); let text = format!("{profile:?}"); for expected in ["300.0", "150.0", "7.1", "10.7", "15.0"] { assert!( diff --git a/crates/ifc-geometry/tests/body_description.rs b/crates/ifc-geometry/tests/body_description.rs index 2e87b532..85ecede9 100644 --- a/crates/ifc-geometry/tests/body_description.rs +++ b/crates/ifc-geometry/tests/body_description.rs @@ -45,9 +45,13 @@ fn an_extrusion_reports_its_profile_direction_and_depth_in_si() { #104=IFCEXTRUDEDAREASOLID(#100,#102,#103,3000.);", "#104", ); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .expect("describes") - .expect("has a body"); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .expect("describes") + .expect("has a body"); assert_eq!(body.representation, EntityId(20)); let item = body.sole_item().expect("one item"); assert_eq!(item.item, EntityId(104)); @@ -104,9 +108,13 @@ fn a_sheared_extrusion_in_rotated_frames_reports_its_world_direction() { #106=IFCEXTRUDEDAREASOLID(#100,#104,#105,500.);", "#106", ); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let swept = body.sole_item().unwrap().swept.as_ref().unwrap(); let SweepPath::Extrusion { direction_world, @@ -133,9 +141,13 @@ fn a_sheared_extrusion_in_rotated_frames_reports_its_world_direction() { #[test] fn every_item_of_the_profile_family_fixture_is_described_in_si() { let model = profile_families(); - let body = body_description(&model, &units::resolve(&model), EntityId(89)) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + EntityId(89), + ) + .unwrap() + .unwrap(); let families: Vec<&str> = body .items .iter() @@ -189,7 +201,12 @@ fn a_product_without_a_body_is_none_not_an_error() { ), ); assert_eq!( - body_description(&model, &units::resolve(&model), PRODUCT).unwrap(), + body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT + ) + .unwrap(), None ); } @@ -213,9 +230,13 @@ fn a_revolution_reports_its_world_axis_and_angle() { #104=IFCREVOLVEDAREASOLID(#100,$,#101,1.5);", "#104", ); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let item = body.sole_item().unwrap(); assert_eq!(item.kind, BodyKind::Revolution); let SweepPath::Revolution { @@ -248,9 +269,13 @@ fn tapered_and_directrix_sweeps_report_what_they_state() { #109=IFCFIXEDREFERENCESWEPTAREASOLID(#108,$,#106,$,$,#107);", "#103,#109", ); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let [tapered, directrix] = body.items.as_slice() else { panic!("two items"); }; @@ -279,9 +304,13 @@ fn a_brep_body_reports_its_kind_only() { #102=IFCFACETEDBREP(#101);", "#102", ); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let item = body.sole_item().unwrap(); assert_eq!(item.kind, BodyKind::Brep); assert_eq!(item.type_name, "IFCFACETEDBREP"); @@ -322,9 +351,13 @@ const MAPPED: &str = "#200=IFCRECTANGLEPROFILEDEF(.AREA.,$,$,0.2,0.1); #[test] fn a_mapped_item_describes_like_the_same_solid_authored_in_place() { let model = metres(MAPPED, "#215,#220"); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let [mapped, direct] = body.items.as_slice() else { panic!("the map resolves to one item beside the direct one"); }; @@ -382,9 +415,13 @@ fn the_product_placement_moves_the_description() { let near = metres(MAPPED, "#215"); let far = model("IFC4", "$", "0.,1.,0.", MAPPED, "#215"); let direction = |model: &Model| { - let body = body_description(model, &units::resolve(model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + model, + &units::resolve(model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); match body.sole_item().unwrap().swept.as_ref().unwrap().path { SweepPath::Extrusion { direction_world, .. @@ -427,9 +464,13 @@ fn the_context_world_coordinate_system_is_applied() { ], ), ); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let swept = body.sole_item().unwrap().swept.as_ref().unwrap(); assert!( close(swept.placement_world.origin, [80.0, 110.0, 30.0]), @@ -495,9 +536,13 @@ const MAPPED_KINDS: &str = "#300=IFCFACE(()); fn mapped_item(records: &str, item: &str) -> ifc_geometry::BodyItem { let model = metres(&format!("{MAPPED_KINDS}\n {records}"), item); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .expect("describes") - .expect("has a body"); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .expect("describes") + .expect("has a body"); body.sole_item().expect("one item").clone() } @@ -562,9 +607,13 @@ fn a_scaled_mapping_scales_the_determinant() { #[test] fn a_direct_item_reports_the_product_frame() { let model = metres(MAPPED_KINDS, "#302"); - let body = body_description(&model, &units::resolve(&model), PRODUCT) - .unwrap() - .unwrap(); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + PRODUCT, + ) + .unwrap() + .unwrap(); let item = body.sole_item().unwrap(); assert!(item.mapped_by.is_empty()); assert_eq!(item.is_mirrored(), Some(false)); diff --git a/crates/ifc-geometry/tests/body_description/profiles.rs b/crates/ifc-geometry/tests/body_description/profiles.rs index add09955..f1c20834 100644 --- a/crates/ifc-geometry/tests/body_description/profiles.rs +++ b/crates/ifc-geometry/tests/body_description/profiles.rs @@ -12,8 +12,12 @@ use super::step::{close, metres, model}; /// Describe entity `id` of `model` with the model's own units. fn describe(model: &Model, id: u64) -> ProfileDescription { - describe_profile(model, &units::resolve(model), EntityId(id)) - .unwrap_or_else(|error| panic!("#{id} describes: {error}")) + describe_profile( + model, + &units::resolve(model).expect("project units"), + EntityId(id), + ) + .unwrap_or_else(|error| panic!("#{id} describes: {error}")) } const DEG: f64 = std::f64::consts::PI / 180.0; @@ -491,9 +495,12 @@ mod lowering_agreement { #103=IFCDIRECTION((0.,1.));", "", ); - let Profile::Derived { basis, transform } = - lower_profile(&model, EntityId(100), &units::resolve(&model)).unwrap() - else { + let Profile::Derived { basis, transform } = lower_profile( + &model, + EntityId(100), + &units::resolve(&model).expect("project units"), + ) + .unwrap() else { panic!("a positioned section lowers as Derived"); }; assert!(matches!(*basis, Profile::Section(SectionProfile::I { .. }))); @@ -505,9 +512,12 @@ mod lowering_agreement { #[test] fn a_derived_profile_lowers_its_translation_in_metres() { let model = profile_families(); - let Profile::Derived { transform, .. } = - lower_profile(&model, EntityId(22), &units::resolve(&model)).unwrap() - else { + let Profile::Derived { transform, .. } = lower_profile( + &model, + EntityId(22), + &units::resolve(&model).expect("project units"), + ) + .unwrap() else { panic!("derived"); }; assert!( diff --git a/crates/ifc-geometry/tests/body_description/refusals.rs b/crates/ifc-geometry/tests/body_description/refusals.rs index 4b9417f7..d5256d9b 100644 --- a/crates/ifc-geometry/tests/body_description/refusals.rs +++ b/crates/ifc-geometry/tests/body_description/refusals.rs @@ -9,7 +9,11 @@ use ifc_model::{EntityId, Model}; use super::step::{metres, PRODUCT}; fn describe(model: &Model) -> Result, GeometryError> { - body_description(model, &units::resolve(model), PRODUCT) + body_description( + model, + &units::resolve(model).expect("project units"), + PRODUCT, + ) } const SOLID: &str = "#101=IFCCARTESIANPOINT((0.,0.,0.)); @@ -36,8 +40,12 @@ fn an_unsupported_profile_is_refused() { #[test] fn a_generic_profile_is_refused() { let model = metres("#100=IFCPROFILEDEF(.AREA.,'HEA300');", ""); - let error = describe_profile(&model, &units::resolve(&model), EntityId(100)) - .expect_err("no geometry to describe"); + let error = describe_profile( + &model, + &units::resolve(&model).expect("project units"), + EntityId(100), + ) + .expect_err("no geometry to describe"); assert!(error.is_unsupported(), "{error:?}"); } @@ -76,7 +84,12 @@ fn a_dangling_item_is_refused() { fn a_dangling_profile_curve_is_refused() { let model = metres("#100=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#997);", ""); assert_eq!( - describe_profile(&model, &units::resolve(&model), EntityId(100)).unwrap_err(), + describe_profile( + &model, + &units::resolve(&model).expect("project units"), + EntityId(100) + ) + .unwrap_err(), GeometryError::MissingEntity { referrer: EntityId(100), missing: EntityId(997), @@ -128,7 +141,7 @@ fn a_hollow_section_with_no_hole_is_refused() { "", ); assert!(matches!( - describe_profile(&model, &units::resolve(&model), EntityId(100)).unwrap_err(), + describe_profile(&model, &units::resolve(&model).expect("project units"), EntityId(100)).unwrap_err(), GeometryError::Degenerate { entity, .. } if entity == EntityId(100) )); } @@ -167,7 +180,12 @@ fn a_profile_cycle_is_refused() { "", ); assert_eq!( - describe_profile(&model, &units::resolve(&model), EntityId(100)).unwrap_err(), + describe_profile( + &model, + &units::resolve(&model).expect("project units"), + EntityId(100) + ) + .unwrap_err(), GeometryError::CyclicChain { entity: EntityId(100), kind: "profile", diff --git a/crates/ifc-geometry/tests/bounded_halfspace_depth.rs b/crates/ifc-geometry/tests/bounded_halfspace_depth.rs index 91c8e5c8..c8efd745 100644 --- a/crates/ifc-geometry/tests/bounded_halfspace_depth.rs +++ b/crates/ifc-geometry/tests/bounded_halfspace_depth.rs @@ -104,7 +104,7 @@ fn model() -> Model { #[test] fn the_exact_body_and_the_mesh_are_cut_to_the_top_of_the_wall() { let model = model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let root = lower_product_representation(&mut session, WALL, RepresentationPurpose::Body) .expect("lowers") diff --git a/crates/ifc-geometry/tests/bounded_halfspace_prism_axis.rs b/crates/ifc-geometry/tests/bounded_halfspace_prism_axis.rs index dfb0813b..d8ae41ed 100644 --- a/crates/ifc-geometry/tests/bounded_halfspace_prism_axis.rs +++ b/crates/ifc-geometry/tests/bounded_halfspace_prism_axis.rs @@ -63,7 +63,7 @@ fn fixture(name: &str) -> Model { /// `product`'s Body lowered as the product entry points lower it. fn lowered(model: &Model, product: EntityId) -> LoweredGeometry { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_product_representation(&mut session, product, RepresentationPurpose::Body) .expect("lowers") diff --git a/crates/ifc-geometry/tests/compile_pairing.rs b/crates/ifc-geometry/tests/compile_pairing.rs index b870e712..e2ec538a 100644 --- a/crates/ifc-geometry/tests/compile_pairing.rs +++ b/crates/ifc-geometry/tests/compile_pairing.rs @@ -12,6 +12,9 @@ //! a silently empty mesh is the failure this catches. #![cfg(feature = "compile-reference-backend")] +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_core::Tolerance; use ifc_geometry::compile::compile_product_mesh; use ifc_geometry::lower::geometric_products; @@ -24,9 +27,12 @@ fn fixture(subdir: &str, name: &str) -> Model { .join("../../test/fixtures") .join(subdir) .join(name); - StepCodec + let mut model = StepCodec .read_path(&path) - .unwrap_or_else(|e| panic!("fixture {} must parse: {e:?}", path.display())) + .unwrap_or_else(|e| panic!("fixture {} must parse: {e:?}", path.display())); + // Some committed fixtures declare no units; this test is about pairing. + assume_units::declare_if_missing(&mut model); + model } /// Fixtures chosen to span the compiler's hard paths, not the easy ones. diff --git a/crates/ifc-geometry/tests/composite_sweep_parameters.rs b/crates/ifc-geometry/tests/composite_sweep_parameters.rs index 2debc2ab..fe6e5089 100644 --- a/crates/ifc-geometry/tests/composite_sweep_parameters.rs +++ b/crates/ifc-geometry/tests/composite_sweep_parameters.rs @@ -329,7 +329,7 @@ fn volume(p: Pipe) -> f64 { /// The lowered sweep's `(directrix, parameter_range)`. fn lowered_range(model: &Model) -> Result, GeometryError> { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let node = lower_representation_item(&mut session, SWEEP, Transform::identity())?; let lowered = session.finish(node)?; @@ -687,7 +687,7 @@ fn the_crankbar_full_range_is_its_whole_directrix() { .join("../../test/fixtures/ifclite-geometry/swept_disk_composite_arc_crankbar.ifc"); let model = StepCodec.read_path(&path).expect("fixture parses"); let sweep = model.ids_of_type("IFCSWEPTDISKSOLID")[0]; - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, sweep, Transform::identity()) .expect("the crankbar lowers"); diff --git a/crates/ifc-geometry/tests/connection_surface_frames.rs b/crates/ifc-geometry/tests/connection_surface_frames.rs index a97ecb61..0f677d54 100644 --- a/crates/ifc-geometry/tests/connection_surface_frames.rs +++ b/crates/ifc-geometry/tests/connection_surface_frames.rs @@ -97,7 +97,7 @@ fn tilted_frame() -> Transform { #[test] fn the_body_frame_composes_the_context_above_the_placement() { let model = model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let frame = product_representation_frame(&model, &scale, EntityId(124), RepresentationPurpose::Body) .expect("resolves") @@ -115,7 +115,7 @@ fn the_body_frame_composes_the_context_above_the_placement() { #[test] fn a_product_without_that_representation_has_no_frame() { let model = model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let frame = product_representation_frame(&model, &scale, EntityId(125), RepresentationPurpose::Body) .expect("resolves"); @@ -136,7 +136,7 @@ mod lowering { model: &Model, lower: impl FnOnce(&mut LoweringSession<'_>) -> GeometryResult, ) -> LoweredGeometry { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower(&mut session).expect("lowers"); session.finish(root).expect("finishes") @@ -190,7 +190,7 @@ mod lowering { #[test] fn the_public_frame_reproduces_body_lowering() { let model = model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let frame = product_representation_frame( &model, &scale, diff --git a/crates/ifc-geometry/tests/curve_bounded_composite_compile.rs b/crates/ifc-geometry/tests/curve_bounded_composite_compile.rs index bddc3f9c..d173f026 100644 --- a/crates/ifc-geometry/tests/curve_bounded_composite_compile.rs +++ b/crates/ifc-geometry/tests/curve_bounded_composite_compile.rs @@ -116,7 +116,7 @@ fn a_one_segment_reversed_composite_compiles_like_its_polyline() { #[test] fn the_boundaries_keep_their_segments_and_senses() { let model = fixture(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let plane = model .of_type("IFCCURVEBOUNDEDPLANE") .map(|(id, _)| id) diff --git a/crates/ifc-geometry/tests/degenerate_face_policy.rs b/crates/ifc-geometry/tests/degenerate_face_policy.rs index aec874c8..9f3b0d5d 100644 --- a/crates/ifc-geometry/tests/degenerate_face_policy.rs +++ b/crates/ifc-geometry/tests/degenerate_face_policy.rs @@ -11,6 +11,9 @@ #![cfg(feature = "compile-reference-backend")] +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_contracts::ExecutionOptions; use axiolid_core::Tolerance; use axiolid_mesh::TriMesh; @@ -21,8 +24,9 @@ use ifc_geometry::lower::{ LoweringSession, }; use ifc_geometry::transform::Transform; +use ifc_geometry::GeometryError; use ifc_geometry::RepresentationPurpose; -use ifc_geometry::{units, GeometryError}; +use ifc_geometry::UnitScale; use ifc_model::{Codec, EntityId, Model}; use ifc_step::StepCodec; @@ -104,7 +108,7 @@ fn parse(text: &str) -> Model { } fn lower(model: &Model, policy: DegenerateFacePolicy) -> Result { - let scale = units::resolve(model); + let scale = assume_units::scale_or_si(model); let mut session = LoweringSession::new(model, &scale).with_face_policy(policy); let root = lower_product_representation(&mut session, EntityId(1), RepresentationPurpose::Body)? @@ -151,7 +155,7 @@ fn assert_volume(actual: f64, expected: f64, what: &str) { fn the_default_still_refuses_a_collapsed_face() { let model = parse(&box_with_sliver()); assert_eq!( - LoweringSession::new(&model, &units::resolve(&model)).face_policy(), + LoweringSession::new(&model, &UnitScale::SI).face_policy(), DegenerateFacePolicy::Refuse ); let error = lower(&model, DegenerateFacePolicy::default()).expect_err("must refuse"); @@ -290,7 +294,7 @@ ENDSEC; END-ISO-10303-21; "; let model = parse(text); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale).with_face_policy(DegenerateFacePolicy::DropAndReport); let error = lower_faceted_brep_node(&mut session, EntityId(60), Transform::identity()) @@ -307,7 +311,7 @@ END-ISO-10303-21; #[test] fn documented_face_policy_example_reports_dropped_faces() -> Result<(), GeometryError> { let model = parse(&box_with_sliver()); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let product = EntityId(1); // docs:snippet face-policy use ifc_geometry::lower::{DegenerateFacePolicy, LoweringSession}; diff --git a/crates/ifc-geometry/tests/derive_bounded_paths.rs b/crates/ifc-geometry/tests/derive_bounded_paths.rs index 0d762104..77ac8c88 100644 --- a/crates/ifc-geometry/tests/derive_bounded_paths.rs +++ b/crates/ifc-geometry/tests/derive_bounded_paths.rs @@ -165,10 +165,10 @@ fn derived( expression: u64, evaluator: &dyn CurveEvaluator, ) -> Result { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians, + angle_to_radians: scale.angle_to_radians.expect("angle unit"), }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); @@ -179,7 +179,7 @@ fn derived( /// Each carries the precision as its window and reads the incoming side /// (#423): its basis's joints are quadratures. fn lowered(model: &Model, expression: u64) -> (GeometryGraph, NodeId, NodeId) { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_point_by_distance_node(&mut session, EntityId(expression), Transform::identity()) @@ -293,7 +293,7 @@ fn assert_reads_as_compilers_path( basis: u64, bounded: &[bool], ) -> CurvePath { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); let ours = basis_curve_path( model, @@ -383,7 +383,7 @@ fn a_placement_on_an_ellipse_trimmed_at_interior_parameters_is_the_stations() { // The piece is the trim between its parameters, as given. let path = basis_curve_path( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), EntityId(ids[0]), EntityId(2010), &evaluator, @@ -409,7 +409,7 @@ fn a_cubic_transition_trimmed_by_an_arc_length_is_the_stations() { assert!((theirs.pieces()[0].end - 30.0).abs() < 1e-10, "{theirs:?}"); let path = basis_curve_path( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), EntityId(ids[0]), EntityId(2013), &ReferenceCurveEvaluator::default(), @@ -479,7 +479,7 @@ fn a_b_spline_trimmed_by_points_is_the_stations() { let theirs = assert_reads_as_compilers_path(&model, ids[0], 2024, &[true]); let path = basis_curve_path( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), EntityId(ids[0]), EntityId(2024), &ReferenceCurveEvaluator::default(), @@ -519,7 +519,7 @@ fn a_b_spline_trimmed_by_points_is_the_stations() { #[test] fn a_trim_of_a_relation_ending_in_an_ellipse_is_stated() { let (model, ids) = points(2056, &[3.0, 8.0, 8.000004, 10.0]); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); let ours = basis_curve_path(&model, &scale, EntityId(ids[0]), EntityId(2056), &evaluator) .expect("a path"); diff --git a/crates/ifc-geometry/tests/derive_open_paths.rs b/crates/ifc-geometry/tests/derive_open_paths.rs index c9cc7038..4d87020a 100644 --- a/crates/ifc-geometry/tests/derive_open_paths.rs +++ b/crates/ifc-geometry/tests/derive_open_paths.rs @@ -99,10 +99,10 @@ fn derived( expression: u64, evaluator: &dyn CurveEvaluator, ) -> Result { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians, + angle_to_radians: scale.angle_to_radians.expect("angle unit"), }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); @@ -113,7 +113,7 @@ fn derived( /// Every one carries the precision as its window and reads the incoming /// side (#423). fn lowered(model: &Model, expression: u64) -> (GeometryGraph, NodeId, NodeId) { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_point_by_distance_node(&mut session, EntityId(expression), Transform::identity()) @@ -185,7 +185,7 @@ fn assert_matches_station(model: &Model, expression: u64) -> Transform { #[test] fn the_kerb_reads_as_the_compilers_path_with_open_ends() { let model = bases(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); for (expression, basis) in [(1000, 90), (1005, 99)] { let ours = basis_curve_path( @@ -302,7 +302,7 @@ fn world( ) -> Result { product_world_transform_with_evaluator( model, - &units::resolve(model), + &units::resolve(model).expect("project units"), product(model, name), evaluator, CachedPositionPolicy::Verify, @@ -514,7 +514,7 @@ fn an_evaluator_without_certified_joints_is_refused_naming_the_basis() { #[test] fn the_joints_snapped_to_are_certified_to_a_thousandth_of_the_window() { let model = bases(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); let path = basis_curve_path(&model, &scale, EntityId(1000), EntityId(90), &evaluator).expect("a path"); diff --git a/crates/ifc-geometry/tests/derive_paths.rs b/crates/ifc-geometry/tests/derive_paths.rs index 3b7df60c..903101d8 100644 --- a/crates/ifc-geometry/tests/derive_paths.rs +++ b/crates/ifc-geometry/tests/derive_paths.rs @@ -89,10 +89,10 @@ fn derived( expression: u64, evaluator: &dyn CurveEvaluator, ) -> Result { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians, + angle_to_radians: scale.angle_to_radians.expect("angle unit"), }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); @@ -101,7 +101,7 @@ fn derived( /// The lowered station of `expression`: the graph, its root and its basis. fn lowered(model: &Model, expression: u64) -> (GeometryGraph, NodeId, NodeId, SeamSide) { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_point_by_distance_node(&mut session, EntityId(expression), Transform::identity()) @@ -196,7 +196,7 @@ fn path_deviation(ours: &CurvePath, theirs: &CurvePath) -> Option { #[test] fn the_derived_path_equals_the_reference_compilers() { let model = relations(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); // A plain composite, a trimmed circle, the kerb placed at stations. for (expression, basis, pieces) in [(145, 29, 3), (1000, 19, 1), (130, 75, 2)] { @@ -362,7 +362,7 @@ fn world( ) -> Result { product_world_transform_with_evaluator( model, - &units::resolve(model), + &units::resolve(model).expect("project units"), product(model, name), evaluator, CachedPositionPolicy::Verify, @@ -499,7 +499,7 @@ fn offsets() -> Model { #[test] fn an_offset_basis_reads_as_the_compilers_offset_path() { let model = offsets(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); for (expression, basis, pieces) in [(59, 27, 2), (93, 32, 1), (113, 40, 2)] { let ours = basis_curve_path( diff --git a/crates/ifc-geometry/tests/derive_placement.rs b/crates/ifc-geometry/tests/derive_placement.rs index 4d203b37..35c5996a 100644 --- a/crates/ifc-geometry/tests/derive_placement.rs +++ b/crates/ifc-geometry/tests/derive_placement.rs @@ -20,7 +20,7 @@ const TOL: f64 = 1e-6; fn metres() -> UnitScale { UnitScale { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -407,7 +407,7 @@ fn a_distance_and_its_curve_are_measured_in_the_same_unit() { // now 0.1 m long and the station is 0.04 m along it. let millimetres = UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let in_millimetres = derive_placement_transform(&model, &millimetres, EntityId(9_001), &at(40.0), &evaluator) diff --git a/crates/ifc-geometry/tests/derive_placement_polyline.rs b/crates/ifc-geometry/tests/derive_placement_polyline.rs index 76bb4ad1..f67c2834 100644 --- a/crates/ifc-geometry/tests/derive_placement_polyline.rs +++ b/crates/ifc-geometry/tests/derive_placement_polyline.rs @@ -23,14 +23,14 @@ const BASIS: EntityId = EntityId(100); fn metres() -> UnitScale { UnitScale { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } fn millimetres() -> UnitScale { UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-geometry/tests/derive_seams.rs b/crates/ifc-geometry/tests/derive_seams.rs index 7a2d23a5..77b27f8b 100644 --- a/crates/ifc-geometry/tests/derive_seams.rs +++ b/crates/ifc-geometry/tests/derive_seams.rs @@ -103,10 +103,10 @@ fn derived( expression: u64, evaluator: &dyn CurveEvaluator, ) -> Result { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians, + angle_to_radians: scale.angle_to_radians.expect("angle unit"), }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); @@ -116,7 +116,7 @@ fn derived( /// The lowered station of `expression`: its basis, distance, side and /// offsets. fn lowered(model: &Model, expression: u64) -> (Curve3, f64, SeamSide, StationOffsets) { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_point_by_distance_node(&mut session, EntityId(expression), Transform::identity()) @@ -316,7 +316,7 @@ fn world( ) -> Result { product_world_transform_with_evaluator( model, - &units::resolve(model), + &units::resolve(model).expect("project units"), product(model, name), evaluator, CachedPositionPolicy::Verify, diff --git a/crates/ifc-geometry/tests/grid_placement.rs b/crates/ifc-geometry/tests/grid_placement.rs index d5413208..ed462d27 100644 --- a/crates/ifc-geometry/tests/grid_placement.rs +++ b/crates/ifc-geometry/tests/grid_placement.rs @@ -116,11 +116,15 @@ fn assert_grid_frame(world: &Transform, origin: [f64; 3], x: [f64; 2], what: &st } fn world(model: &Model, name: &str) -> Result { - product_world_transform(model, &units::resolve(model), product(model, name)) + product_world_transform( + model, + &units::resolve(model).expect("project units"), + product(model, name), + ) } fn lowered_centre(model: &Model, name: &str) -> Result<[f64; 3], GeometryError> { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_product_items(&mut session, product(model, name))?.expect("a Body"); let lowered = session.finish(root)?; @@ -417,7 +421,7 @@ mod curved { let evaluator = ReferenceCurveEvaluator::default(); let world = product_world_transform_with_evaluator( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), product(&model, "R3_CURVED"), &evaluator, CachedPositionPolicy::Verify, @@ -425,7 +429,7 @@ mod curved { .expect("intersected through the evaluator"); assert_grid_frame(&world, [x, y, 0.0], [-sin, cos], name); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale).with_curve_evaluator(&evaluator); let root = lower_product_items(&mut session, product(&model, "R3_CURVED")) .expect("lowers") @@ -447,7 +451,7 @@ mod curved { ); let error = product_world_transform_with_evaluator( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), product(&model, "R3_CURVED"), &ReferenceCurveEvaluator::default(), CachedPositionPolicy::Verify, diff --git a/crates/ifc-geometry/tests/halfspace_boundary.rs b/crates/ifc-geometry/tests/halfspace_boundary.rs index 688900f7..5347adcd 100644 --- a/crates/ifc-geometry/tests/halfspace_boundary.rs +++ b/crates/ifc-geometry/tests/halfspace_boundary.rs @@ -124,7 +124,7 @@ fn half_space(model: &Model, name: &str) -> EntityId { /// The lowered boundary node of the wall named `name`. fn boundary_node(model: &Model, name: &str) -> GeometryResult { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let node = lower_half_space_node(&mut session, half_space(model, name), Transform::identity())?; let lowered = session.finish(node).expect("session finishes"); @@ -660,7 +660,7 @@ fn each_arc_bounded_wall_compiles_exactly_to_its_closed_form_volume() { use ifc_geometry::lower::{lower_product_representation, RepresentationPurpose}; let model = parse(&text()); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); for name in ARC_WALLS { let mut session = LoweringSession::new(&model, &scale); let root = lower_product_representation( diff --git a/crates/ifc-geometry/tests/indexed_curve_arcs.rs b/crates/ifc-geometry/tests/indexed_curve_arcs.rs index d45e11b7..20f4e6c1 100644 --- a/crates/ifc-geometry/tests/indexed_curve_arcs.rs +++ b/crates/ifc-geometry/tests/indexed_curve_arcs.rs @@ -74,7 +74,7 @@ fn axis_curve(model: &Model, name: &str) -> EntityId { } fn lower(model: &Model, name: &str) -> GeometryResult { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_representation_item(&mut session, axis_curve(model, name), Transform::identity())?; diff --git a/crates/ifc-geometry/tests/indexed_profile_boundary.rs b/crates/ifc-geometry/tests/indexed_profile_boundary.rs index 12ee81de..12b98e76 100644 --- a/crates/ifc-geometry/tests/indexed_profile_boundary.rs +++ b/crates/ifc-geometry/tests/indexed_profile_boundary.rs @@ -69,7 +69,11 @@ fn profile_id(model: &Model, name: &str) -> EntityId { } fn lowered(model: &Model, name: &str) -> GeometryResult { - lower_profile(model, profile_id(model, name), &units::resolve(model)) + lower_profile( + model, + profile_id(model, name), + &units::resolve(model).expect("project units"), + ) } fn contour(model: &Model, name: &str) -> ContourProfile { diff --git a/crates/ifc-geometry/tests/kernel_free_build.rs b/crates/ifc-geometry/tests/kernel_free_build.rs index ab65efa8..fb3af201 100644 --- a/crates/ifc-geometry/tests/kernel_free_build.rs +++ b/crates/ifc-geometry/tests/kernel_free_build.rs @@ -157,7 +157,7 @@ fn placement_resolution_survives_without_the_kernel() { // Exercise it, so the reference cannot be optimized into a no-op claim. let model = ifc_model::Model::new(); - let units = ifc_geometry::units::UnitScale::default(); + let units = ifc_geometry::units::UnitScale::SI; assert!( resolve(&model, &units, ifc_model::EntityId(1)).is_err(), "an absent product must report rather than silently resolve to origin" @@ -221,7 +221,7 @@ fn body_description_survives_without_the_kernel() { let profile: Profile = ifc_geometry::describe_profile; let model = ifc_model::Model::new(); - let units = ifc_geometry::units::UnitScale::default(); + let units = ifc_geometry::units::UnitScale::SI; assert!(describe(&model, &units, ifc_model::EntityId(1)).is_err()); assert!(profile(&model, &units, ifc_model::EntityId(1)).is_err()); } diff --git a/crates/ifc-geometry/tests/linear_placement.rs b/crates/ifc-geometry/tests/linear_placement.rs index 7fb07ecc..32f2eaa2 100644 --- a/crates/ifc-geometry/tests/linear_placement.rs +++ b/crates/ifc-geometry/tests/linear_placement.rs @@ -84,7 +84,7 @@ fn model_with_linear_placement(cached: bool) -> (Model, EntityId) { #[test] fn a_linearly_placed_product_resolves_through_the_cached_position() { let (model, sign) = model_with_linear_placement(true); - let world = product_world_transform(&model, &UnitScale::default(), sign) + let world = product_world_transform(&model, &UnitScale::SI, sign) .expect("a linear placement with a cached position must resolve"); assert_eq!(world.origin, [100.0, 200.0, 5.0]); } @@ -96,7 +96,7 @@ fn a_linearly_placed_product_resolves_through_the_cached_position() { #[test] fn without_a_cached_position_the_gap_is_named_not_guessed() { let (model, sign) = model_with_linear_placement(false); - let error = product_world_transform(&model, &UnitScale::default(), sign) + let error = product_world_transform(&model, &UnitScale::SI, sign) .expect_err("deriving a frame from the basis curve is not implemented"); let GeometryError::Unsupported { detail, .. } = error else { panic!("expected a typed Unsupported refusal, got {error:?}"); @@ -124,7 +124,7 @@ fn a_malformed_expression_is_refused_despite_the_shortcut() { EntityId(2), Entity::new("IFCPOINTBYDISTANCEEXPRESSION", broken), ); - let result = product_world_transform(&model, &UnitScale::default(), sign); + let result = product_world_transform(&model, &UnitScale::SI, sign); assert!( result.is_err(), "a dangling BasisCurve must not pass because a shortcut exists" diff --git a/crates/ifc-geometry/tests/linear_placement_evaluator.rs b/crates/ifc-geometry/tests/linear_placement_evaluator.rs index 2ce6a55b..bf94fc8a 100644 --- a/crates/ifc-geometry/tests/linear_placement_evaluator.rs +++ b/crates/ifc-geometry/tests/linear_placement_evaluator.rs @@ -103,7 +103,7 @@ fn with_evaluator( let evaluator = ReferenceCurveEvaluator::default(); product_world_transform_with_evaluator( model, - &units::resolve(model), + &units::resolve(model).expect("project units"), product(model, name), &evaluator, cached, @@ -115,7 +115,7 @@ fn with_evaluator( /// Instance carries its centre). fn lowered_centre(model: &Model, name: &str) -> Result<[f64; 3], GeometryError> { let evaluator = ReferenceCurveEvaluator::default(); - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale).with_curve_evaluator(&evaluator); let root = lower_product_items(&mut session, product(model, name))?.expect("a Body"); let lowered = session.finish(root)?; @@ -167,7 +167,7 @@ fn linear_placement_uncached_world_transform_is_the_derived_frame() { let evaluator = ReferenceCurveEvaluator::default(); let frame = product_representation_frame_with_evaluator( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), product(&model, "LENGTH"), RepresentationPurpose::Body, &evaluator, @@ -181,7 +181,7 @@ fn linear_placement_uncached_world_transform_is_the_derived_frame() { #[test] fn linear_placement_uncached_without_an_evaluator_is_refused_as_before() { let model = uncached(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); assert!(!session.derives_linear_placements()); let error = lower_product_items(&mut session, product(&model, "LENGTH")) @@ -271,7 +271,7 @@ fn linear_placement_uncached_an_evaluator_measuring_another_distance_is_refused( let model = uncached(); let error = product_world_transform_with_evaluator( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), product(&model, "LENGTH"), &ArcLengthEverywhere::default(), CachedPositionPolicy::Verify, @@ -355,7 +355,7 @@ fn linear_placement_cache_check_a_stale_cache_is_refused_naming_both_positions() let error = lowered_centre(&model, "STALE").expect_err("lowering checks too"); mismatch(error); let evaluator = ReferenceCurveEvaluator::default(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale).with_curve_evaluator(&evaluator); mismatch(lower_product_net(&mut session, stale).expect_err("net lowering checks too")); } @@ -369,7 +369,7 @@ fn linear_placement_cache_check_trust_uses_the_cache_as_it_is() { // The session builder carries the policy. let evaluator = ReferenceCurveEvaluator::default(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale) .with_curve_evaluator(&evaluator) .with_cached_position_policy(CachedPositionPolicy::Trust); @@ -383,8 +383,12 @@ fn linear_placement_cache_check_trust_uses_the_cache_as_it_is() { fn linear_placement_cache_check_without_an_evaluator_is_unchanged() { // The cache-only entry points read the cache, stale or not. let model = cache_check(); - let world = product_world_transform(&model, &units::resolve(&model), product(&model, "STALE")) - .expect("the cache-only path"); + let world = product_world_transform( + &model, + &units::resolve(&model).expect("project units"), + product(&model, "STALE"), + ) + .expect("the cache-only path"); assert_vec(world.origin, [60.0, 2.0, 11.2], "the cached origin"); } @@ -426,7 +430,7 @@ fn model_with_context(precision: Value) -> Model { fn millimetres() -> UnitScale { UnitScale { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-geometry/tests/lower_advanced_brep.rs b/crates/ifc-geometry/tests/lower_advanced_brep.rs index c4a5019a..e36d01e3 100644 --- a/crates/ifc-geometry/tests/lower_advanced_brep.rs +++ b/crates/ifc-geometry/tests/lower_advanced_brep.rs @@ -32,7 +32,7 @@ fn model() -> Model { } fn brep(model: &Model) -> axiolid_topology::BRep { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let ids = model.ids_of_type("IFCADVANCEDBREP"); assert_eq!(ids.len(), 1, "one advanced brep in the fixture"); let mut session = LoweringSession::new(model, &scale); @@ -178,7 +178,7 @@ fn the_lateral_face_surface_is_the_cylinder_in_metres() { 1, "one cylindrical surface backs the lateral face" ); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = model.ids_of_type("IFCADVANCEDBREP"); let mut session = LoweringSession::new(&model, &scale); let node = diff --git a/crates/ifc-geometry/tests/lower_brep.rs b/crates/ifc-geometry/tests/lower_brep.rs index e14d4e27..46103421 100644 --- a/crates/ifc-geometry/tests/lower_brep.rs +++ b/crates/ifc-geometry/tests/lower_brep.rs @@ -16,6 +16,7 @@ use axiolid_topology::Orientation; use ifc_geometry::lower::{lower_faceted_brep_node, LoweringSession}; use ifc_geometry::transform::Transform; use ifc_geometry::units; +use ifc_geometry::UnitScale; use ifc_model::{Codec, Entity, EntityId, Model, Value}; use ifc_step::StepCodec; use std::path::PathBuf; @@ -57,7 +58,7 @@ fn brep_of( #[test] fn the_cube_fixture_lowers_to_eight_vertices_and_twelve_edges() { let model = model_of("ifclite-geometry/issue_1985_scaled_kinds.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model .ids_of_type("IFCFACETEDBREP") .first() @@ -88,7 +89,7 @@ fn the_cube_fixture_lowers_to_eight_vertices_and_twelve_edges() { #[test] fn every_loop_is_closed_with_as_many_edges_as_points() { let model = model_of("ifclite-geometry/issue_1985_scaled_kinds.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model.ids_of_type("IFCFACETEDBREP").first().expect("brep"); let mut session = LoweringSession::new(&model, &scale); @@ -113,7 +114,7 @@ fn every_loop_is_closed_with_as_many_edges_as_points() { #[test] fn each_edge_is_used_by_exactly_two_faces_in_opposite_senses() { let model = model_of("ifclite-geometry/issue_1985_scaled_kinds.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model.ids_of_type("IFCFACETEDBREP").first().expect("brep"); let mut session = LoweringSession::new(&model, &scale); @@ -149,7 +150,7 @@ fn each_edge_is_used_by_exactly_two_faces_in_opposite_senses() { #[test] fn vertex_positions_are_converted_to_metres_and_placed() { let model = model_of("ifclite-geometry/issue_1985_scaled_kinds.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model.ids_of_type("IFCFACETEDBREP").first().expect("brep"); let offset = Transform::translation([10.0, 0.0, 0.0]); @@ -181,7 +182,7 @@ fn vertex_positions_are_converted_to_metres_and_placed() { #[test] fn twelve_solids_each_intern_their_own_shared_points() { let model = model_of("ifclite-geometry/shared_point_faceted_brep.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = model.ids_of_type("IFCFACETEDBREP").to_vec(); assert_eq!(ids.len(), 12, "the fixture holds twelve solids"); @@ -292,7 +293,7 @@ fn a_brep_with_voids_keeps_its_interior_shells() { ), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_faceted_brep_node(&mut session, EntityId(900), Transform::identity()) .expect("a brep with voids must lower"); @@ -336,7 +337,7 @@ fn a_reversed_bound_orientation_survives_lowering() { Entity::new("IFCFACETEDBREP", vec![Value::Ref(EntityId(shell))]), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_faceted_brep_node(&mut session, EntityId(900), Transform::identity()) .expect("lowers"); @@ -371,7 +372,7 @@ fn a_collapsed_loop_is_reported_as_degenerate() { Entity::new("IFCFACETEDBREP", vec![Value::Ref(EntityId(shell))]), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_faceted_brep_node(&mut session, EntityId(900), Transform::identity()) .expect_err("a collapsed loop must not lower"); @@ -418,7 +419,7 @@ fn an_inner_bound_is_kept_and_marked_as_not_outer() { Entity::new("IFCFACETEDBREP", vec![Value::Ref(EntityId(shell))]), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_faceted_brep_node(&mut session, EntityId(900), Transform::identity()) .expect("lowers"); @@ -463,7 +464,7 @@ fn the_dispatcher_routes_faceted_breps() { ); let model = model_of("ifclite-geometry/issue_1985_scaled_kinds.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model.ids_of_type("IFCFACETEDBREP").first().expect("brep"); let mut session = LoweringSession::new(&model, &scale); diff --git a/crates/ifc-geometry/tests/lower_collections_and_primitives.rs b/crates/ifc-geometry/tests/lower_collections_and_primitives.rs index 0f50e477..6643b214 100644 --- a/crates/ifc-geometry/tests/lower_collections_and_primitives.rs +++ b/crates/ifc-geometry/tests/lower_collections_and_primitives.rs @@ -30,7 +30,7 @@ fn only(model: &Model, kind: &str) -> EntityId { /// Lower one item and return the whole graph, so nested nodes stay reachable. fn lower(model: &Model, id: EntityId) -> ifc_geometry::lower::LoweredGeometry { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let node = lower_representation_item(&mut session, id, Transform::identity()) .unwrap_or_else(|e| panic!("{id:?} must lower: {e}")); @@ -131,7 +131,7 @@ fn a_rotated_bounding_box_grows_to_the_world_aligned_extent() { use std::f64::consts::FRAC_1_SQRT_2; let model = model("synthetic_primitives_and_bbox.ifc"); let id = only(&model, "IFCBOUNDINGBOX"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); // 45 degrees about z, applied as the caller's frame. let rotated = Transform::from_axes( diff --git a/crates/ifc-geometry/tests/lower_connection_surface.rs b/crates/ifc-geometry/tests/lower_connection_surface.rs index 6e9cfd81..d1de50d5 100644 --- a/crates/ifc-geometry/tests/lower_connection_surface.rs +++ b/crates/ifc-geometry/tests/lower_connection_surface.rs @@ -11,6 +11,9 @@ //! connection helper routes every select member and refuses every //! non-surface connection kind with a typed error. +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_model::{GeometryNode, NodeId}; use axiolid_surface::Surface; use axiolid_topology::{BRep, Orientation}; @@ -19,6 +22,7 @@ use ifc_geometry::lower::{ lower_representation_item, DegenerateFacePolicy, LoweredGeometry, LoweringSession, }; use ifc_geometry::transform::Transform; +use ifc_geometry::UnitScale; use ifc_geometry::{units, GeometryError, GeometryResult}; use ifc_model::{Codec, EntityId, Model}; use ifc_step::StepCodec; @@ -90,7 +94,7 @@ fn lowered( model: &Model, lower: impl FnOnce(&mut LoweringSession<'_>) -> GeometryResult, ) -> GeometryResult { - let scale = units::resolve(model); + let scale = assume_units::scale_or_si(model); let mut session = LoweringSession::new(model, &scale); let root = lower(&mut session)?; session.finish(root) @@ -310,7 +314,7 @@ fn the_connection_helper_routes_every_select_member() { #[test] fn the_related_end_is_optional_and_independent() { let model = model(); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let related = lower_related_connection_surface(&mut session, EntityId(51), Transform::identity()) @@ -349,7 +353,7 @@ fn every_non_surface_connection_kind_is_a_typed_refusal() { (57, "IFCCONNECTIONPORTGEOMETRY"), ] { for related in [false, true] { - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = if related { lower_related_connection_surface(&mut session, EntityId(id), Transform::identity()) @@ -447,7 +451,7 @@ fn broken_connection_references_are_structural_errors() { #[test] fn a_collapsed_face_surface_is_degenerate_under_both_policies() { let model = model(); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_face_surface_node(&mut session, EntityId(82), Transform::identity()) .expect_err("two distinct edges bound no area"); @@ -590,7 +594,7 @@ mod meshing { #[test] fn committed_space_boundaries_mesh_in_the_space_frame() { let model = space_fixture(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let frame = ifc_geometry::lower::product_world_transform(&model, &scale, EntityId(13)) .expect("the space is placed"); for (connection, m2, normal) in [ diff --git a/crates/ifc-geometry/tests/lower_csg_swept.rs b/crates/ifc-geometry/tests/lower_csg_swept.rs index 22f74d67..94de76b0 100644 --- a/crates/ifc-geometry/tests/lower_csg_swept.rs +++ b/crates/ifc-geometry/tests/lower_csg_swept.rs @@ -36,7 +36,7 @@ fn fixture(name: &str) -> Model { #[test] fn the_bath_csg_solid_lowers_to_its_boolean_tree() { let model = fixture("ifclite-geometry/bath_csg_solid.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = model.ids_of_type("IFCCSGSOLID"); assert_eq!(ids.len(), 1, "the fixture carries exactly one CSG solid"); @@ -60,7 +60,7 @@ fn every_corpus_swept_disk_lowers_with_resolved_units() { "ifclite-geometry/swept_disk_composite_arc_crankbar.ifc", ] { let model = fixture(name); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); for id in model.ids_of_type("IFCSWEPTDISKSOLID") { let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, *id, Transform::identity()) @@ -104,7 +104,7 @@ fn every_corpus_swept_disk_lowers_with_resolved_units() { #[test] fn the_crankbar_directrix_lowers_as_a_composite_of_trimmed_curves() { let model = fixture("ifclite-geometry/swept_disk_composite_arc_crankbar.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = model.ids_of_type("IFCSWEPTDISKSOLID"); assert!(!ids.is_empty(), "the fixture carries a swept disk"); diff --git a/crates/ifc-geometry/tests/lower_dispatch_corpus.rs b/crates/ifc-geometry/tests/lower_dispatch_corpus.rs index 550d8b57..711a1802 100644 --- a/crates/ifc-geometry/tests/lower_dispatch_corpus.rs +++ b/crates/ifc-geometry/tests/lower_dispatch_corpus.rs @@ -12,12 +12,16 @@ //! holds for all of them: either a node comes back, or a typed error naming //! the source entity does. Never a panic, never a silent substitute. +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_model::{CurveRelation, GeometryNode}; use ifc_geometry::lower::dispatch::{IMPLEMENTED, PLANNED}; use ifc_geometry::lower::{lower_representation_item, LoweringSession}; use ifc_geometry::transform::Transform; use ifc_geometry::units; use ifc_geometry::GeometryError; +use ifc_geometry::UnitScale; use ifc_model::Codec; use ifc_step::StepCodec; use std::collections::{BTreeMap, BTreeSet}; @@ -145,7 +149,7 @@ fn a_committed_standalone_composite_curve_on_surface_lowers_exactly() { let model = StepCodec.read_path(&path).expect("fixture parses"); let ids = model.ids_of_type("IFCCOMPOSITECURVEONSURFACE"); assert_eq!(ids.len(), 1, "fixture has one standalone curve-on-surface"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let root = lower_representation_item(&mut session, ids[0], Transform::identity()) .expect("curve-on-surface lowers through the total dispatcher"); @@ -185,7 +189,7 @@ fn every_corpus_representation_item_lowers_or_reports_a_typed_reason() { let Ok(model) = StepCodec.read_path(path) else { continue; }; - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); for type_name in IMPLEMENTED.iter().chain(PLANNED.iter().map(|(n, _)| n)) { for id in model.ids_of_type(type_name) { @@ -263,7 +267,7 @@ fn planned_families_report_their_documented_reason() { let Ok(model) = StepCodec.read_path(path) else { continue; }; - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); for (type_name, detail) in PLANNED { for id in model.ids_of_type(type_name) { @@ -333,7 +337,7 @@ fn a_nested_failure_names_the_innermost_unlowerable_entity() { ), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_representation_item(&mut session, outer, Transform::identity()) .expect_err("the spine operand is not lowered yet"); @@ -403,7 +407,7 @@ fn implemented_families_lower_and_lowering_families_are_claimed() { let Ok(model) = StepCodec.read_path(path) else { continue; }; - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); for family in IMPLEMENTED { for id in model.ids_of_type(family) { let mut session = LoweringSession::new(&model, &scale); @@ -474,7 +478,7 @@ fn every_family_that_lowers_is_named_in_the_inventory() { let Ok(model) = StepCodec.read_path(path) else { continue; }; - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); for (id, entity) in model.iter() { let type_name = entity.type_name.to_ascii_uppercase(); let claimed = IMPLEMENTED @@ -760,7 +764,7 @@ fn declared_variant_support_matches_runtime_behaviour() { ) }); - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); let mut session = LoweringSession::new(&model, &scale); let outcome = lower_representation_item(&mut session, EntityId(9), Transform::identity()); diff --git a/crates/ifc-geometry/tests/lower_fixtures.rs b/crates/ifc-geometry/tests/lower_fixtures.rs index 481a978d..36f785fe 100644 --- a/crates/ifc-geometry/tests/lower_fixtures.rs +++ b/crates/ifc-geometry/tests/lower_fixtures.rs @@ -11,11 +11,15 @@ //! layer can look complete against synthetic input and still misread the //! first real record it meets. +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_model::{GeometryNode, SolidOperation}; use axiolid_profile::Profile; use ifc_geometry::lower::{lower_extruded_area_solid, LoweredGeometry}; use ifc_geometry::transform::Transform; use ifc_geometry::units; +use ifc_geometry::UnitScale; use ifc_model::Codec; use ifc_step::StepCodec; use std::path::PathBuf; @@ -69,7 +73,7 @@ fn base_profile(mut profile: &Profile) -> &Profile { fn the_wall_fixture_lowers_to_an_extrusion_with_the_documented_numbers() { let path = fixture("ifclite-geometry/issue_098_wall_W.ifc"); let model = StepCodec.read_path(&path).expect("fixture parses"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = model .ids_of_type("IFCEXTRUDEDAREASOLID") @@ -109,7 +113,7 @@ fn every_extrusion_in_the_corpus_lowers_or_reports_why() { let Ok(model) = StepCodec.read_path(path) else { continue; }; - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); for id in model.ids_of_type("IFCEXTRUDEDAREASOLID") { match lower_extruded_area_solid(&model, *id, Transform::identity(), &scale) { Ok(lowered) => { @@ -170,7 +174,7 @@ fn collect_ifc(dir: &std::path::Path, out: &mut Vec) { fn a_hollow_rectangle_section_keeps_its_void() { let path = fixture("ifclite-geometry/issue_1155_halfspace_flyaway.ifc"); let model = StepCodec.read_path(&path).expect("fixture parses"); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let id = model .ids_of_type("IFCRECTANGLEHOLLOWPROFILEDEF") diff --git a/crates/ifc-geometry/tests/lower_halfspace.rs b/crates/ifc-geometry/tests/lower_halfspace.rs index 9c535f0d..f3dfc767 100644 --- a/crates/ifc-geometry/tests/lower_halfspace.rs +++ b/crates/ifc-geometry/tests/lower_halfspace.rs @@ -16,7 +16,7 @@ use axiolid_model::GeometryNode; use ifc_geometry::lower::{lower_representation_item, LoweringSession}; use ifc_geometry::transform::Transform; -use ifc_geometry::units; +use ifc_geometry::UnitScale; use ifc_model::{Codec, Model}; use ifc_step::StepCodec; use std::path::PathBuf; @@ -34,7 +34,7 @@ fn model_of(relative: &str) -> Model { #[test] fn the_flyaway_half_space_lowers_to_a_plane() { let model = model_of("ifclite-geometry/issue_1155_halfspace_flyaway.ifc"); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let id = *model .ids_of_type("IFCHALFSPACESOLID") .first() @@ -74,7 +74,7 @@ fn the_flyaway_half_space_lowers_to_a_plane() { #[test] fn the_enclosing_clipping_result_resolves_now_that_its_operand_lowers() { let model = model_of("ifclite-geometry/issue_1155_halfspace_flyaway.ifc"); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let id = *model .ids_of_type("IFCBOOLEANCLIPPINGRESULT") .first() @@ -110,7 +110,7 @@ fn every_corpus_half_space_lowers() { "ifclite-geometry/bath_csg_solid.ifc", ] { let model = model_of(name); - let scale = units::resolve(&model); + let scale = UnitScale::SI; for id in model.ids_of_type("IFCHALFSPACESOLID") { let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, *id, Transform::identity()) diff --git a/crates/ifc-geometry/tests/lower_ifc4x3_curves.rs b/crates/ifc-geometry/tests/lower_ifc4x3_curves.rs index 3eff357e..bdc0bb6e 100644 --- a/crates/ifc-geometry/tests/lower_ifc4x3_curves.rs +++ b/crates/ifc-geometry/tests/lower_ifc4x3_curves.rs @@ -29,7 +29,7 @@ fn fixture() -> Model { } fn lower(model: &Model, id: EntityId) -> GeometryResult { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_representation_item(&mut session, id, Transform::identity())?; session.finish(root) @@ -414,7 +414,7 @@ fn a_curve_segment_profile_boundary_is_refused_by_name() { ], ), ); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let error = ifc_geometry::lower::profile::lower_profile(&model, profile, &scale) .expect_err("an IfcCurveSegment boundary is not a contour"); assert!( diff --git a/crates/ifc-geometry/tests/lower_ifc4x3_sweeps_profiles.rs b/crates/ifc-geometry/tests/lower_ifc4x3_sweeps_profiles.rs index 1c2c3166..288da6de 100644 --- a/crates/ifc-geometry/tests/lower_ifc4x3_sweeps_profiles.rs +++ b/crates/ifc-geometry/tests/lower_ifc4x3_sweeps_profiles.rs @@ -42,7 +42,7 @@ fn put(model: &mut Model, id: u64, type_name: &str, attributes: Vec) { /// Millimetres and degrees: a missed conversion of either quantity is visible. const MM_DEG: UnitScale = UnitScale { length_to_metres: 0.001, - angle_to_radians: std::f64::consts::PI / 180.0, + angle_to_radians: Some(std::f64::consts::PI / 180.0), }; /// An `IfcOpenCrossProfileDef` at id 10, with `OffsetPoint` at id 9 when given. @@ -144,8 +144,7 @@ fn an_open_cross_profile_with_slope_widths_lowers_to_its_exact_vertices() { #[test] fn an_open_cross_profile_is_not_an_area_profile() { let model = open_cross(true, &[1.0], &[0.0], None, None); - let error = - lower_profile(&model, EntityId(10), &UnitScale::default()).expect_err("open, no area"); + let error = lower_profile(&model, EntityId(10), &UnitScale::SI).expect_err("open, no area"); assert!( error.is_unsupported() && error.to_string().contains("use lower_open_profile_node"), "{error}" @@ -175,7 +174,7 @@ fn malformed_open_cross_profiles_are_refused_as_degenerate() { ), ]; for (model, needle) in cases { - let units = UnitScale::default(); + let units = UnitScale::SI; let mut session = LoweringSession::new(&model, &units); let error = lower_open_profile_node(&mut session, EntityId(10)).expect_err("malformed profile"); @@ -244,7 +243,7 @@ fn lower_graph( model: &Model, id: u64, ) -> Result<(SolidOperation, Vec), GeometryError> { - let units = UnitScale::default(); + let units = UnitScale::SI; let mut session = LoweringSession::new(model, &units); let root = lower_representation_item(&mut session, EntityId(id), Transform::identity())?; let lowered = session.finish(root).expect("valid graph"); diff --git a/crates/ifc-geometry/tests/lower_mapped.rs b/crates/ifc-geometry/tests/lower_mapped.rs index 207b8399..65384a50 100644 --- a/crates/ifc-geometry/tests/lower_mapped.rs +++ b/crates/ifc-geometry/tests/lower_mapped.rs @@ -20,6 +20,7 @@ use ifc_geometry::lower::{ }; use ifc_geometry::transform::Transform; use ifc_geometry::units; +use ifc_geometry::UnitScale; use ifc_model::{Codec, Entity, EntityId, Model, Value}; use ifc_step::StepCodec; use std::path::PathBuf; @@ -69,7 +70,7 @@ fn count_kind(graph: &axiolid_model::GeometryGraph, pick: fn(&GeometryNode) -> b #[test] fn occurrences_of_one_map_share_a_single_lowered_subtree() { let model = model("mapped_instances_multi_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let items = [EntityId(34), EntityId(41), EntityId(48), EntityId(55)]; @@ -108,7 +109,7 @@ fn occurrences_of_one_map_share_a_single_lowered_subtree() { #[test] fn a_mapped_item_lowers_to_an_instance_node() { let model = model("mapped_instances_multi_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let root = lower_mapped_item_node(&mut session, EntityId(34), Transform::identity()) @@ -143,7 +144,7 @@ fn a_mapped_item_lowers_to_an_instance_node() { #[test] fn the_instance_transform_composes_frame_then_target_then_origin() { let model = model("mapped_instances_multi_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let frame = Transform::translation([0.0, 2.0, 0.0]); @@ -182,7 +183,7 @@ fn the_instance_transform_composes_frame_then_target_then_origin() { #[test] fn nested_maps_compose_through_every_level() { let model = model("nested_mapped_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let root = lower_mapped_item_node(&mut session, EntityId(31), Transform::identity()) @@ -257,7 +258,7 @@ fn nested_maps_compose_through_every_level() { #[test] fn a_cyclic_mapping_graph_is_reported() { let model = model("nested_mapped_item_cycle.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let error = lower_mapped_item_node(&mut session, EntityId(18), Transform::identity()) @@ -277,7 +278,7 @@ fn a_cyclic_mapping_graph_is_reported() { #[test] fn a_mapping_chain_stops_at_the_depth_limit() { let model = model("nested_mapped_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::with_limits( &model, &scale, @@ -299,7 +300,7 @@ fn a_mapping_chain_stops_at_the_depth_limit() { #[test] fn the_dispatcher_routes_mapped_items() { let model = model("mapped_instances_multi_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, EntityId(34), Transform::identity()) @@ -319,7 +320,7 @@ fn the_dispatcher_routes_mapped_items() { #[test] fn an_empty_representation_lowers_to_an_empty_collection() { let model = model("nested_mapped_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let node = ifc_geometry::lower::lower_representation(&mut session, EntityId(40)) @@ -463,7 +464,7 @@ fn composition_applies_the_target_outside_the_origin() { ), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let root = lower_mapped_item_node(&mut session, EntityId(14), Transform::identity()) .expect("the mapped item lowers"); @@ -488,7 +489,7 @@ fn composition_applies_the_target_outside_the_origin() { #[test] fn one_representation_reached_twice_is_lowered_once() { let model = model("mapped_instances_multi_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); // #34 and #41 are different IfcMappedItems over the same map #18. @@ -584,7 +585,7 @@ fn a_self_referencing_mapped_item_is_caught_at_the_item_level() { ), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let error = lower_mapped_item_node(&mut session, EntityId(6), Transform::identity()) .expect_err("a self-referencing mapped item must not lower"); diff --git a/crates/ifc-geometry/tests/lower_open_profile.rs b/crates/ifc-geometry/tests/lower_open_profile.rs index 22e0c2b7..9da2f3ab 100644 --- a/crates/ifc-geometry/tests/lower_open_profile.rs +++ b/crates/ifc-geometry/tests/lower_open_profile.rs @@ -44,7 +44,7 @@ fn model(points: &[u64]) -> Model { #[test] fn arbitrary_open_profile_preserves_its_exact_open_path() { let model = model(&[1, 2]); - let units = UnitScale::default(); + let units = UnitScale::SI; let mut session = LoweringSession::new(&model, &units); let root = lower_open_profile_node(&mut session, EntityId(4)).expect("open profile lowers"); let lowered = session.finish(root).expect("valid graph"); @@ -65,15 +65,15 @@ fn arbitrary_open_profile_preserves_its_exact_open_path() { #[test] fn area_profile_api_refuses_open_semantics() { let model = model(&[1, 2]); - let error = lower_profile(&model, EntityId(4), &UnitScale::default()) - .expect_err("open path has no area"); + let error = + lower_profile(&model, EntityId(4), &UnitScale::SI).expect_err("open path has no area"); assert!(error.to_string().contains("use lower_open_profile_node")); } #[test] fn geometrically_closed_path_fails_closed() { let model = model(&[1, 2, 1]); - let units = UnitScale::default(); + let units = UnitScale::SI; let mut session = LoweringSession::new(&model, &units); let error = lower_open_profile_node(&mut session, EntityId(4)) .expect_err("closed endpoints are not an open profile"); @@ -90,7 +90,7 @@ fn tiny_authored_gap_remains_open_without_an_invented_tolerance() { vec![Value::List(vec![n(1e-13), n(0.0)])], ), ); - let units = UnitScale::default(); + let units = UnitScale::SI; let mut session = LoweringSession::new(&model, &units); let root = lower_open_profile_node(&mut session, EntityId(4)) .expect("distinct authored endpoints remain open"); diff --git a/crates/ifc-geometry/tests/lower_product.rs b/crates/ifc-geometry/tests/lower_product.rs index a8bd0c95..4d9854af 100644 --- a/crates/ifc-geometry/tests/lower_product.rs +++ b/crates/ifc-geometry/tests/lower_product.rs @@ -8,10 +8,14 @@ //! Lowering its items without that chain stacks every product at //! the origin: the model renders, but it is a heap, not a building. +#[path = "support/assume_units.rs"] +mod assume_units; + use ifc_geometry::lower::{ lower_product_items, product_world_transform, select_shape_representation, LoweringSession, }; use ifc_geometry::units; +use ifc_geometry::UnitScale; use ifc_model::{Codec, Entity, EntityId, Model, Value}; use std::path::PathBuf; @@ -31,7 +35,7 @@ fn load(rel: &str) -> Model { /// Centroid of a lowered product, in metres. fn centroid(model: &Model, product: EntityId) -> [f64; 3] { - let scale = units::resolve(model); + let scale = assume_units::scale_or_si(model); let mut session = LoweringSession::new(model, &scale); let root = lower_product_items(&mut session, product) .expect("product lowers") @@ -110,7 +114,7 @@ fn graph_centroid(graph: &axiolid_model::GeometryGraph, root: axiolid_model::Nod #[test] fn products_are_separated_by_their_placement_chains() { let model = load("ifclite-geometry/mapped_instances_multi_item.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); // Compare the PRODUCT frame directly. A blended centroid also averages // the per-proxy mapped-item targets, which differ, masking the placement. let xs: Vec = [40u64, 47, 54, 61] @@ -247,7 +251,7 @@ fn a_millimetre_placement_chain_converts_exactly_once() { ], ), ); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); model.insert( EntityId(17), Entity::new( @@ -315,7 +319,7 @@ fn an_unplaced_product_resolves_to_the_identity() { ], ), ); - let scale = units::resolve(&model); + let scale = UnitScale::SI; // A product record that exists but omits ObjectPlacement. model.insert( EntityId(50), diff --git a/crates/ifc-geometry/tests/lower_profile_families.rs b/crates/ifc-geometry/tests/lower_profile_families.rs index 7153939c..6c35e0d4 100644 --- a/crates/ifc-geometry/tests/lower_profile_families.rs +++ b/crates/ifc-geometry/tests/lower_profile_families.rs @@ -33,7 +33,7 @@ fn profile_named(model: &Model, type_name: &str) -> Profile { .is_some_and(|e| e.type_name.eq_ignore_ascii_case(type_name)) }) .unwrap_or_else(|| panic!("fixture has a {type_name}")); - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); lower_profile(model, id, &scale).unwrap_or_else(|e| panic!("{type_name} must lower: {e}")) } @@ -368,7 +368,7 @@ fn a_profile_reference_cycle_is_refused_rather_than_overflowing() { ], ), ); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let error = lower_profile(&model, id, &scale) .expect_err("a one-point path has no direction to offset along"); assert!( @@ -451,7 +451,7 @@ fn a_one_point_center_line_is_refused() { ], ), ); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let error = lower_profile(&model, id, &scale) .expect_err("a one-point path has no direction to offset along"); assert!( diff --git a/crates/ifc-geometry/tests/lower_properties.rs b/crates/ifc-geometry/tests/lower_properties.rs index fd841c1d..a32f2d91 100644 --- a/crates/ifc-geometry/tests/lower_properties.rs +++ b/crates/ifc-geometry/tests/lower_properties.rs @@ -39,7 +39,7 @@ fn millimetres() -> UnitScale { let mut project = vec![Value::Null; 9]; project[8] = r(2); model.insert(EntityId(3), Entity::new("IFCPROJECT", project)); - ifc_geometry::units::resolve(&model) + ifc_geometry::units::resolve(&model).expect("project units") } fn extrusion_model(depth: f64, with_position: bool) -> (Model, EntityId) { @@ -161,7 +161,7 @@ fn circles_remain_exact_without_adapter_approximation_policy() { vec![Value::Enum("AREA".into()), Value::Null, Value::Null, n(5.0)], ), ); - let exact = lower_profile(&model, EntityId(1), &UnitScale::default()).expect("lowers"); + let exact = lower_profile(&model, EntityId(1), &UnitScale::SI).expect("lowers"); match exact { Profile::Circle(circle) => assert_eq!(circle.radius, 5.0), other => panic!("expected exact circle, got {other:?}"), diff --git a/crates/ifc-geometry/tests/lower_provenance.rs b/crates/ifc-geometry/tests/lower_provenance.rs index 61b1234d..ff549131 100644 --- a/crates/ifc-geometry/tests/lower_provenance.rs +++ b/crates/ifc-geometry/tests/lower_provenance.rs @@ -12,6 +12,7 @@ use axiolid_model::{GeometryNode, SolidOperation}; use ifc_geometry::lower::{lower_extruded_area_solid_node, LoweringSession}; use ifc_geometry::transform::Transform; use ifc_geometry::units; +use ifc_geometry::UnitScale; use ifc_model::{Codec, EntityId, Model, Value}; use ifc_step::StepCodec; use std::path::PathBuf; @@ -50,7 +51,7 @@ fn swept_area(model: &Model, extrusion: EntityId) -> EntityId { #[test] fn a_real_lowered_subtree_names_the_ifc_entity_for_each_node() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let extrusion = first_extrusion(&model); let profile_entity = swept_area(&model, extrusion); let mut session = LoweringSession::new(&model, &scale); @@ -94,7 +95,7 @@ fn a_real_lowered_subtree_names_the_ifc_entity_for_each_node() { #[test] fn implicit_nodes_follow_the_innermost_active_entity_not_id_order() { let model = Model::new(); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let outer = EntityId(99); let inner = EntityId(1); @@ -129,7 +130,7 @@ fn implicit_nodes_follow_the_innermost_active_entity_not_id_order() { #[test] fn caller_synthesized_unscoped_nodes_have_no_fake_ifc_source() { let model = Model::new(); - let scale = units::resolve(&model); + let scale = UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let root = session @@ -144,7 +145,7 @@ fn caller_synthesized_unscoped_nodes_have_no_fake_ifc_source() { #[test] fn memoized_lowering_reuses_the_original_provenance_entry() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let extrusion = first_extrusion(&model); let mut session = LoweringSession::new(&model, &scale); diff --git a/crates/ifc-geometry/tests/lower_representation_selection.rs b/crates/ifc-geometry/tests/lower_representation_selection.rs index 2a7e1c74..15b461fb 100644 --- a/crates/ifc-geometry/tests/lower_representation_selection.rs +++ b/crates/ifc-geometry/tests/lower_representation_selection.rs @@ -100,7 +100,7 @@ fn plan_selection_lowers_curve_items_instead_of_the_body_path() { let units = UnitScale { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let mut session = LoweringSession::new(&model, &units); let root = diff --git a/crates/ifc-geometry/tests/lower_session.rs b/crates/ifc-geometry/tests/lower_session.rs index 7965aa4a..8b190c28 100644 --- a/crates/ifc-geometry/tests/lower_session.rs +++ b/crates/ifc-geometry/tests/lower_session.rs @@ -58,7 +58,7 @@ fn extrusion_ids(model: &Model, count: usize) -> Vec { #[test] fn two_families_lower_into_one_graph_and_can_be_combined() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = extrusion_ids(&model, 2); let mut session = LoweringSession::new(&model, &scale); @@ -95,7 +95,7 @@ fn two_families_lower_into_one_graph_and_can_be_combined() { #[test] fn lowering_the_same_entity_twice_reuses_one_node() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = extrusion_ids(&model, 1)[0]; let mut session = LoweringSession::new(&model, &scale); @@ -123,7 +123,7 @@ fn lowering_the_same_entity_twice_reuses_one_node() { #[test] fn the_same_entity_under_a_different_frame_is_a_distinct_node() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = extrusion_ids(&model, 1)[0]; let mut session = LoweringSession::new(&model, &scale); @@ -148,7 +148,7 @@ fn the_same_entity_under_a_different_frame_is_a_distinct_node() { #[test] fn a_cyclic_chain_is_reported_rather_than_overflowing() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let entity = EntityId(1); @@ -172,7 +172,7 @@ fn a_cyclic_chain_is_reported_rather_than_overflowing() { #[test] fn an_over_deep_chain_stops_at_the_limit() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let limits = SessionLimits { max_depth: 4, ..SessionLimits::default() @@ -201,7 +201,7 @@ fn an_over_deep_chain_stops_at_the_limit() { #[test] fn leaving_a_chain_allows_legitimate_reuse() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let entity = EntityId(7); @@ -220,7 +220,7 @@ fn leaving_a_chain_allows_legitimate_reuse() { #[test] fn graph_faults_are_reported_against_the_source_entity() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let point = session @@ -245,7 +245,7 @@ fn graph_faults_are_reported_against_the_source_entity() { #[test] fn a_shared_profile_is_stored_once_for_many_solids() { let model = wall_model(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = extrusion_ids(&model, 2); let swept_area = |id: EntityId| -> EntityId { diff --git a/crates/ifc-geometry/tests/lower_subedge.rs b/crates/ifc-geometry/tests/lower_subedge.rs index a2d96c40..f8e8112e 100644 --- a/crates/ifc-geometry/tests/lower_subedge.rs +++ b/crates/ifc-geometry/tests/lower_subedge.rs @@ -10,10 +10,12 @@ //! parent's carrier curve, keeping the subedge's own narrower extent, and //! walking a parent chain more than one hop deep. +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_model::GeometryNode; use ifc_geometry::lower::{lower_representation_item, LoweringSession}; use ifc_geometry::transform::Transform; -use ifc_geometry::units; use ifc_model::{Entity, EntityId, Model, Value}; fn r(id: u64) -> Value { @@ -129,7 +131,7 @@ fn model(parent_chain: bool) -> Model { } fn brep(model: &Model) -> axiolid_topology::BRep { - let scale = units::resolve(model); + let scale = assume_units::scale_or_si(model); let mut session = LoweringSession::new(model, &scale); let node = lower_representation_item(&mut session, EntityId(54), Transform::identity()) .unwrap_or_else(|e| panic!("the brep must lower: {e}")); diff --git a/crates/ifc-geometry/tests/lower_surface.rs b/crates/ifc-geometry/tests/lower_surface.rs index f99d6308..6c382dd3 100644 --- a/crates/ifc-geometry/tests/lower_surface.rs +++ b/crates/ifc-geometry/tests/lower_surface.rs @@ -13,6 +13,9 @@ //! lowers end to end: its open generatrix is unwrapped rather than forced //! through the closed-contour profile model. +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_model::{GeometryNode, SurfaceRelation}; use axiolid_surface::Surface; use ifc_geometry::lower::{lower_representation_item, lower_surface_node, LoweringSession}; @@ -43,7 +46,7 @@ fn fixture(name: &str) -> Model { #[test] fn the_duct_elbow_reference_surface_lowers_through_its_open_generatrix() { let model = fixture(DUCT); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = model.ids_of_type("IFCSURFACEOFLINEAREXTRUSION"); assert_eq!(ids.len(), 1, "the fixture carries one extruded surface"); @@ -66,7 +69,7 @@ fn the_duct_elbow_reference_surface_lowers_through_its_open_generatrix() { #[test] fn the_duct_elbow_swept_solid_lowers_end_to_end() { let model = fixture(DUCT); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let ids = model.ids_of_type("IFCSURFACECURVESWEPTAREASOLID"); assert_eq!(ids.len(), 1, "the fixture carries one surface-curve sweep"); @@ -87,7 +90,7 @@ fn every_corpus_plane_lowers_to_an_orthonormal_frame() { "synthetic-surfaces/synthetic_curve_bounded_plane.ifc", ] { let model = fixture(name); - let scale = units::resolve(&model); + let scale = assume_units::scale_or_si(&model); for id in model.ids_of_type("IFCPLANE") { let mut session = LoweringSession::new(&model, &scale); let node = lower_surface_node(&mut session, *id, Transform::identity()) diff --git a/crates/ifc-geometry/tests/lower_synthetic_surfaces.rs b/crates/ifc-geometry/tests/lower_synthetic_surfaces.rs index fc36f764..c77d183a 100644 --- a/crates/ifc-geometry/tests/lower_synthetic_surfaces.rs +++ b/crates/ifc-geometry/tests/lower_synthetic_surfaces.rs @@ -38,7 +38,7 @@ fn fixture(name: &str) -> Model { } fn lower_one(model: &Model, id: EntityId) -> GeometryNode { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let node = lower_surface_node(&mut session, id, Transform::identity()) .unwrap_or_else(|e| panic!("surface {id:?} must lower: {e}")); @@ -47,7 +47,7 @@ fn lower_one(model: &Model, id: EntityId) -> GeometryNode { } fn lower_item(model: &Model, id: EntityId) -> ifc_geometry::lower::LoweredGeometry { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_representation_item(&mut session, id, Transform::identity()) .unwrap_or_else(|e| panic!("item {id:?} must lower: {e}")); @@ -342,7 +342,7 @@ fn offset_intersection_and_seam_curve_semantics_survive_corpus_lowering() { #[test] fn the_curve_bounded_surface_corpus_keeps_parameter_curves() { let model = fixture("synthetic_conic_offset_bounded.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let root = lower_surface_node(&mut session, EntityId(25), Transform::identity()) .expect("corpus surface lowers"); diff --git a/crates/ifc-geometry/tests/lower_tapered_sweeps.rs b/crates/ifc-geometry/tests/lower_tapered_sweeps.rs index fdb4b24d..d49f27d1 100644 --- a/crates/ifc-geometry/tests/lower_tapered_sweeps.rs +++ b/crates/ifc-geometry/tests/lower_tapered_sweeps.rs @@ -28,7 +28,7 @@ fn only(model: &Model, kind: &str) -> EntityId { } fn lower(model: &Model, id: EntityId) -> ifc_geometry::lower::LoweredGeometry { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let node = lower_representation_item(&mut session, id, Transform::identity()) .unwrap_or_else(|e| panic!("{id:?} must lower: {e}")); @@ -173,7 +173,7 @@ fn a_polygonal_disk_carries_its_fillet_radius() { let ids = model.ids_of_type("IFCSWEPTDISKSOLIDPOLYGONAL"); assert_eq!(ids.len(), 2, "the fixture has a sharp and a filleted disk"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut fillets = Vec::new(); for &id in ids { let mut session = LoweringSession::new(&model, &scale); diff --git a/crates/ifc-geometry/tests/lower_tessellated.rs b/crates/ifc-geometry/tests/lower_tessellated.rs index 0463131d..57aab2cd 100644 --- a/crates/ifc-geometry/tests/lower_tessellated.rs +++ b/crates/ifc-geometry/tests/lower_tessellated.rs @@ -39,7 +39,7 @@ fn model_of(relative: &str) -> Model { #[test] fn the_triangulated_fixture_lowers_to_four_points_and_four_triangles() { let model = model_of("ifclite-geometry/mapped_instances_indexed_colour.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model .ids_of_type("IFCTRIANGULATEDFACESET") .first() @@ -76,7 +76,7 @@ fn the_triangulated_fixture_lowers_to_four_points_and_four_triangles() { #[test] fn the_polygonal_fixture_lowers_its_indexed_face_without_unsharing_points() { let model = model_of("ifclite-geometry/mapped_instances_indexed_colour.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = model.ids_of_type("IFCPOLYGONALFACESET")[0]; let mut session = LoweringSession::new(&model, &scale); let root = lower_representation_item(&mut session, id, Transform::identity()) @@ -104,7 +104,7 @@ fn no_lowered_mesh_index_escapes_its_own_position_list() { "ifclite-geometry/mapped_instances_indexed_colour_uniform.ifc", ] { let model = model_of(relative); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); for id in model.ids_of_type("IFCTRIANGULATEDFACESET") { let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, *id, Transform::identity()) @@ -135,7 +135,7 @@ fn no_lowered_mesh_index_escapes_its_own_position_list() { #[test] fn placing_a_face_set_moves_it_without_unsharing_points() { let model = model_of("ifclite-geometry/mapped_instances_indexed_colour.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model .ids_of_type("IFCTRIANGULATEDFACESET") .first() @@ -166,7 +166,7 @@ fn placing_a_face_set_moves_it_without_unsharing_points() { #[test] fn an_over_budget_face_set_is_refused_and_locatable() { let model = model_of("ifclite-geometry/mapped_instances_indexed_colour.ifc"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let id = *model .ids_of_type("IFCTRIANGULATEDFACESET") .first() diff --git a/crates/ifc-geometry/tests/meshing_coverage.rs b/crates/ifc-geometry/tests/meshing_coverage.rs index a1c77ca8..8f6ff41d 100644 --- a/crates/ifc-geometry/tests/meshing_coverage.rs +++ b/crates/ifc-geometry/tests/meshing_coverage.rs @@ -265,7 +265,7 @@ fn each_failure_kind_compiles_to_its_pinned_answer() { fn the_collapsed_loop_product_compiles_under_drop_and_report() { let model = model(); let id = product_named(&model, "collapsed-poly-loop"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale).with_face_policy(DegenerateFacePolicy::DropAndReport); let root = lower_product_representation(&mut session, id, RepresentationPurpose::Body) @@ -328,7 +328,7 @@ fn the_fixture_covers_every_kind_and_nothing_unpinned() { fn a_chord_budget_brings_the_composite_curve_row_to_its_exact_volume() { let model = model(); let id = product_named(&model, "composite-curve-profile"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let root = lower_product_representation(&mut session, id, RepresentationPurpose::Body) .expect("lowers") diff --git a/crates/ifc-geometry/tests/net_host_frame.rs b/crates/ifc-geometry/tests/net_host_frame.rs index 1ba4fdc4..948d06f2 100644 --- a/crates/ifc-geometry/tests/net_host_frame.rs +++ b/crates/ifc-geometry/tests/net_host_frame.rs @@ -159,7 +159,7 @@ fn openings_placed_beside_their_host_are_subtracted_in_its_frame() { fn each_part_of_a_multi_part_host_is_cut_in_its_frame() { let model = fixture(); assert_net(&model, "TWO_PART"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let host = named(&model, "IFCWALL", "TWO_PART"); let net = lower_product_net(&mut session, host) @@ -189,7 +189,7 @@ fn each_part_of_a_multi_part_host_is_cut_in_its_frame() { #[test] fn the_difference_runs_under_one_instance_of_the_host_frame() { let model = fixture(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); for wall in SITES.into_iter().chain(["SIBLING"]) { let mut session = LoweringSession::new(&model, &scale); let host = named(&model, "IFCWALL", wall); @@ -217,7 +217,7 @@ fn the_difference_runs_under_one_instance_of_the_host_frame() { #[test] fn openings_without_a_shared_chain_keep_the_world_frame() { let model = fixture(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let host = named(&model, "IFCWALL", "ABSOLUTE"); let net = lower_product_net(&mut session, host) diff --git a/crates/ifc-geometry/tests/net_reference_view.rs b/crates/ifc-geometry/tests/net_reference_view.rs index 17fa5eb6..0c7d8dea 100644 --- a/crates/ifc-geometry/tests/net_reference_view.rs +++ b/crates/ifc-geometry/tests/net_reference_view.rs @@ -48,7 +48,7 @@ fn relation_of(model: &Model, opening: EntityId) -> EntityId { } fn net(model: &Model, wall: &str, options: NetOptions) -> GeometryResult { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let host = named(model, "IFCWALL", wall); Ok(lower_product_net_with(&mut session, host, options)?.expect("the wall has a Body")) @@ -70,7 +70,7 @@ fn reference_view_openings_without_the_opt_in_refuse_the_host_as_before() { let model = fixture(); let wall = named(&model, "IFCWALL", "W1"); let opening = named(&model, "IFCOPENINGELEMENT", "O1-reference-only"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let error = lower_product_net(&mut session, wall).expect_err("no opt-in, no net body"); assert_eq!(refused_opening(error), (wall, opening)); diff --git a/crates/ifc-geometry/tests/nurbs_lowering.rs b/crates/ifc-geometry/tests/nurbs_lowering.rs index 16754425..b63d8b67 100644 --- a/crates/ifc-geometry/tests/nurbs_lowering.rs +++ b/crates/ifc-geometry/tests/nurbs_lowering.rs @@ -13,7 +13,7 @@ use std::path::PathBuf; const UNITS: UnitScale = UnitScale { length_to_metres: 0.01, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; fn frame() -> Transform { diff --git a/crates/ifc-geometry/tests/placement_frames.rs b/crates/ifc-geometry/tests/placement_frames.rs index 46bfbbaf..f09b30f7 100644 --- a/crates/ifc-geometry/tests/placement_frames.rs +++ b/crates/ifc-geometry/tests/placement_frames.rs @@ -127,7 +127,11 @@ fn assert_frame(world: &Transform, origin: [f64; 3], axes: [[f64; 3]; 3]) { } fn cache_only(model: &Model, name: &str) -> Result { - product_world_transform(model, &units::resolve(model), product(model, name)) + product_world_transform( + model, + &units::resolve(model).expect("project units"), + product(model, name), + ) } /// Centre of the lowered block: the mean Instance translation, with @@ -157,7 +161,7 @@ fn centre_of(lowered: &ifc_geometry::lower::LoweredGeometry) -> [f64; 3] { } fn lowered_centre_cache_only(model: &Model, name: &str) -> Result<[f64; 3], GeometryError> { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_product_items(&mut session, product(model, name))?.expect("a Body"); Ok(centre_of(&session.finish(root)?)) @@ -296,7 +300,7 @@ mod derived { ) -> Result { product_world_transform_with_evaluator( model, - &units::resolve(model), + &units::resolve(model).expect("project units"), product(model, name), &ReferenceCurveEvaluator::default(), cached, @@ -305,7 +309,7 @@ mod derived { fn lowered_centre(model: &Model, name: &str) -> Result<[f64; 3], GeometryError> { let evaluator = ReferenceCurveEvaluator::default(); - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale).with_curve_evaluator(&evaluator); let root = lower_product_items(&mut session, product(model, name))?.expect("a Body"); Ok(centre_of(&session.finish(root)?)) diff --git a/crates/ifc-geometry/tests/profile_outline.rs b/crates/ifc-geometry/tests/profile_outline.rs index f792d070..112b4c49 100644 --- a/crates/ifc-geometry/tests/profile_outline.rs +++ b/crates/ifc-geometry/tests/profile_outline.rs @@ -76,7 +76,7 @@ fn outline(id: u64) -> Result { let model: Model = StepCodec .read_bytes(FILE.as_bytes()) .expect("fixture parses"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); profile_outline(&model, &scale, EntityId(id)) } diff --git a/crates/ifc-geometry/tests/stations/common.rs b/crates/ifc-geometry/tests/stations/common.rs index 6803821a..591b799b 100644 --- a/crates/ifc-geometry/tests/stations/common.rs +++ b/crates/ifc-geometry/tests/stations/common.rs @@ -74,7 +74,7 @@ pub fn only(model: &Model, kind: &str) -> u64 { /// Lower representation item `id` under the identity frame. pub fn lower(model: &Model, id: u64) -> Result { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_representation_item(&mut session, EntityId(id), Transform::identity())?; Ok(session.finish(root).expect("a valid graph")) @@ -142,7 +142,7 @@ pub fn assert_windowed(lowered: &LoweredGeometry, distance: f64, window: f64) { /// requires of a sweep along an unbounded curve. #[cfg(feature = "compile-reference-backend")] pub fn swept_along(model: &Model, item: u64, radius: f64) -> LoweredGeometry { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let curve = lower_representation_item(&mut session, EntityId(item), Transform::identity()) .unwrap_or_else(|e| panic!("#{item} lowers: {e}")); diff --git a/crates/ifc-geometry/tests/stations/offset_bases.rs b/crates/ifc-geometry/tests/stations/offset_bases.rs index 2cc2f588..bc86bbe1 100644 --- a/crates/ifc-geometry/tests/stations/offset_bases.rs +++ b/crates/ifc-geometry/tests/stations/offset_bases.rs @@ -428,7 +428,7 @@ mod kernel { let placement = model.ids_of_type("IFCLINEARPLACEMENT")[0]; let transform = derive_linear_placement_transform( &model, - &units::resolve(&model), + &units::resolve(&model).expect("project units"), placement, &ReferenceCurveEvaluator::default(), ) diff --git a/crates/ifc-geometry/tests/stations/offset_run_bases.rs b/crates/ifc-geometry/tests/stations/offset_run_bases.rs index 95f63f46..ccdb1ee8 100644 --- a/crates/ifc-geometry/tests/stations/offset_run_bases.rs +++ b/crates/ifc-geometry/tests/stations/offset_run_bases.rs @@ -455,7 +455,7 @@ mod kernel { #[test] fn the_ring_reads_as_the_compilers_path() { let model = bases(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); let ours = basis_curve_path(&model, &scale, EntityId(390), EntityId(369), &evaluator) .expect("the ring's path"); @@ -478,7 +478,7 @@ mod kernel { model: &Model, axes: u64, ) -> Result { - let scale = units::resolve(model); + let scale = units::resolve(model).expect("project units"); let mut session = LoweringSession::new(model, &scale); let root = lower_axis2_placement_linear_node(&mut session, EntityId(axes), Transform::identity()) @@ -497,7 +497,7 @@ mod kernel { #[test] fn linear_placements_on_the_ring_derive_as_the_kernel_resolves_them() { let model = bases(); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); let close = |a: [f64; 3], b: Vec3, what: &str| { let off = (a[0] - b.x) @@ -582,7 +582,7 @@ mod kernel { polygon + 1.0 ); let model = super::super::common::step(&records, false); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let evaluator = ReferenceCurveEvaluator::default(); let error = derive_linear_placement_transform(&model, &scale, EntityId(43), &evaluator) .expect_err("refused"); diff --git a/crates/ifc-geometry/tests/stations/offsets.rs b/crates/ifc-geometry/tests/stations/offsets.rs index 234d4514..6ab7ba4e 100644 --- a/crates/ifc-geometry/tests/stations/offsets.rs +++ b/crates/ifc-geometry/tests/stations/offsets.rs @@ -271,7 +271,7 @@ mod kernel { .ids_of_type("IFCAXIS2PLACEMENTLINEAR") .last() .expect("the corner frame"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let node = lower_axis2_placement_linear_node(&mut session, id, Transform::identity()) .expect("the frame lowers"); diff --git a/crates/ifc-geometry/tests/stations/points.rs b/crates/ifc-geometry/tests/stations/points.rs index 679e107b..ad65d370 100644 --- a/crates/ifc-geometry/tests/stations/points.rs +++ b/crates/ifc-geometry/tests/stations/points.rs @@ -290,7 +290,7 @@ fn stations_ifc_and_axiolid_cannot_share_are_refused_by_name() { lower(&model, 46).expect("a line is unbounded"); // A scaled frame changes the measure along the curve. - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let mut frame = Transform::identity(); frame.basis = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]]; diff --git a/crates/ifc-geometry/tests/stations/relations.rs b/crates/ifc-geometry/tests/stations/relations.rs index 2568d7a9..33cbbc7c 100644 --- a/crates/ifc-geometry/tests/stations/relations.rs +++ b/crates/ifc-geometry/tests/stations/relations.rs @@ -180,7 +180,7 @@ mod kernel { .ids_of_type("IFCAXIS2PLACEMENTLINEAR") .last() .expect("the corner frame"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); let mut session = LoweringSession::new(&model, &scale); let node = lower_axis2_placement_linear_node(&mut session, id, Transform::identity()) .expect("the frame lowers"); diff --git a/crates/ifc-geometry/tests/stub_kernel.rs b/crates/ifc-geometry/tests/stub_kernel.rs index f82b379d..82c5f543 100644 --- a/crates/ifc-geometry/tests/stub_kernel.rs +++ b/crates/ifc-geometry/tests/stub_kernel.rs @@ -53,13 +53,9 @@ fn full_ifc_pipeline_emits_exact_neutral_nodes() { assert!(select::is_a("IFCEXTRUDEDAREASOLID", "IFCSOLIDMODEL")); assert!(rules::validate(&model, EntityId(3)).is_empty()); - let lowered = lower_extruded_area_solid( - &model, - EntityId(3), - Transform::identity(), - &UnitScale::default(), - ) - .expect("lowering succeeds without a backend"); + let lowered = + lower_extruded_area_solid(&model, EntityId(3), Transform::identity(), &UnitScale::SI) + .expect("lowering succeeds without a backend"); let operation = match lowered.graph.get(lowered.root).expect("root") { GeometryNode::Instance(instance) => instance.source, @@ -102,7 +98,7 @@ fn units_are_resolved_before_the_neutral_graph() { ); let scale = UnitScale { length_to_metres: 1e-3, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let lowered = lower_extruded_area_solid(&model, EntityId(3), Transform::identity(), &scale) .expect("lowers"); diff --git a/crates/ifc-geometry/tests/support/assume_units.rs b/crates/ifc-geometry/tests/support/assume_units.rs new file mode 100644 index 00000000..38c66bc8 --- /dev/null +++ b/crates/ifc-geometry/tests/support/assume_units.rs @@ -0,0 +1,70 @@ +//! Test-only unit assumptions for models that declare none (#585). +//! +//! A committed fixture or hand-built model without a project unit assignment +//! is refused by `units::resolve`. Tests that are about something else say +//! here, in one place, that they read such a model in metres and radians. +#![allow(dead_code)] + +use ifc_geometry::{units, GeometryError, UnitScale}; +use ifc_model::{Entity, Model, Value}; + +/// The model's resolved scale; metres and radians when it declares no length +/// unit. Any other refusal fails the test. +pub fn scale_or_si(model: &Model) -> UnitScale { + match units::resolve(model) { + Ok(scale) => scale, + Err(GeometryError::LengthUnitUndeclared { .. }) => UnitScale::SI, + Err(error) => panic!("units unreadable: {error}"), + } +} + +/// [`declare_metres_and_radians`] when the model declares no length unit; +/// a model that declares one, or whose units are unreadable, is left as it is. +pub fn declare_if_missing(model: &mut Model) { + if matches!( + units::resolve(model), + Err(GeometryError::LengthUnitUndeclared { .. }) + ) { + declare_metres_and_radians(model); + } +} + +/// Declare metres and radians on a model that has no unit assignment: push +/// the two `IfcSIUnit`s and an `IfcUnitAssignment`, then set +/// `IfcProject.UnitsInContext`, or push a project when there is none. +pub fn declare_metres_and_radians(model: &mut Model) { + let si = |unit_type: &str, name: &str| { + Entity::new( + "IFCSIUNIT", + vec![ + Value::Derived, + Value::Enum(unit_type.into()), + Value::Null, + Value::Enum(name.into()), + ], + ) + }; + let metre = model.push(si("LENGTHUNIT", "METRE")); + let radian = model.push(si("PLANEANGLEUNIT", "RADIAN")); + let assignment = model.push(Entity::new( + "IFCUNITASSIGNMENT", + vec![Value::List(vec![Value::Ref(metre), Value::Ref(radian)])], + )); + match model.ids_of_type("IFCPROJECT").first().copied() { + Some(project) => { + let mut entity = model.get(project).expect("project").clone(); + assert!( + matches!(entity.attributes.get(8), None | Some(Value::Null)), + "{project} already declares UnitsInContext" + ); + entity.attributes.resize(9, Value::Null); + entity.attributes[8] = Value::Ref(assignment); + model.insert(project, entity); + } + None => { + let mut slots = vec![Value::Null; 9]; + slots[8] = Value::Ref(assignment); + model.push(Entity::new("IFCPROJECT", slots)); + } + } +} diff --git a/crates/ifc-geometry/tests/sweep_trim_kind.rs b/crates/ifc-geometry/tests/sweep_trim_kind.rs index 002753e2..5d894dc2 100644 --- a/crates/ifc-geometry/tests/sweep_trim_kind.rs +++ b/crates/ifc-geometry/tests/sweep_trim_kind.rs @@ -10,13 +10,16 @@ //! parameter. The directrix is a circle, where the difference is an angle //! against a distance: a 1.5 m trim read as 1.5 rad is a different solid. +#[path = "support/assume_units.rs"] +mod assume_units; + use axiolid_model::{GeometryNode, SolidOperation}; use ifc_geometry::lower::{lower_representation_item, LoweringSession}; use ifc_geometry::solid::swept::{ FixedReferenceSweptAreaSolid, SurfaceCurveSweptAreaSolid, TrimMeasure, }; use ifc_geometry::transform::Transform; -use ifc_geometry::{units, GeometryError}; +use ifc_geometry::GeometryError; use ifc_model::{Codec, EntityId, Model}; use ifc_step::StepCodec; @@ -64,7 +67,7 @@ fn trims(model: &Model, kind: &str) -> (Option, Option } fn lower(model: &Model) -> Result, GeometryError> { - let scale = units::resolve(model); + let scale = assume_units::scale_or_si(model); let mut session = LoweringSession::new(model, &scale); let node = lower_representation_item(&mut session, SWEEP, Transform::identity())?; let lowered = session.finish(node)?; diff --git a/crates/ifc-geometry/tests/units_on_fixtures.rs b/crates/ifc-geometry/tests/units_on_fixtures.rs index 07b1d696..2d079fac 100644 --- a/crates/ifc-geometry/tests/units_on_fixtures.rs +++ b/crates/ifc-geometry/tests/units_on_fixtures.rs @@ -4,7 +4,7 @@ //! actual exporters write, where the unit assignment is buried in a real //! project structure. -use ifc_geometry::units; +use ifc_geometry::{units, GeometryError}; use ifc_model::Codec; use ifc_step::StepCodec; use std::path::PathBuf; @@ -15,12 +15,16 @@ fn fixture(rel: &str) -> PathBuf { .join(rel) } -/// Every fixture must resolve units without panicking, and the factor must be -/// physically plausible. A building is not 1e12 metres. +/// Every fixture either resolves to a physically plausible scale (a building +/// is not 1e12 metres) or is refused for declaring no length unit. None is +/// unreadable or ambiguous. #[test] fn every_fixture_resolves_a_plausible_unit_scale() { let dir = fixture(""); let mut checked = 0; + let mut resolved = 0; + let mut refused = 0; + let mut flyaway_refused = false; fn walk(dir: &std::path::Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { @@ -43,25 +47,41 @@ fn every_fixture_resolves_a_plausible_unit_scale() { let Ok(model) = StepCodec.read_path(&path) else { continue; }; - let scale = units::resolve(&model); let name = path.file_name().unwrap().to_string_lossy(); - - assert!( - scale.length_to_metres > 1e-9 && scale.length_to_metres < 1e9, - "{name}: implausible length factor {}", - scale.length_to_metres - ); - assert!( - scale.angle_to_radians > 0.0 && scale.angle_to_radians <= 1.0, - "{name}: implausible angle factor {}", - scale.angle_to_radians - ); + match units::resolve(&model) { + Ok(scale) => { + assert!( + scale.length_to_metres > 1e-9 && scale.length_to_metres < 1e9, + "{name}: implausible length factor {}", + scale.length_to_metres + ); + if let Some(angle) = scale.angle_to_radians { + assert!( + angle > 0.0 && angle <= 1.0, + "{name}: implausible angle factor {angle}" + ); + } + resolved += 1; + } + Err(GeometryError::LengthUnitUndeclared { .. }) => { + if &*name == "issue_1155_halfspace_flyaway.ifc" { + flyaway_refused = true; + } + refused += 1; + } + Err(error) => panic!("{name}: units unreadable: {error}"), + } checked += 1; } assert!( checked >= 19, "expected the committed corpus, got {checked}" ); + assert_eq!(resolved + refused, checked); + assert!( + flyaway_refused, + "issue_1155_halfspace_flyaway.ifc declares no units and must be refused" + ); } /// Unit resolution must read what the file actually declares. @@ -79,7 +99,7 @@ fn every_fixture_resolves_a_plausible_unit_scale() { fn reads_the_declared_length_unit_and_ignores_other_unit_types() { let path = fixture("ifclite-geometry/issue_098_wall_W.ifc"); let model = StepCodec.read_path(&path).expect("fixture parses"); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); assert_eq!( scale.length_to_metres, 1.0, @@ -95,13 +115,14 @@ fn reads_the_declared_length_unit_and_ignores_other_unit_types() { // but #45 is NOT referenced by the IfcUnitAssignment -- only the degree // unit is. Reading the assignment list rather than scanning every // IfcSIUnit in the file is what makes this come out right. + let angle = scale.angle_to_radians.expect("file declares an angle unit"); assert!( - (scale.angle_to_radians - std::f64::consts::PI / 180.0).abs() < 1e-12, - "file assigns DEGREE, got {}", - scale.angle_to_radians + (angle - std::f64::consts::PI / 180.0).abs() < 1e-12, + "file assigns DEGREE, got {angle}" ); assert!( - (scale.angle(90.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12, + (scale.angle(ifc_model::EntityId(1), 90.0).unwrap() - std::f64::consts::FRAC_PI_2).abs() + < 1e-12, "90 degrees is pi/2 radians" ); } @@ -138,7 +159,7 @@ fn millimetre_projects_scale_coordinates() { project[8] = Value::Ref(EntityId(2)); model.insert(EntityId(3), Entity::new("IFCPROJECT", project)); - let scale = units::resolve(&model); + let scale = units::resolve(&model).expect("project units"); assert_eq!(scale.length_to_metres, 1e-3); assert_eq!(scale.length(3000.0), 3.0, "a 3000 mm wall is 3 m"); } diff --git a/crates/ifc-geometry/tests/units_refusal.rs b/crates/ifc-geometry/tests/units_refusal.rs new file mode 100644 index 00000000..dded0ada --- /dev/null +++ b/crates/ifc-geometry/tests/units_refusal.rs @@ -0,0 +1,97 @@ +//! A model whose units have no usable scale is refused, never placed in +//! metres (#585). +//! +//! The text is one proxy at the point `(1000, 0, 0)`. Whether that is a +//! kilometre, a metre or a millimetre depends on the project's unit +//! assignment, which the model either declares or does not. + +#![cfg(feature = "lowering")] + +use ifc_geometry::{product_world_transform, units, GeometryError}; +use ifc_model::{Codec, EntityId, Model}; + +const HEADER: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1');\n\ +FILE_NAME('r.ifc','2026-01-01T00:00:00',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n"; +const FOOTER: &str = "ENDSEC;\nEND-ISO-10303-21;\n"; +const PLACED: &str = "#2=IFCCARTESIANPOINT((1000.,0.,0.));\n#3=IFCAXIS2PLACEMENT3D(#2,$,$);\n\ +#4=IFCLOCALPLACEMENT($,#3);\n\ +#5=IFCBUILDINGELEMENTPROXY('1YvctVUKr0kugbFTf53O9L',$,'X',$,$,#4,$,$,$);\n"; + +/// The #585 repro: `UnitsInContext` is `$`. +fn without_units() -> String { + format!("{HEADER}#1=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,$);\n{PLACED}{FOOTER}") +} + +/// A project whose `UnitsInContext` is `#7`, which holds `units`. +fn with_assignment(units: &str) -> String { + format!( + "{HEADER}#1=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,#7);\n{PLACED}{units}{FOOTER}" + ) +} + +fn parse(text: String) -> Model { + ifc_step::StepCodec + .read_bytes(text.as_bytes()) + .expect("text parses") +} + +fn origin_in_metres(prefix: &str) -> [f64; 3] { + let model = parse(with_assignment(&format!( + "#6=IFCSIUNIT(*,.LENGTHUNIT.,{prefix},.METRE.);\n#7=IFCUNITASSIGNMENT((#6));\n" + ))); + let scale = units::resolve(&model).expect("units"); + product_world_transform(&model, &scale, EntityId(5)) + .expect("placed") + .origin +} + +#[test] +fn a_project_without_units_in_context_is_refused_not_placed_in_metres() { + let model = parse(without_units()); + assert_eq!( + units::resolve(&model), + Err(GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: None + }) + ); +} + +#[test] +fn a_declared_millimetre_places_the_proxy_one_metre_out() { + assert_eq!(origin_in_metres(".MILLI."), [1.0, 0.0, 0.0]); +} + +#[test] +fn a_declared_metre_places_the_proxy_a_thousand_metres_out() { + assert_eq!(origin_in_metres("$"), [1000.0, 0.0, 0.0]); +} + +#[test] +fn an_assignment_with_only_an_angle_unit_is_refused() { + let model = parse(with_assignment( + "#6=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);\n#7=IFCUNITASSIGNMENT((#6));\n", + )); + assert_eq!( + units::resolve(&model), + Err(GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: Some(EntityId(7)) + }) + ); +} + +#[test] +fn a_conversion_unit_with_a_non_numeric_factor_is_unreadable() { + let model = parse(with_assignment( + "#8=IFCMEASUREWITHUNIT(IFCLABEL('x'),#9);\n#9=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\ + #6=IFCCONVERSIONBASEDUNIT(#10,.LENGTHUNIT.,'INCH',#8);\n#7=IFCUNITASSIGNMENT((#6));\n", + )); + assert!(matches!( + units::resolve(&model), + Err(GeometryError::UnitUnreadable { + unit_type: "LENGTHUNIT", + .. + }) + )); +} From cc1e5246c93589adec91b8160f1b7a6cb262c43a Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:18:50 +0000 Subject: [PATCH 05/13] refactor(ifc-geometry): move error tests to a submodule error.rs crossed the 800-line limit with the unit refusals. --- crates/ifc-geometry/src/error.rs | 70 +------------------------- crates/ifc-geometry/src/error/tests.rs | 67 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 69 deletions(-) create mode 100644 crates/ifc-geometry/src/error/tests.rs diff --git a/crates/ifc-geometry/src/error.rs b/crates/ifc-geometry/src/error.rs index 17a7b23e..fa627d7a 100644 --- a/crates/ifc-geometry/src/error.rs +++ b/crates/ifc-geometry/src/error.rs @@ -760,72 +760,4 @@ impl GeometryError { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn errors_name_the_entity_so_failures_are_locatable() { - let e = GeometryError::MissingAttribute { - entity: EntityId(42), - type_name: "IFCEXTRUDEDAREASOLID".into(), - attribute: "SweptArea", - }; - assert_eq!(e.entity(), Some(EntityId(42))); - assert!(e.to_string().contains("#42")); - assert!(e.to_string().contains("SweptArea")); - } - - #[test] - fn unsupported_is_distinguishable_from_corruption() { - let unsupported = GeometryError::Unsupported { - entity: EntityId(1), - type_name: "IFCSECTIONEDSPINE".into(), - detail: "spine interpolation", - }; - let broken = GeometryError::Degenerate { - entity: EntityId(1), - type_name: "IFCCIRCLE".into(), - detail: "zero radius".into(), - }; - assert!(unsupported.is_unsupported()); - assert!(!broken.is_unsupported()); - } - - #[test] - fn unit_refusals_name_the_entity_and_are_not_unsupported() { - let undeclared = GeometryError::LengthUnitUndeclared { - project: Some(EntityId(1)), - assignment: Some(EntityId(2)), - }; - assert_eq!(undeclared.entity(), Some(EntityId(2))); - let no_assignment = GeometryError::LengthUnitUndeclared { - project: Some(EntityId(1)), - assignment: None, - }; - assert_eq!(no_assignment.entity(), Some(EntityId(1))); - let no_project = GeometryError::LengthUnitUndeclared { - project: None, - assignment: None, - }; - assert_eq!(no_project.entity(), None); - let angle = GeometryError::AngleUnitUndeclared { - entity: EntityId(3), - }; - assert_eq!(angle.entity(), Some(EntityId(3))); - let unreadable = GeometryError::UnitUnreadable { - unit: EntityId(4), - unit_type: "LENGTHUNIT", - detail: "x".into(), - }; - assert_eq!(unreadable.entity(), Some(EntityId(4))); - let ambiguous = GeometryError::UnitsAmbiguous { - first: EntityId(5), - second: EntityId(6), - kind: "LENGTHUNIT", - }; - assert_eq!(ambiguous.entity(), Some(EntityId(6))); - for e in [undeclared, angle, unreadable, ambiguous] { - assert!(!e.is_unsupported()); - } - } -} +mod tests; diff --git a/crates/ifc-geometry/src/error/tests.rs b/crates/ifc-geometry/src/error/tests.rs new file mode 100644 index 00000000..e5ca1e6e --- /dev/null +++ b/crates/ifc-geometry/src/error/tests.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn errors_name_the_entity_so_failures_are_locatable() { + let e = GeometryError::MissingAttribute { + entity: EntityId(42), + type_name: "IFCEXTRUDEDAREASOLID".into(), + attribute: "SweptArea", + }; + assert_eq!(e.entity(), Some(EntityId(42))); + assert!(e.to_string().contains("#42")); + assert!(e.to_string().contains("SweptArea")); +} + +#[test] +fn unsupported_is_distinguishable_from_corruption() { + let unsupported = GeometryError::Unsupported { + entity: EntityId(1), + type_name: "IFCSECTIONEDSPINE".into(), + detail: "spine interpolation", + }; + let broken = GeometryError::Degenerate { + entity: EntityId(1), + type_name: "IFCCIRCLE".into(), + detail: "zero radius".into(), + }; + assert!(unsupported.is_unsupported()); + assert!(!broken.is_unsupported()); +} + +#[test] +fn unit_refusals_name_the_entity_and_are_not_unsupported() { + let undeclared = GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: Some(EntityId(2)), + }; + assert_eq!(undeclared.entity(), Some(EntityId(2))); + let no_assignment = GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: None, + }; + assert_eq!(no_assignment.entity(), Some(EntityId(1))); + let no_project = GeometryError::LengthUnitUndeclared { + project: None, + assignment: None, + }; + assert_eq!(no_project.entity(), None); + let angle = GeometryError::AngleUnitUndeclared { + entity: EntityId(3), + }; + assert_eq!(angle.entity(), Some(EntityId(3))); + let unreadable = GeometryError::UnitUnreadable { + unit: EntityId(4), + unit_type: "LENGTHUNIT", + detail: "x".into(), + }; + assert_eq!(unreadable.entity(), Some(EntityId(4))); + let ambiguous = GeometryError::UnitsAmbiguous { + first: EntityId(5), + second: EntityId(6), + kind: "LENGTHUNIT", + }; + assert_eq!(ambiguous.entity(), Some(EntityId(6))); + for e in [undeclared, angle, unreadable, ambiguous] { + assert!(!e.is_unsupported()); + } +} From 4009bf66e07abba0e5ef1df08cdf33bd394f5791 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:21:23 +0000 Subject: [PATCH 06/13] test(openbim-ifc): cover unit refusals in the facade and binding core Inline models state a metre and a radian; a model without a length unit refuses each product's placement with invalid-model. Refs #585. --- .../tests/geometry.rs | 39 ++++++++++++++ crates/openbim-ifc/tests/body_description.rs | 10 ++-- .../tests/docs_geometry_examples.rs | 4 +- crates/openbim-ifc/tests/georef_placement.rs | 2 +- crates/openbim-ifc/tests/product_geometry.rs | 52 +++++++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/openbim-ifc-binding-core/tests/geometry.rs b/crates/openbim-ifc-binding-core/tests/geometry.rs index c475067f..40a88392 100644 --- a/crates/openbim-ifc-binding-core/tests/geometry.rs +++ b/crates/openbim-ifc-binding-core/tests/geometry.rs @@ -39,6 +39,10 @@ DATA; #22=IFCWALL('1YvctVUKr0kugbFTf53O9L',$,'cyclic',$,$,#20,#4,$,$); #30=IFCLOCALPLACEMENT($,#1); #31=IFCWALL('2YvctVUKr0kugbFTf53O9L',$,'point',$,$,#30,#4,$,$); +#90=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#91=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#92=IFCUNITASSIGNMENT((#90,#91)); +#93=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,#92); ENDSEC; END-ISO-10303-21; "; @@ -157,6 +161,37 @@ mod placements { assert_eq!(placements[0].representation.as_ref().unwrap().id, 3); } + #[test] + fn a_model_without_a_length_unit_refuses_each_placement() { + let model = IfcModel::parse( + b"ISO-10303-21; +HEADER;FILE_DESCRIPTION((''),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('IFC4'));ENDSEC; +DATA; +#1=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,$); +#2=IFCCARTESIANPOINT((1000.,0.,0.)); +#3=IFCAXIS2PLACEMENT3D(#2,$,$); +#4=IFCLOCALPLACEMENT($,#3); +#5=IFCBUILDINGELEMENTPROXY('1YvctVUKr0kugbFTf53O9L',$,'X',$,$,#4,#7,$,$); +#6=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',()); +#7=IFCPRODUCTDEFINITIONSHAPE($,$,(#6)); +ENDSEC; +END-ISO-10303-21; +", + ) + .unwrap(); + let placements = model.product_placements(None).unwrap(); + assert_eq!(placements.len(), 1); + let refusal = placements[0].refusal.as_ref().expect("refused"); + assert!(placements[0].transform.is_none()); + assert_eq!(refusal.code, "invalid-model"); + assert_eq!(refusal.entity, Some(1)); + assert!( + refusal.message.contains("length unit"), + "{}", + refusal.message + ); + } + #[test] fn an_id_the_model_lacks_is_refused_not_dropped() { let placements = fixture().product_placements(Some(&[9999])).unwrap(); @@ -363,6 +398,10 @@ DATA; #32=IFCSHAPEREPRESENTATION(#1,'Axis','Curve2D',(#33)); #33=IFCOFFSETCURVE2D(#34,0.1,.F.); #34=IFCOFFSETCURVE2D(#23,0.1,.F.); +#90=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#91=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#92=IFCUNITASSIGNMENT((#90,#91)); +#93=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,#92); ENDSEC; END-ISO-10303-21; ", diff --git a/crates/openbim-ifc/tests/body_description.rs b/crates/openbim-ifc/tests/body_description.rs index 34f1a6dc..4169f52d 100644 --- a/crates/openbim-ifc/tests/body_description.rs +++ b/crates/openbim-ifc/tests/body_description.rs @@ -18,9 +18,13 @@ fn the_facade_describes_a_steel_section_body() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../test/fixtures/synthetic-surfaces/synthetic_profile_families.ifc"); let model = ifc::read_path(&path).expect("fixture parses"); - let body = body_description(&model, &units::resolve(&model), EntityId(89)) - .expect("describes") - .expect("has a body"); + let body = body_description( + &model, + &units::resolve(&model).expect("project units"), + EntityId(89), + ) + .expect("describes") + .expect("has a body"); assert_eq!(body.items.len(), 13); let first = &body.items[0]; diff --git a/crates/openbim-ifc/tests/docs_geometry_examples.rs b/crates/openbim-ifc/tests/docs_geometry_examples.rs index 0876ce41..7eca53bd 100644 --- a/crates/openbim-ifc/tests/docs_geometry_examples.rs +++ b/crates/openbim-ifc/tests/docs_geometry_examples.rs @@ -171,7 +171,7 @@ fn placed_wall() -> (Model, EntityId) { #[test] fn documented_world_transform_resolves() -> TestResult { let (model, wall) = placed_wall(); - let units = ifc::geometry::units::resolve(&model); + let units = ifc::geometry::UnitScale::SI; // docs:snippet world-transform use ifc::product_world_transform; // feature = "geometry-select" @@ -185,7 +185,7 @@ fn documented_world_transform_resolves() -> TestResult { #[test] fn documented_world_transforms_batch_resolves() { let (model, wall) = placed_wall(); - let units = ifc::geometry::units::resolve(&model); + let units = ifc::geometry::UnitScale::SI; let ids = [wall]; // docs:snippet world-transforms use ifc::products_world_transforms; diff --git a/crates/openbim-ifc/tests/georef_placement.rs b/crates/openbim-ifc/tests/georef_placement.rs index 80604bf3..06b4c8dc 100644 --- a/crates/openbim-ifc/tests/georef_placement.rs +++ b/crates/openbim-ifc/tests/georef_placement.rs @@ -90,7 +90,7 @@ fn a_products_resolved_placement_composes_into_known_map_coordinates() { // Both halves take their unit from the same model, which is exactly the // agreement this layer is responsible for. - let units = ifc::geometry::units::resolve(&model); + let units = ifc::geometry::units::resolve(&model).expect("project units"); assert_eq!(units.length_to_metres, 0.001); let project_frame = product_world_transform(&model, &units, WALL) diff --git a/crates/openbim-ifc/tests/product_geometry.rs b/crates/openbim-ifc/tests/product_geometry.rs index 8293d56e..36cb5923 100644 --- a/crates/openbim-ifc/tests/product_geometry.rs +++ b/crates/openbim-ifc/tests/product_geometry.rs @@ -16,6 +16,50 @@ fn fixture() -> Model { .expect("parses") } +/// A wall with a Body and a project that states no units (#585). +const WITHOUT_UNITS: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\ +FILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA; +#1=IFCPROJECT('0000000000000000000001',$,'P',$,$,$,$,$,$); +#2=IFCCARTESIANPOINT((1000.,0.,0.)); +#3=IFCAXIS2PLACEMENT3D(#2,$,$); +#4=IFCLOCALPLACEMENT($,#3); +#5=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',()); +#6=IFCPRODUCTDEFINITIONSHAPE($,$,(#5)); +#10=IFCWALL('0000000000000000000010',$,'W',$,$,#4,#6,$,$); +ENDSEC;\nEND-ISO-10303-21;\n"; + +#[test] +fn a_model_without_a_length_unit_refuses_every_product() { + let model = StepCodec + .read_bytes(WITHOUT_UNITS.as_bytes()) + .expect("parses"); + let placements = product_placements(&model, None); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].product, EntityId(10)); + assert!( + matches!( + placements[0].world, + Err(ifc::geometry::GeometryError::LengthUnitUndeclared { + project: Some(EntityId(1)), + assignment: None + }) + ), + "{:?}", + placements[0].world + ); + assert!(matches!(placements[0].body, Ok(Some(_)))); + + #[cfg(feature = "mesh")] + { + let meshes = ifc::product_meshes(&model, None); + assert_eq!(meshes.len(), 1); + assert!(matches!( + meshes[0].1, + Err(ifc::geometry::GeometryError::LengthUnitUndeclared { .. }) + )); + } +} + #[test] fn placements_and_the_selected_body_per_product() { let model = fixture(); @@ -189,6 +233,10 @@ FILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA; #24=IFCCIRCLE(#25,1.); #25=IFCAXIS2PLACEMENT2D(#26,$); #26=IFCCARTESIANPOINT((0.,0.)); +#90=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#91=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#92=IFCUNITASSIGNMENT((#90,#91)); +#93=IFCPROJECT('0000000000000000000093',$,'P',$,$,$,$,$,#92); ENDSEC;\nEND-ISO-10303-21;\n"; let model = StepCodec.read_bytes(text.as_bytes()).expect("parses"); @@ -287,6 +335,10 @@ FILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('IFC4X3_ADD2'));\nENDSEC;\nDA #21=IFCCARTESIANPOINT((2.,0.)); #22=IFCCARTESIANPOINT((0.,-0.5)); #23=IFCCARTESIANPOINT((1.,0.1)); +#90=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#91=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#92=IFCUNITASSIGNMENT((#90,#91)); +#93=IFCPROJECT('0000000000000000000093',$,'P',$,$,$,$,$,#92); ENDSEC;\nEND-ISO-10303-21;\n"; let model = StepCodec.read_bytes(text.as_bytes()).expect("parses"); From 682add9fcbed7fe02c75305e10f4aa954f494d93 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:22:05 +0000 Subject: [PATCH 07/13] docs: record the unit refusals in the changelogs Refs #585. --- crates/ifc-geometry/CHANGELOG.md | 24 ++++++++++++++++++++ crates/openbim-ifc-binding-core/CHANGELOG.md | 8 +++++++ crates/openbim-ifc-capi/CHANGELOG.md | 8 +++++++ crates/openbim-ifc-cli/CHANGELOG.md | 8 +++++++ crates/openbim-ifc-dotnet/CHANGELOG.md | 8 +++++++ crates/openbim-ifc-py/CHANGELOG.md | 8 +++++++ crates/openbim-ifc-wasm/CHANGELOG.md | 8 +++++++ crates/openbim-ifc/CHANGELOG.md | 12 ++++++++++ docs/use-cases/2d-approval-plans.md | 1 + 9 files changed, 85 insertions(+) diff --git a/crates/ifc-geometry/CHANGELOG.md b/crates/ifc-geometry/CHANGELOG.md index 9708fe0c..e82f7184 100644 --- a/crates/ifc-geometry/CHANGELOG.md +++ b/crates/ifc-geometry/CHANGELOG.md @@ -12,6 +12,30 @@ everything released before per-crate changelogs began. ## [Unreleased] +### Changed (#585) + +- Breaking: `units::resolve` returns `GeometryResult` and refuses + a model whose units have no usable scale instead of assuming metres and + radians. Four new `GeometryError` variants: `LengthUnitUndeclared` (no + project, no `UnitsInContext`, or no `LENGTHUNIT`), `AngleUnitUndeclared`, + `UnitUnreadable` and `UnitsAmbiguous` (two projects, or two units of one + type). +- Breaking: `UnitScale::angle_to_radians` is `Option`; a missing + `PLANEANGLEUNIT` is refused only where an angle is read. + `UnitScale::angle(entity, value)` returns a `Result`, and the new + `UnitScale::radians_per_unit(entity)` is the factor. +- Breaking: `impl Default for UnitScale` is removed; `UnitScale::SI` is the + factors of one, never a fallback for a model's missing units. +- An `IfcConversionBasedUnit` applies its `UnitComponent`: an inch stated as + 25.4 millimetres reads as 0.0254 metres, not 25.4. A conversion unit + defined through another conversion unit is `Unsupported`. +- An unknown `IfcSIPrefix`, an `IfcSIUnit` not named `METRE` (length) or + `RADIAN` (angle), a context-dependent or offset unit, and a factor that is + not a positive finite number are `UnitUnreadable`, not a silent factor of + one. + +Semver: breaking (0.x minor). + ## [0.31.2] - 2026-10-28 ### Changed diff --git a/crates/openbim-ifc-binding-core/CHANGELOG.md b/crates/openbim-ifc-binding-core/CHANGELOG.md index 332d535c..1301c03e 100644 --- a/crates/openbim-ifc-binding-core/CHANGELOG.md +++ b/crates/openbim-ifc-binding-core/CHANGELOG.md @@ -8,6 +8,14 @@ a release here does not imply a release of any other crate in the family. ## [Unreleased] +### Changed (#585) + +- A model without a declared, readable length unit now refuses each + product's placement or geometry with `invalid-model` instead of placing it + in metres. + +Semver: patch (internal crate, not published). + ### Changed - Licence: `(AGPL-3.0-or-later OR LicenseRef-Commercial) AND CC-BY-ND-4.0` (was `AGPL-3.0-or-later`; the embedded or shipped catalog stays `CC-BY-ND-4.0`); a commercial licence is available from the copyright holder. See LICENSING.md. diff --git a/crates/openbim-ifc-capi/CHANGELOG.md b/crates/openbim-ifc-capi/CHANGELOG.md index e2d80e29..50e56fb6 100644 --- a/crates/openbim-ifc-capi/CHANGELOG.md +++ b/crates/openbim-ifc-capi/CHANGELOG.md @@ -8,6 +8,14 @@ a release here does not imply a release of any other crate in the family. ## [Unreleased] +### Changed (#585) + +- A model without a declared, readable length unit now refuses each + product's placement or geometry with `invalid-model` instead of placing it + in metres. + +Semver: patch (behaviour fix; no signature changes). + ## [0.1.19] - 2026-10-28 ### Changed diff --git a/crates/openbim-ifc-cli/CHANGELOG.md b/crates/openbim-ifc-cli/CHANGELOG.md index 25a6d5a1..57f72eef 100644 --- a/crates/openbim-ifc-cli/CHANGELOG.md +++ b/crates/openbim-ifc-cli/CHANGELOG.md @@ -11,6 +11,14 @@ documents) is this crate's public interface. ## [Unreleased] +### Changed (#585) + +- A model without a declared, readable length unit now refuses each + product's placement or geometry with `invalid-model` instead of placing it + in metres. + +Semver: patch (behaviour fix; no signature changes). + ## [0.5.2] - 2026-10-28 ### Changed diff --git a/crates/openbim-ifc-dotnet/CHANGELOG.md b/crates/openbim-ifc-dotnet/CHANGELOG.md index a6c1231e..9e0c2352 100644 --- a/crates/openbim-ifc-dotnet/CHANGELOG.md +++ b/crates/openbim-ifc-dotnet/CHANGELOG.md @@ -9,6 +9,14 @@ a release here does not imply a release of any other crate in the family. ## [Unreleased] +### Changed (#585) + +- A model without a declared, readable length unit now refuses each + product's placement or geometry with `invalid-model` instead of placing it + in metres. + +Semver: patch (behaviour fix; no signature changes). + ## [0.11.2] - 2026-10-28 ### Changed diff --git a/crates/openbim-ifc-py/CHANGELOG.md b/crates/openbim-ifc-py/CHANGELOG.md index 38f02814..c4c26edf 100644 --- a/crates/openbim-ifc-py/CHANGELOG.md +++ b/crates/openbim-ifc-py/CHANGELOG.md @@ -8,6 +8,14 @@ a release here does not imply a release of any other crate in the family. ## [Unreleased] +### Changed (#585) + +- A model without a declared, readable length unit now refuses each + product's placement or geometry with `invalid-model` instead of placing it + in metres. + +Semver: patch (behaviour fix; no signature changes). + ## [0.14.2] - 2026-10-28 ### Changed diff --git a/crates/openbim-ifc-wasm/CHANGELOG.md b/crates/openbim-ifc-wasm/CHANGELOG.md index 1d59e462..7fe43729 100644 --- a/crates/openbim-ifc-wasm/CHANGELOG.md +++ b/crates/openbim-ifc-wasm/CHANGELOG.md @@ -8,6 +8,14 @@ a release here does not imply a release of any other crate in the family. ## [Unreleased] +### Changed (#585) + +- A model without a declared, readable length unit now refuses each + product's placement or geometry with `invalid-model` instead of placing it + in metres. + +Semver: patch (behaviour fix; no signature changes). + ## [0.14.2] - 2026-10-28 ### Changed diff --git a/crates/openbim-ifc/CHANGELOG.md b/crates/openbim-ifc/CHANGELOG.md index 4d43839f..558481db 100644 --- a/crates/openbim-ifc/CHANGELOG.md +++ b/crates/openbim-ifc/CHANGELOG.md @@ -12,6 +12,18 @@ everything released before per-crate changelogs began. ## [Unreleased] +### Changed (#585) + +- A model whose units do not resolve refuses every product of + `product_placements`, `product_meshes`, `product_meshes_for`, + `product_curves` and `product_graphs` with the unit error, instead of + placing it in metres; return shapes are unchanged. The re-exported + `geometry` module carries `ifc-geometry`'s breaking `units` change + (`units::resolve` is fallible, `UnitScale::angle_to_radians` is an + `Option`, `UnitScale::default()` is `UnitScale::SI`). + +Semver: breaking through the re-exported `geometry` module (0.x minor). + ## [0.38.2] - 2026-10-28 ### Changed diff --git a/docs/use-cases/2d-approval-plans.md b/docs/use-cases/2d-approval-plans.md index e86bad08..2fa1651f 100644 --- a/docs/use-cases/2d-approval-plans.md +++ b/docs/use-cases/2d-approval-plans.md @@ -155,6 +155,7 @@ result; an application must still provide those layers. Unit resolution (`units.rs`) handles SI and conversion-based units, which matters because plan dimensions in millimetres versus metres is a classic silent-corruption bug in permit drawings. +A model that declares no length unit is refused with a typed error, never read in metres (#585). ### Reading a product's representations From 8b09b216fb62dda827c7dc2ad8c66f008b6a83e7 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 18:51:32 +0000 Subject: [PATCH 08/13] test: state units in the host and feature-gated test models Refs #585. --- crates/ifc-geometry/tests/authoring_roundtrip.rs | 3 +-- crates/ifc-geometry/tests/lower_connection_surface.rs | 4 ++-- crates/openbim-ifc-wasm/tests/js/geometry.mjs | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/ifc-geometry/tests/authoring_roundtrip.rs b/crates/ifc-geometry/tests/authoring_roundtrip.rs index 7e6136ba..e87bfd7f 100644 --- a/crates/ifc-geometry/tests/authoring_roundtrip.rs +++ b/crates/ifc-geometry/tests/authoring_roundtrip.rs @@ -10,7 +10,6 @@ use ifc_geometry::authoring::{ rectangle_profile, }; use ifc_geometry::solid::boolean::IfcBooleanOperator; -use ifc_geometry::UnitScale; use ifc_model::{Model, Transaction}; /// A 6m x 0.3m wall extruded 2.4m up, authored from plain numbers. @@ -56,7 +55,7 @@ fn an_authored_extrusion_lowers_into_the_neutral_graph() { let mut model = model; tx.commit(&mut model).expect("commit"); - let scale = UnitScale::SI; + let scale = ifc_geometry::UnitScale::SI; let mut session = LoweringSession::new(&model, &scale); let node = lower_representation_item(&mut session, solid, Transform::identity()) .expect("authored solid lowers"); diff --git a/crates/ifc-geometry/tests/lower_connection_surface.rs b/crates/ifc-geometry/tests/lower_connection_surface.rs index d1de50d5..43bb63e4 100644 --- a/crates/ifc-geometry/tests/lower_connection_surface.rs +++ b/crates/ifc-geometry/tests/lower_connection_surface.rs @@ -23,7 +23,7 @@ use ifc_geometry::lower::{ }; use ifc_geometry::transform::Transform; use ifc_geometry::UnitScale; -use ifc_geometry::{units, GeometryError, GeometryResult}; +use ifc_geometry::{GeometryError, GeometryResult}; use ifc_model::{Codec, EntityId, Model}; use ifc_step::StepCodec; use std::path::PathBuf; @@ -594,7 +594,7 @@ mod meshing { #[test] fn committed_space_boundaries_mesh_in_the_space_frame() { let model = space_fixture(); - let scale = units::resolve(&model).expect("project units"); + let scale = ifc_geometry::units::resolve(&model).expect("project units"); let frame = ifc_geometry::lower::product_world_transform(&model, &scale, EntityId(13)) .expect("the space is placed"); for (connection, m2, normal) in [ diff --git a/crates/openbim-ifc-wasm/tests/js/geometry.mjs b/crates/openbim-ifc-wasm/tests/js/geometry.mjs index 0d269e12..106a46e3 100644 --- a/crates/openbim-ifc-wasm/tests/js/geometry.mjs +++ b/crates/openbim-ifc-wasm/tests/js/geometry.mjs @@ -105,6 +105,10 @@ DATA; #20=IFCLOCALPLACEMENT(#21,#2); #21=IFCLOCALPLACEMENT(#20,#2); #22=IFCWALL('1YvctVUKr0kugbFTf53O9L',$,'cyclic',$,$,#20,#4,$,$); +#90=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#91=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#92=IFCUNITASSIGNMENT((#90,#91)); +#93=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,#92); ENDSEC; END-ISO-10303-21; `), From ab55e43fc7a8a1a755bd2465db5d94aaff17067c Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 19:06:05 +0000 Subject: [PATCH 09/13] fix(ifc-alignment)!: read the angle unit only where an angle is read AlignmentUnits::angle_to_radians is optional. A StartDirection without a plane-angle unit refuses with AngleUnitUndeclared; length-only bridges (linear placements, seam tolerance) no longer need one. ifc-geometry maps the refusal back to GeometryError::AngleUnitUndeclared. --- crates/ifc-alignment/CHANGELOG.md | 8 + crates/ifc-alignment/src/curve/tolerance.rs | 4 +- crates/ifc-alignment/src/error.rs | 9 + .../ifc-alignment/src/horizontal/segment.rs | 16 +- .../ifc-alignment/tests/authoring_referent.rs | 2 +- .../tests/authoring_roundtrip.rs | 2 +- .../ifc-alignment/tests/banked_centreline.rs | 2 +- crates/ifc-alignment/tests/cant_frames.rs | 2 +- crates/ifc-alignment/tests/closing_segment.rs | 2 +- .../tests/declaration_inventory.rs | 2 +- crates/ifc-alignment/tests/gradient_curve.rs | 2 +- .../ifc-alignment/tests/horizontal_curve.rs | 10 +- crates/ifc-alignment/tests/horizontal_plan.rs | 2 +- .../tests/layout_and_placement.rs | 2 +- .../ifc-alignment/tests/parameter_segments.rs | 6 +- .../ifc-alignment/tests/partial_lowering.rs | 2 +- .../ifc-alignment/tests/profile_continuity.rs | 2 +- .../ifc-alignment/tests/real_export_survey.rs | 4 +- .../tests/referent_value_form.rs | 2 +- crates/ifc-alignment/tests/seam_tolerance.rs | 4 +- crates/ifc-alignment/tests/segment_readers.rs | 22 +- crates/ifc-alignment/tests/spiral_law.rs | 2 +- .../tests/step_text_roundtrip.rs | 2 +- crates/ifc-alignment/tests/support/mod.rs | 2 +- .../tests/typed_segment_values.rs | 4 +- .../ifc-alignment/tests/vertical_lowering.rs | 2 +- crates/ifc-alignment/tests/viennese_bend.rs | 2 +- .../src/constraint/placement/derive.rs | 19 +- .../src/constraint/placement/linear.rs | 2 +- .../ifc-geometry/src/lower/curve/gradient.rs | 2 +- .../ifc-geometry/src/lower/station/seams.rs | 2 +- crates/ifc-geometry/tests/angle_unit_scope.rs | 219 ++++++++++++++++++ .../tests/derive_bounded_paths.rs | 2 +- .../ifc-geometry/tests/derive_open_paths.rs | 2 +- crates/ifc-geometry/tests/derive_paths.rs | 2 +- crates/ifc-geometry/tests/derive_seams.rs | 2 +- 36 files changed, 317 insertions(+), 56 deletions(-) create mode 100644 crates/ifc-geometry/tests/angle_unit_scope.rs diff --git a/crates/ifc-alignment/CHANGELOG.md b/crates/ifc-alignment/CHANGELOG.md index f6124bf6..9d5c96c7 100644 --- a/crates/ifc-alignment/CHANGELOG.md +++ b/crates/ifc-alignment/CHANGELOG.md @@ -12,6 +12,14 @@ everything released before per-crate changelogs began. ## [Unreleased] +### Changed (#585) + +- Breaking: `AlignmentUnits::angle_to_radians` is `Option`; `None` means + the project declares no `PLANEANGLEUNIT`. Reading a `StartDirection` then + refuses with the new `AlignmentError::AngleUnitUndeclared { entity }`; + length-only reads (`SeamTolerance::for_model`, `resolve_linear_placement`) + need no angle unit. + ## [0.10.2] - 2026-10-28 ### Changed diff --git a/crates/ifc-alignment/src/curve/tolerance.rs b/crates/ifc-alignment/src/curve/tolerance.rs index 6445b244..b43f27c6 100644 --- a/crates/ifc-alignment/src/curve/tolerance.rs +++ b/crates/ifc-alignment/src/curve/tolerance.rs @@ -184,7 +184,7 @@ mod tests { fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -232,7 +232,7 @@ mod tests { context(&mut model, 1, 3, Value::Real(0.1)); let millimetres = AlignmentUnits { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let tolerance = SeamTolerance::for_model(&model, millimetres).expect("tolerance"); assert!((tolerance.length() - 1e-4).abs() < 1e-18); diff --git a/crates/ifc-alignment/src/error.rs b/crates/ifc-alignment/src/error.rs index 78471381..6f484041 100644 --- a/crates/ifc-alignment/src/error.rs +++ b/crates/ifc-alignment/src/error.rs @@ -43,6 +43,11 @@ pub enum AlignmentError { /// What expectation failed. detail: &'static str, }, + /// An angle attribute was read, and the project declares no `PLANEANGLEUNIT`. + AngleUnitUndeclared { + /// The entity holding the angle. + entity: EntityId, + }, /// A horizontal, vertical, or cant segment carries internally inconsistent data. InvalidSegment { /// The segment entity. @@ -212,6 +217,10 @@ impl std::fmt::Display for AlignmentError { name, } => write!(f, "{entity} has invalid {name} at slot {index}"), Self::InvalidUnits { detail } => write!(f, "invalid alignment units: {detail}"), + Self::AngleUnitUndeclared { entity } => write!( + f, + "{entity} holds a plane angle, and the project declares no PLANEANGLEUNIT" + ), Self::InvalidSegment { entity, detail } => { write!(f, "invalid alignment segment {entity}: {detail}") } diff --git a/crates/ifc-alignment/src/horizontal/segment.rs b/crates/ifc-alignment/src/horizontal/segment.rs index 22cc0c80..0470e7cd 100644 --- a/crates/ifc-alignment/src/horizontal/segment.rs +++ b/crates/ifc-alignment/src/horizontal/segment.rs @@ -11,8 +11,10 @@ use crate::error::{AlignmentError, AlignmentResult}; pub struct AlignmentUnits { /// Factor converting a length attribute's stored value to metres. pub length_to_metres: f64, - /// Factor converting an angle attribute's stored value to radians. - pub angle_to_radians: f64, + /// Factor converting an angle attribute's stored value to radians, or + /// `None` when the project declares no plane-angle unit; reading an angle + /// then refuses with [`AlignmentError::AngleUnitUndeclared`]. + pub angle_to_radians: Option, } impl AlignmentUnits { @@ -22,7 +24,10 @@ impl AlignmentUnits { detail: "length factor must be finite and positive", }); } - if !self.angle_to_radians.is_finite() || self.angle_to_radians <= 0.0 { + if self + .angle_to_radians + .is_some_and(|factor| !factor.is_finite() || factor <= 0.0) + { return Err(AlignmentError::InvalidUnits { detail: "angle factor must be finite and positive", }); @@ -171,8 +176,11 @@ pub fn read_horizontal_segment( })? * units.length_to_metres; + let angle_to_radians = units + .angle_to_radians + .ok_or(AlignmentError::AngleUnitUndeclared { entity: id })?; let start_direction = - required_number(entity, id, START_DIRECTION, "StartDirection")? * units.angle_to_radians; + required_number(entity, id, START_DIRECTION, "StartDirection")? * angle_to_radians; let start_radius = required_number(entity, id, START_RADIUS, "StartRadiusOfCurvature")? * units.length_to_metres; let end_radius = diff --git a/crates/ifc-alignment/tests/authoring_referent.rs b/crates/ifc-alignment/tests/authoring_referent.rs index 3d6a0db6..21cbf6f8 100644 --- a/crates/ifc-alignment/tests/authoring_referent.rs +++ b/crates/ifc-alignment/tests/authoring_referent.rs @@ -45,7 +45,7 @@ fn an_authored_referent_resolves_to_its_station() { let units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let stations = station_equations(&model, units).expect("resolves"); assert_eq!(stations.len(), 1, "the authored referent is found"); diff --git a/crates/ifc-alignment/tests/authoring_roundtrip.rs b/crates/ifc-alignment/tests/authoring_roundtrip.rs index 5e5445e3..5e9b4cf4 100644 --- a/crates/ifc-alignment/tests/authoring_roundtrip.rs +++ b/crates/ifc-alignment/tests/authoring_roundtrip.rs @@ -13,7 +13,7 @@ use ifc_model::{Entity, Model, Transaction, Value}; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/banked_centreline.rs b/crates/ifc-alignment/tests/banked_centreline.rs index d5854a27..c0a043d6 100644 --- a/crates/ifc-alignment/tests/banked_centreline.rs +++ b/crates/ifc-alignment/tests/banked_centreline.rs @@ -33,7 +33,7 @@ const B: f64 = 1.5; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/cant_frames.rs b/crates/ifc-alignment/tests/cant_frames.rs index 7001337f..eedf6166 100644 --- a/crates/ifc-alignment/tests/cant_frames.rs +++ b/crates/ifc-alignment/tests/cant_frames.rs @@ -24,7 +24,7 @@ use ifc_model::{Entity, EntityId, Model, Transaction, Value}; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/closing_segment.rs b/crates/ifc-alignment/tests/closing_segment.rs index c9c7641c..286826cd 100644 --- a/crates/ifc-alignment/tests/closing_segment.rs +++ b/crates/ifc-alignment/tests/closing_segment.rs @@ -34,7 +34,7 @@ use ifc_step::StepCodec; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/declaration_inventory.rs b/crates/ifc-alignment/tests/declaration_inventory.rs index 8bb0f9c3..250b409b 100644 --- a/crates/ifc-alignment/tests/declaration_inventory.rs +++ b/crates/ifc-alignment/tests/declaration_inventory.rs @@ -16,7 +16,7 @@ use ifc_schema::{ifc4x3, TypeKind}; const UNITS: AlignmentUnits = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; fn members(enumeration: &str) -> Vec { diff --git a/crates/ifc-alignment/tests/gradient_curve.rs b/crates/ifc-alignment/tests/gradient_curve.rs index 5f7abad1..54330f6c 100644 --- a/crates/ifc-alignment/tests/gradient_curve.rs +++ b/crates/ifc-alignment/tests/gradient_curve.rs @@ -15,7 +15,7 @@ use std::sync::Arc; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/horizontal_curve.rs b/crates/ifc-alignment/tests/horizontal_curve.rs index ddb0f608..60f145f8 100644 --- a/crates/ifc-alignment/tests/horizontal_curve.rs +++ b/crates/ifc-alignment/tests/horizontal_curve.rs @@ -51,7 +51,7 @@ fn segment(kind: &str, start_radius: f64, end_radius: f64, length: f64) -> Model fn millimetres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -127,7 +127,7 @@ fn finite_arc_inputs_that_overflow_the_circle_frame_are_rejected() { EntityId(2), AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }, ), Err(AlignmentError::InvalidSegment { .. }) @@ -149,7 +149,7 @@ fn finite_arc_inputs_that_overflow_the_trim_parameter_are_rejected() { EntityId(2), AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }, ), Err(AlignmentError::InvalidSegment { .. }) @@ -165,7 +165,7 @@ fn large_global_coordinates_do_not_collapse_the_arc_frame_axes() { EntityId(2), AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }, ) .expect("finite arc"); @@ -243,7 +243,7 @@ fn committed_ifc_fixture_lowers_line_and_arc_exactly() { let model = StepCodec.read_path(&path).expect("fixture parses"); let units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let line = lower_horizontal_segment(&model, EntityId(61), units).expect("line"); let arc = lower_horizontal_segment(&model, EntityId(62), units).expect("arc"); diff --git a/crates/ifc-alignment/tests/horizontal_plan.rs b/crates/ifc-alignment/tests/horizontal_plan.rs index 8373fe36..98dd5820 100644 --- a/crates/ifc-alignment/tests/horizontal_plan.rs +++ b/crates/ifc-alignment/tests/horizontal_plan.rs @@ -22,7 +22,7 @@ use ifc_step::StepCodec; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/layout_and_placement.rs b/crates/ifc-alignment/tests/layout_and_placement.rs index bf1d52d2..a0ecf26c 100644 --- a/crates/ifc-alignment/tests/layout_and_placement.rs +++ b/crates/ifc-alignment/tests/layout_and_placement.rs @@ -21,7 +21,7 @@ fn fixture() -> ifc_model::Model { fn units() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/parameter_segments.rs b/crates/ifc-alignment/tests/parameter_segments.rs index 9a7634f4..b3ed999b 100644 --- a/crates/ifc-alignment/tests/parameter_segments.rs +++ b/crates/ifc-alignment/tests/parameter_segments.rs @@ -17,7 +17,7 @@ fn fixture() -> ifc_model::Model { fn units() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -29,7 +29,7 @@ fn resolves_vertical_parameters_and_units_without_curve_approximation() { EntityId(63), AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }, ) .expect("vertical segment"); @@ -54,7 +54,7 @@ fn resolves_cant_parameters_and_signed_offsets() { EntityId(64), AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }, ) .expect("cant segment"); diff --git a/crates/ifc-alignment/tests/partial_lowering.rs b/crates/ifc-alignment/tests/partial_lowering.rs index cfa6da94..7d13a81b 100644 --- a/crates/ifc-alignment/tests/partial_lowering.rs +++ b/crates/ifc-alignment/tests/partial_lowering.rs @@ -24,7 +24,7 @@ fn load(name: &str) -> ifc_model::Model { fn units() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/profile_continuity.rs b/crates/ifc-alignment/tests/profile_continuity.rs index b999d4d5..93ef7dfc 100644 --- a/crates/ifc-alignment/tests/profile_continuity.rs +++ b/crates/ifc-alignment/tests/profile_continuity.rs @@ -22,7 +22,7 @@ use ifc_model::{Entity, EntityId, Model, Value}; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/real_export_survey.rs b/crates/ifc-alignment/tests/real_export_survey.rs index cd80c0c7..c426188d 100644 --- a/crates/ifc-alignment/tests/real_export_survey.rs +++ b/crates/ifc-alignment/tests/real_export_survey.rs @@ -92,7 +92,7 @@ fn files(dir: &Path, out: &mut Vec) { fn units(model: &Model) -> AlignmentUnits { let mut units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; for (_, entity) in model.iter() { let kind = entity.attributes.get(1).and_then(|v| match v { @@ -117,7 +117,7 @@ fn units(model: &Model) -> AlignmentUnits { .eq_ignore_ascii_case("IFCCONVERSIONBASEDUNIT") && kind.as_deref() == Some("PLANEANGLEUNIT") { - units.angle_to_radians = core::f64::consts::PI / 180.0; + units.angle_to_radians = Some(core::f64::consts::PI / 180.0); } } units diff --git a/crates/ifc-alignment/tests/referent_value_form.rs b/crates/ifc-alignment/tests/referent_value_form.rs index 056b24e7..6db4086f 100644 --- a/crates/ifc-alignment/tests/referent_value_form.rs +++ b/crates/ifc-alignment/tests/referent_value_form.rs @@ -22,7 +22,7 @@ use ifc_step::StepCodec; const UNITS: AlignmentUnits = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; /// The STEP record of `id`, from the written text. diff --git a/crates/ifc-alignment/tests/seam_tolerance.rs b/crates/ifc-alignment/tests/seam_tolerance.rs index d2770882..bee8651f 100644 --- a/crates/ifc-alignment/tests/seam_tolerance.rs +++ b/crates/ifc-alignment/tests/seam_tolerance.rs @@ -27,7 +27,7 @@ use ifc_model::{Entity, EntityId, Model, Transaction, Value}; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -207,7 +207,7 @@ fn a_coarse_declared_precision_cannot_join_a_visible_step() { fn the_precision_is_read_in_the_project_length_unit() { let millimetres = AlignmentUnits { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let (model, _, v) = fixture(&ROUNDED, 1000.0, Some(0.1)); vertical_profile_law(&model, v, millimetres).expect("sound within 0.1 mm"); diff --git a/crates/ifc-alignment/tests/segment_readers.rs b/crates/ifc-alignment/tests/segment_readers.rs index 80e21cdd..917731c6 100644 --- a/crates/ifc-alignment/tests/segment_readers.rs +++ b/crates/ifc-alignment/tests/segment_readers.rs @@ -32,14 +32,14 @@ use ifc_step::StepCodec; fn millimetres_and_degrees() -> AlignmentUnits { AlignmentUnits { length_to_metres: 0.001, - angle_to_radians: PI / 180.0, + angle_to_radians: Some(PI / 180.0), } } fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } @@ -317,11 +317,11 @@ fn refuses_unusable_horizontal_units() { for units in [ AlignmentUnits { length_to_metres: 0.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }, AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: f64::NAN, + angle_to_radians: Some(f64::NAN), }, ] { assert!(matches!( @@ -331,6 +331,18 @@ fn refuses_unusable_horizontal_units() { } } +#[test] +fn a_horizontal_segment_without_an_angle_unit_refuses_naming_it() { + let units = AlignmentUnits { + length_to_metres: 1.0, + angle_to_radians: None, + }; + assert!(matches!( + read_horizontal_segment(&fixture(), EntityId(2), units), + Err(AlignmentError::AngleUnitUndeclared { entity }) if entity == EntityId(2) + )); +} + // ------------------------------------------------------------------ vertical /// A parabolic arc read from a file. Lengths are scaled; gradients are @@ -456,7 +468,7 @@ fn refuses_an_unusable_vertical_length_unit() { EntityId(1), AlignmentUnits { length_to_metres: -1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } ), Err(AlignmentError::InvalidUnits { .. }) diff --git a/crates/ifc-alignment/tests/spiral_law.rs b/crates/ifc-alignment/tests/spiral_law.rs index 9257b772..c3f5285a 100644 --- a/crates/ifc-alignment/tests/spiral_law.rs +++ b/crates/ifc-alignment/tests/spiral_law.rs @@ -26,7 +26,7 @@ const HORIZONTAL: EntityId = EntityId(191); fn units() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/step_text_roundtrip.rs b/crates/ifc-alignment/tests/step_text_roundtrip.rs index e680403b..308c9390 100644 --- a/crates/ifc-alignment/tests/step_text_roundtrip.rs +++ b/crates/ifc-alignment/tests/step_text_roundtrip.rs @@ -20,7 +20,7 @@ use ifc_step::StepCodec; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/support/mod.rs b/crates/ifc-alignment/tests/support/mod.rs index 108dab61..b23a0d11 100644 --- a/crates/ifc-alignment/tests/support/mod.rs +++ b/crates/ifc-alignment/tests/support/mod.rs @@ -20,7 +20,7 @@ use ifc_model::{Entity, EntityId, Header, Model, Transaction, Value}; pub fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/typed_segment_values.rs b/crates/ifc-alignment/tests/typed_segment_values.rs index 19477d7f..8a95e155 100644 --- a/crates/ifc-alignment/tests/typed_segment_values.rs +++ b/crates/ifc-alignment/tests/typed_segment_values.rs @@ -20,7 +20,7 @@ use ifc_step::StepCodec; /// the unwrapped number. const MILLIMETRES: AlignmentUnits = AlignmentUnits { length_to_metres: 0.001, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; fn model(data: &str) -> Model { @@ -107,7 +107,7 @@ fn the_cant_layout_reads_a_typed_rail_head_distance_as_bare() { assert_ne!(bare, typed, "both records were rewritten"); let units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), }; let read = |text: &str| { let m = StepCodec.read_bytes(text.as_bytes()).expect("parses"); diff --git a/crates/ifc-alignment/tests/vertical_lowering.rs b/crates/ifc-alignment/tests/vertical_lowering.rs index a0d351f6..ecbfa21a 100644 --- a/crates/ifc-alignment/tests/vertical_lowering.rs +++ b/crates/ifc-alignment/tests/vertical_lowering.rs @@ -27,7 +27,7 @@ use ifc_model::{Entity, EntityId, Model, Value}; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-alignment/tests/viennese_bend.rs b/crates/ifc-alignment/tests/viennese_bend.rs index 179efd5b..4b26ec9f 100644 --- a/crates/ifc-alignment/tests/viennese_bend.rs +++ b/crates/ifc-alignment/tests/viennese_bend.rs @@ -15,7 +15,7 @@ use ifc_model::{Entity, EntityId, Model, Transaction, Value}; fn metres() -> AlignmentUnits { AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: 1.0, + angle_to_radians: Some(1.0), } } diff --git a/crates/ifc-geometry/src/constraint/placement/derive.rs b/crates/ifc-geometry/src/constraint/placement/derive.rs index a9d2e392..d4dcf3c3 100644 --- a/crates/ifc-geometry/src/constraint/placement/derive.rs +++ b/crates/ifc-geometry/src/constraint/placement/derive.rs @@ -327,7 +327,7 @@ pub(crate) fn resolve_in_file_units( ) -> GeometryResult { let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: units.radians_per_unit(placement)?, + angle_to_radians: units.angle_to_radians, }; ifc_alignment::resolve_linear_placement(model, placement, file_units).map_err(|_error| { GeometryError::Unsupported { @@ -554,7 +554,7 @@ fn model_precision(model: &Model, units: &UnitScale, placement: EntityId) -> Geo model, AlignmentUnits { length_to_metres: units.length_to_metres, - angle_to_radians: units.radians_per_unit(placement)?, + angle_to_radians: units.angle_to_radians, }, ) .map_err(|_| GeometryError::Degenerate { @@ -645,7 +645,7 @@ fn basis_curve( })?; let alignment_units = AlignmentUnits { length_to_metres: units.length_to_metres, - angle_to_radians: units.radians_per_unit(basis)?, + angle_to_radians: units.angle_to_radians, }; // IFC lets the BasisCurve be the alignment itself or its curve // representation. Both name the same centreline, so both resolve; a @@ -668,10 +668,15 @@ fn basis_curve( )), "IFCALIGNMENT" => ifc_alignment::gradient_curve3(model, basis, alignment_units) .map(|curve| Basis::Curve(curve, DistanceConvention::PlanDistance)) - .map_err(|_error| GeometryError::Unsupported { - entity: placement, - type_name: entity.type_name.to_string(), - detail: "basis curve does not compose an exact centreline", + .map_err(|error| match error { + ifc_alignment::AlignmentError::AngleUnitUndeclared { entity } => { + GeometryError::AngleUnitUndeclared { entity } + } + _ => GeometryError::Unsupported { + entity: placement, + type_name: entity.type_name.to_string(), + detail: "basis curve does not compose an exact centreline", + }, }), "IFCPOLYLINE" => Ok(Basis::Curve( polyline::polyline(model, units, basis, entity)?, diff --git a/crates/ifc-geometry/src/constraint/placement/linear.rs b/crates/ifc-geometry/src/constraint/placement/linear.rs index b7db5a88..b403fd81 100644 --- a/crates/ifc-geometry/src/constraint/placement/linear.rs +++ b/crates/ifc-geometry/src/constraint/placement/linear.rs @@ -115,7 +115,7 @@ pub(crate) fn basis_curve( ) -> GeometryResult { let file_units = ifc_alignment::AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: units.radians_per_unit(placement)?, + angle_to_radians: units.angle_to_radians, }; ifc_alignment::resolve_linear_placement(model, placement, file_units) .map(|resolved| resolved.relative_placement.basis_curve) diff --git a/crates/ifc-geometry/src/lower/curve/gradient.rs b/crates/ifc-geometry/src/lower/curve/gradient.rs index 8016e09d..c3954295 100644 --- a/crates/ifc-geometry/src/lower/curve/gradient.rs +++ b/crates/ifc-geometry/src/lower/curve/gradient.rs @@ -189,7 +189,7 @@ impl Tolerance { fn for_session(session: &LoweringSession<'_>, id: EntityId) -> GeometryResult { let units = AlignmentUnits { length_to_metres: session.units().length_to_metres, - angle_to_radians: session.units().radians_per_unit(id)?, + angle_to_radians: session.units().angle_to_radians, }; SeamTolerance::for_model(session.model(), units) .map(|t| Self(t.length())) diff --git a/crates/ifc-geometry/src/lower/station/seams.rs b/crates/ifc-geometry/src/lower/station/seams.rs index 3f296c63..b45bcae9 100644 --- a/crates/ifc-geometry/src/lower/station/seams.rs +++ b/crates/ifc-geometry/src/lower/station/seams.rs @@ -97,7 +97,7 @@ pub(crate) fn precision( ) -> GeometryResult { let units = AlignmentUnits { length_to_metres: session.units().length_to_metres, - angle_to_radians: session.units().radians_per_unit(owner)?, + angle_to_radians: session.units().angle_to_radians, }; SeamTolerance::for_model(session.model(), units) .map(|tolerance| tolerance.length()) diff --git a/crates/ifc-geometry/tests/angle_unit_scope.rs b/crates/ifc-geometry/tests/angle_unit_scope.rs new file mode 100644 index 00000000..d654fbc3 --- /dev/null +++ b/crates/ifc-geometry/tests/angle_unit_scope.rs @@ -0,0 +1,219 @@ +//! A missing `PLANEANGLEUNIT` is refused where an angle is read, and only +//! there (#585). +//! +//! A model that declares a length unit and no plane-angle unit is valid IFC. +//! Length-only work (a linearly placed product with a cached position, a +//! Cartesian trim) must resolve; reading an angle (a revolution, a horizontal +//! alignment segment, a parameter trim of a conic) names the entity. + +#![cfg(feature = "lowering")] + +use ifc_geometry::lower::{lower_representation_item, LoweringSession}; +use ifc_geometry::transform::Transform; +use ifc_geometry::{product_world_transform, units, GeometryError}; +use ifc_model::{Codec, Entity, EntityId, Model, Value}; +use std::path::PathBuf; + +fn parse(text: &str) -> Model { + ifc_step::StepCodec + .read_bytes(text.as_bytes()) + .unwrap_or_else(|e| panic!("text parses: {e:?}")) +} + +fn fixture(rel: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures") + .join(rel); + std::fs::read_to_string(path).expect("fixture reads") +} + +/// The shape of `linear_placement.rs::model_with_linear_placement(true)` (a +/// cached `CartesianPosition`, an `IfcPolyline` basis) in a model whose unit +/// assignment holds only a millimetre length unit. +fn linear_placement_in_millimetres() -> (Model, EntityId) { + let mut model = Model::default(); + let r = |n| Value::Ref(EntityId(n)); + model.insert( + EntityId(1), + Entity::new("IFCPOLYLINE", vec![Value::List(vec![])]), + ); + model.insert( + EntityId(2), + Entity::new( + "IFCPOINTBYDISTANCEEXPRESSION", + vec![ + Value::Real(1240.0), + Value::Real(2.0), + Value::Real(3.0), + Value::Null, + r(1), + ], + ), + ); + model.insert( + EntityId(3), + Entity::new( + "IFCAXIS2PLACEMENTLINEAR", + vec![r(2), Value::Null, Value::Null], + ), + ); + model.insert( + EntityId(4), + Entity::new( + "IFCCARTESIANPOINT", + vec![Value::List(vec![ + Value::Real(100.0), + Value::Real(200.0), + Value::Real(5.0), + ])], + ), + ); + model.insert( + EntityId(5), + Entity::new("IFCAXIS2PLACEMENT3D", vec![r(4), Value::Null, Value::Null]), + ); + model.insert( + EntityId(6), + Entity::new("IFCLINEARPLACEMENT", vec![Value::Null, r(3), r(5)]), + ); + let mut sign = vec![Value::Null; 7]; + sign[5] = r(6); + model.insert(EntityId(7), Entity::new("IFCSIGN", sign)); + model.insert( + EntityId(10), + Entity::new( + "IFCSIUNIT", + vec![ + Value::Derived, + Value::Enum("LENGTHUNIT".into()), + Value::Enum("MILLI".into()), + Value::Enum("METRE".into()), + ], + ), + ); + model.insert( + EntityId(11), + Entity::new("IFCUNITASSIGNMENT", vec![Value::List(vec![r(10)])]), + ); + let mut project = vec![Value::Null; 9]; + project[8] = r(11); + model.insert(EntityId(12), Entity::new("IFCPROJECT", project)); + (model, EntityId(7)) +} + +#[test] +fn a_linear_placement_resolves_without_an_angle_unit() { + let (model, sign) = linear_placement_in_millimetres(); + let scale = units::resolve(&model).expect("a length unit is enough"); + assert_eq!(scale.angle_to_radians, None); + let world = product_world_transform(&model, &scale, sign) + .expect("length-only placement must not need an angle unit"); + let expected = [0.1, 0.2, 0.005]; + for (got, want) in world.origin.iter().zip(expected) { + assert!((got - want).abs() < 1e-12, "{:?}", world.origin); + } +} + +#[cfg(feature = "compile-reference-backend")] +mod alignment { + use super::*; + use axiolid_evaluate::ReferenceCurveEvaluator; + use ifc_geometry::{product_world_transform_with_evaluator, CachedPositionPolicy}; + + const EXTRA: &str = "#900=IFCPOINTBYDISTANCEEXPRESSION(IFCLENGTHMEASURE(5.),0.,$,$,#401);\n\ +#901=IFCAXIS2PLACEMENTLINEAR(#900,$,$);\n#902=IFCLINEARPLACEMENT($,#901,$);\n\ +#903=IFCBUILDINGELEMENTPROXY('1Y0f2uCbr0gfm5cmtZmh01',$,'ON_ALIGNMENT',$,$,#902,$,$,$);\n"; + + fn layout(with_angle_unit: bool) -> Model { + let text = fixture("synthetic-surfaces/synthetic_alignment_layout.ifc"); + // The vertical profile spans the whole horizontal plan. + let text = text.replacen("0.,15.,100.", "0.,17.853981633974483,100.", 1); + let text = if with_angle_unit { + text + } else { + let without = text.replacen("IFCUNITASSIGNMENT((#1,#2))", "IFCUNITASSIGNMENT((#1))", 1); + assert_ne!(without, text, "the angle unit is dropped"); + without + }; + let text = text.replacen("ENDSEC;\nEND-ISO", &format!("{EXTRA}ENDSEC;\nEND-ISO"), 1); + assert!(text.contains("ON_ALIGNMENT"), "records appended"); + parse(&text) + } + + fn place(model: &Model) -> Result { + let scale = units::resolve(model).expect("project units"); + product_world_transform_with_evaluator( + model, + &scale, + EntityId(903), + &ReferenceCurveEvaluator::default(), + CachedPositionPolicy::Verify, + ) + } + + #[test] + fn a_horizontal_alignment_basis_read_refuses_without_an_angle_unit() { + place(&layout(true)).expect("with an angle unit the placement resolves"); + let model = layout(false); + let GeometryError::AngleUnitUndeclared { entity } = + place(&model).expect_err("a StartDirection needs an angle unit") + else { + panic!("expected AngleUnitUndeclared"); + }; + assert!( + model + .get(entity) + .is_some_and(|e| e.is_type("IFCALIGNMENTHORIZONTALSEGMENT")), + "names the segment that holds the angle, got {entity:?}" + ); + } +} + +const TRIMMED: &str = + "#1=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n#2=IFCUNITASSIGNMENT((#1));\n\ +#3=IFCPROJECT('3YvctVUKr0kugbFTf53O9L',$,'P',$,$,$,$,$,#2);\n\ +#10=IFCCARTESIANPOINT((0.,0.,0.));\n#11=IFCAXIS2PLACEMENT3D(#10,$,$);\n\ +#12=IFCCIRCLE(#11,1000.);\n#13=IFCCARTESIANPOINT((1000.,0.,0.));\n\ +#14=IFCCARTESIANPOINT((0.,1000.,0.));\n"; + +fn trimmed_model(preference: &str) -> Model { + parse(&format!( + "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1');\n\ +FILE_NAME('t.ifc','2026-01-01T00:00:00',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\ +{TRIMMED}#15=IFCTRIMMEDCURVE(#12,(IFCPARAMETERVALUE(0.),#13),(IFCPARAMETERVALUE(90.),#14),.T.,{preference});\n\ +ENDSEC;\nEND-ISO-10303-21;\n" + )) +} + +fn lower_trimmed(model: &Model) -> Result<(), GeometryError> { + let scale = units::resolve(model).expect("project units"); + let mut session = LoweringSession::new(model, &scale); + lower_representation_item(&mut session, EntityId(15), Transform::identity()).map(|_| ()) +} + +#[test] +fn a_cartesian_trim_of_a_circle_lowers_without_an_angle_unit() { + lower_trimmed(&trimmed_model(".CARTESIAN.")).expect("the points carry the trim"); +} + +#[test] +fn a_parameter_trim_of_a_circle_needs_the_angle_unit() { + assert!(matches!( + lower_trimmed(&trimmed_model(".PARAMETER.")), + Err(GeometryError::AngleUnitUndeclared { entity }) if entity == EntityId(15) + )); +} + +#[test] +fn a_revolved_solid_refuses_without_an_angle_unit() { + let text = fixture("synthetic-surfaces/synthetic_tapered_sweeps.ifc"); + let without = text.replacen("IFCUNITASSIGNMENT((#1,#5))", "IFCUNITASSIGNMENT((#1))", 1); + assert_ne!(without, text, "the angle unit is dropped"); + let model = parse(&without); + let solid = model.ids_of_type("IFCREVOLVEDAREASOLID")[0]; + let scale = units::resolve(&model).expect("project units"); + let mut session = LoweringSession::new(&model, &scale); + let error = lower_representation_item(&mut session, solid, Transform::identity()) + .expect_err("an Angle needs an angle unit"); + assert_eq!(error, GeometryError::AngleUnitUndeclared { entity: solid }); +} diff --git a/crates/ifc-geometry/tests/derive_bounded_paths.rs b/crates/ifc-geometry/tests/derive_bounded_paths.rs index 77ac8c88..5c8ff7e8 100644 --- a/crates/ifc-geometry/tests/derive_bounded_paths.rs +++ b/crates/ifc-geometry/tests/derive_bounded_paths.rs @@ -168,7 +168,7 @@ fn derived( let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians.expect("angle unit"), + angle_to_radians: scale.angle_to_radians, }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); diff --git a/crates/ifc-geometry/tests/derive_open_paths.rs b/crates/ifc-geometry/tests/derive_open_paths.rs index 4d87020a..b63e5327 100644 --- a/crates/ifc-geometry/tests/derive_open_paths.rs +++ b/crates/ifc-geometry/tests/derive_open_paths.rs @@ -102,7 +102,7 @@ fn derived( let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians.expect("angle unit"), + angle_to_radians: scale.angle_to_radians, }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); diff --git a/crates/ifc-geometry/tests/derive_paths.rs b/crates/ifc-geometry/tests/derive_paths.rs index 903101d8..0553f039 100644 --- a/crates/ifc-geometry/tests/derive_paths.rs +++ b/crates/ifc-geometry/tests/derive_paths.rs @@ -92,7 +92,7 @@ fn derived( let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians.expect("angle unit"), + angle_to_radians: scale.angle_to_radians, }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); diff --git a/crates/ifc-geometry/tests/derive_seams.rs b/crates/ifc-geometry/tests/derive_seams.rs index 77b27f8b..b3ccfe26 100644 --- a/crates/ifc-geometry/tests/derive_seams.rs +++ b/crates/ifc-geometry/tests/derive_seams.rs @@ -106,7 +106,7 @@ fn derived( let scale = units::resolve(model).expect("project units"); let file_units = AlignmentUnits { length_to_metres: 1.0, - angle_to_radians: scale.angle_to_radians.expect("angle unit"), + angle_to_radians: scale.angle_to_radians, }; let point = ifc_alignment::resolve_point_by_distance(model, EntityId(expression), file_units) .expect("the expression reads"); From 4d79ab917eae40ee9cbfbc2ecaef2da02506cde8 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 19:06:05 +0000 Subject: [PATCH 10/13] fix(ifc-geometry): skip an unreadable trim angle when the point is preferred A Cartesian-preferred conic trim with a point needs no plane-angle unit. --- crates/ifc-geometry/src/lower/curve.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/ifc-geometry/src/lower/curve.rs b/crates/ifc-geometry/src/lower/curve.rs index 93018ab0..b8d61273 100644 --- a/crates/ifc-geometry/src/lower/curve.rs +++ b/crates/ifc-geometry/src/lower/curve.rs @@ -453,8 +453,9 @@ fn trimmed( let spec = view.spec()?; let (t1, t2) = spec.endpoints(); - let start = selectors(session, id, t1, &basis_kind, frame)?; - let end = selectors(session, id, t2, &basis_kind, frame)?; + let cartesian_preferred = matches!(preference, TrimmingPreference::Cartesian); + let start = selectors(session, id, t1, &basis_kind, frame, cartesian_preferred)?; + let end = selectors(session, id, t2, &basis_kind, frame, cartesian_preferred)?; if start.is_empty() || end.is_empty() { return Err(session.degenerate( id, @@ -486,12 +487,19 @@ fn selectors( trim: crate::curve::trimmed::Trim, basis_kind: &str, frame: Transform, + cartesian_preferred: bool, ) -> GeometryResult> { let mut out = Vec::new(); if let Some(raw) = trim.parameter { - out.push(TrimSelector::Parameter(scale_parameter( - session, owner, basis_kind, raw, - )?)); + match scale_parameter(session, owner, basis_kind, raw) { + Ok(scaled) => out.push(TrimSelector::Parameter(scaled)), + // A conic's parameter is an angle. When the project declares no + // angle unit and the point is the preferred form, the point + // carries the trim and the angle is never read (#585). + Err(GeometryError::AngleUnitUndeclared { .. }) + if cartesian_preferred && trim.cartesian.is_some() => {} + Err(error) => return Err(error), + } } if let Some(point_ref) = trim.cartesian { let placed = world_point(session, owner, point_ref, frame)?; From b0a2849bcf6475f36c635782e69fadaeaf63ede8 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 19:06:05 +0000 Subject: [PATCH 11/13] fix(ifc-geometry): report two units of one type before reading the first --- crates/ifc-geometry/src/units.rs | 31 +++++++++++++++++--------- crates/ifc-geometry/src/units/tests.rs | 19 ++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/ifc-geometry/src/units.rs b/crates/ifc-geometry/src/units.rs index 798c8db8..1bcf2d88 100644 --- a/crates/ifc-geometry/src/units.rs +++ b/crates/ifc-geometry/src/units.rs @@ -141,29 +141,40 @@ pub fn resolve(model: &Model) -> GeometryResult { } let slots = Slots::new(assignment, assignment_entity); - let mut length: Option<(EntityId, f64)> = None; - let mut angle: Option<(EntityId, f64)> = None; + // Classify every member first: two units of one type are ambiguous even + // when the first cannot be read. + let mut length: Option = None; + let mut angle: Option = None; for unit_id in slots.req_ref_list(0, "Units")? { let unit = slots.resolve(model, unit_id)?; - let unit_slots = Slots::new(unit_id, unit); - match unit_type(&unit_slots) { + match unit_type(&Slots::new(unit_id, unit)) { Some("LENGTHUNIT") => { - if let Some((first, _)) = length { + if let Some(first) = length { return Err(ambiguous(first, unit_id, "LENGTHUNIT")); } - length = Some((unit_id, factor(model, &unit_slots, "LENGTHUNIT")?)); + length = Some(unit_id); } Some("PLANEANGLEUNIT") => { - if let Some((first, _)) = angle { + if let Some(first) = angle { return Err(ambiguous(first, unit_id, "PLANEANGLEUNIT")); } - angle = Some((unit_id, factor(model, &unit_slots, "PLANEANGLEUNIT")?)); + angle = Some(unit_id); } _ => {} } } + let read = |unit_id: EntityId, kind: &'static str| -> GeometryResult { + let unit = slots.resolve(model, unit_id)?; + factor(model, &Slots::new(unit_id, unit), kind) + }; + let length = length + .map(|unit_id| read(unit_id, "LENGTHUNIT")) + .transpose()?; + let angle = angle + .map(|unit_id| read(unit_id, "PLANEANGLEUNIT")) + .transpose()?; - let Some((_, length_to_metres)) = length else { + let Some(length_to_metres) = length else { return Err(GeometryError::LengthUnitUndeclared { project: Some(project), assignment: Some(assignment), @@ -171,7 +182,7 @@ pub fn resolve(model: &Model) -> GeometryResult { }; Ok(UnitScale { length_to_metres, - angle_to_radians: angle.map(|(_, factor)| factor), + angle_to_radians: angle, }) } diff --git a/crates/ifc-geometry/src/units/tests.rs b/crates/ifc-geometry/src/units/tests.rs index 6d2243de..a66f9b6a 100644 --- a/crates/ifc-geometry/src/units/tests.rs +++ b/crates/ifc-geometry/src/units/tests.rs @@ -192,6 +192,25 @@ fn two_length_units_refuse() { ); } +#[test] +fn two_length_units_are_ambiguous_even_when_the_first_is_unreadable() { + let model = model_with( + vec![ + (1, si("LENGTHUNIT", Some("BOGUS"), "METRE")), + (2, si("LENGTHUNIT", None, "METRE")), + ], + &[1, 2], + ); + assert_eq!( + resolve(&model), + Err(GeometryError::UnitsAmbiguous { + first: id(1), + second: id(2), + kind: "LENGTHUNIT" + }) + ); +} + #[test] fn unknown_prefix_refuses() { let model = model_with_length_unit(Some("BOGUS")); From cb22d232910e23ea86e8039ebc8b30c638295126 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 19:06:05 +0000 Subject: [PATCH 12/13] test(openbim-ifc): cover unit refusals in curves, graphs and world_transform world_transform resolves the project units first so a model without units reports LengthUnitUndeclared. The fixture corpus test asserts lower bounds. --- .../ifc-geometry/tests/units_on_fixtures.rs | 8 ++++- crates/openbim-ifc/src/operation/read.rs | 34 +++++++++++++++++-- crates/openbim-ifc/tests/product_geometry.rs | 32 +++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/ifc-geometry/tests/units_on_fixtures.rs b/crates/ifc-geometry/tests/units_on_fixtures.rs index 2d079fac..12fbb4a1 100644 --- a/crates/ifc-geometry/tests/units_on_fixtures.rs +++ b/crates/ifc-geometry/tests/units_on_fixtures.rs @@ -77,7 +77,13 @@ fn every_fixture_resolves_a_plausible_unit_scale() { checked >= 19, "expected the committed corpus, got {checked}" ); - assert_eq!(resolved + refused, checked); + // Lower bounds from the committed corpus (90 parsed: 76 resolved, 14 + // refused); a resolver that refuses everything must not pass. + assert!( + resolved >= 76, + "expected 76 resolved fixtures, got {resolved}" + ); + assert!(refused >= 14, "expected 14 refused fixtures, got {refused}"); assert!( flyaway_refused, "issue_1155_halfspace_flyaway.ifc declares no units and must be refused" diff --git a/crates/openbim-ifc/src/operation/read.rs b/crates/openbim-ifc/src/operation/read.rs index eac01347..487c970b 100644 --- a/crates/openbim-ifc/src/operation/read.rs +++ b/crates/openbim-ifc/src/operation/read.rs @@ -131,9 +131,8 @@ fn length_scale(model: &Model) -> Result { /// The product's world transform in metres, scaled by the exact length /// unit rather than the permissive one. pub(crate) fn world_transform(model: &Model, product: EntityId) -> Result { - let length = length_scale(model)?; let mut scale = units::resolve(model).map_err(ReadError::Placement)?; - scale.length_to_metres = length; + scale.length_to_metres = length_scale(model)?; product_world_transform(model, &scale, product).map_err(ReadError::Placement) } @@ -150,3 +149,34 @@ pub(crate) fn governing( let governing = sets.iter().filter(|set| set.source == source).collect(); Some((source, governing)) } + +#[cfg(test)] +mod tests { + use super::*; + use ifc_model::{Codec, EntityId}; + + /// A wall in a project that states no units (#585). + const WITHOUT_UNITS: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\ +FILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA; +#1=IFCPROJECT('0000000000000000000001',$,'P',$,$,$,$,$,$); +#2=IFCCARTESIANPOINT((1000.,0.,0.)); +#3=IFCAXIS2PLACEMENT3D(#2,$,$); +#4=IFCLOCALPLACEMENT($,#3); +#10=IFCWALL('0000000000000000000010',$,'W',$,$,#4,$,$,$); +ENDSEC;\nEND-ISO-10303-21;\n"; + + #[test] + fn a_model_without_units_refuses_the_world_transform() { + let model = ifc_step::StepCodec + .read_bytes(WITHOUT_UNITS.as_bytes()) + .expect("parses"); + let error = world_transform(&model, EntityId(10)).expect_err("no unit, no scale"); + assert!( + matches!( + error, + ReadError::Placement(GeometryError::LengthUnitUndeclared { .. }) + ), + "{error:?}" + ); + } +} diff --git a/crates/openbim-ifc/tests/product_geometry.rs b/crates/openbim-ifc/tests/product_geometry.rs index 36cb5923..609d9b20 100644 --- a/crates/openbim-ifc/tests/product_geometry.rs +++ b/crates/openbim-ifc/tests/product_geometry.rs @@ -496,3 +496,35 @@ fn a_polygonal_bounded_clip_is_labelled_wire_format_1_4() { let back = GeometryGraph::from_json(&text).expect("reads back"); assert_eq!(back.to_json().unwrap(), text); } + +#[test] +fn a_model_without_units_refuses_every_product_curve() { + let model = StepCodec + .read_bytes(WITHOUT_UNITS.as_bytes()) + .expect("parses"); + let curves = ifc::product_curves(&model, None, ifc::RepresentationPurpose::Plan); + assert_eq!(curves.len(), 1); + assert_eq!(curves[0].0, EntityId(10)); + assert!( + matches!( + curves[0].1, + Err(ifc::geometry::GeometryError::LengthUnitUndeclared { .. }) + ), + "{:?}", + curves[0].1 + ); +} + +#[cfg(feature = "geometry-wire")] +#[test] +fn a_model_without_units_refuses_every_product_graph() { + let model = StepCodec + .read_bytes(WITHOUT_UNITS.as_bytes()) + .expect("parses"); + let graphs = ifc::product_graphs(&model, None); + assert_eq!(graphs.len(), 1); + assert!(matches!( + graphs[0].1, + Err(ifc::geometry::GeometryError::LengthUnitUndeclared { .. }) + )); +} From 1c61613bea473b487cdac7e0a9fba626a3d0c92d Mon Sep 17 00:00:00 2001 From: generalpawz Date: Wed, 28 Oct 2026 19:34:38 +0000 Subject: [PATCH 13/13] test(openbim-ifc): gate the product_curves refusal test on mesh --- crates/openbim-ifc/tests/product_geometry.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openbim-ifc/tests/product_geometry.rs b/crates/openbim-ifc/tests/product_geometry.rs index 609d9b20..3a91a18c 100644 --- a/crates/openbim-ifc/tests/product_geometry.rs +++ b/crates/openbim-ifc/tests/product_geometry.rs @@ -497,6 +497,7 @@ fn a_polygonal_bounded_clip_is_labelled_wire_format_1_4() { assert_eq!(back.to_json().unwrap(), text); } +#[cfg(feature = "mesh")] #[test] fn a_model_without_units_refuses_every_product_curve() { let model = StepCodec