ifc-geometry 0.9.0

IFC semantic views lowered into the format-neutral geometry DAG.
Documentation
//! Deriving a placement frame from the basis curve.
//!
//! The cached `CartesianPosition` path resolves a placement without any
//! computation. When an authoring tool omits it, the frame has to be
//! derived by evaluating the basis curve at the authored distance, which is
//! computation and therefore an opt-in capability under ADR 0004.
//!
//! This module holds the IFC-side half: it turns an `IfcLinearPlacement`
//! into a curve, a measure and an offset, then asks an injected
//! [`CurveEvaluator`] for the frame. It never picks an evaluator, and
//! `ifc-geometry` links no implementation.
//!
//! # What an `IfcParameterValue` means here
//!
//! `IfcPointByDistanceExpression.DistanceAlong` is an `IfcCurveMeasureSelect`:
//! a length, or a parameter of the basis curve (IFC4.3 ADD2 8.9.3.48). A
//! parameter is only as defined as the basis curve's parameterisation:
//!
//! - `IfcPolyline` counts one per segment (8.9.3.51), and so does a
//!   line-only `IfcIndexedPolyCurve` (see the `polyline` submodule); the
//!   neutral polyline uses the same convention, so the parameter crosses
//!   unchanged. Only where every segment is one unit long does it equal a
//!   length.
//! - `IfcGradientCurve` takes the parameter of its `BaseCurve` (8.9.3.34.1),
//!   an `IfcCompositeCurve` of `IfcCurveSegment`s. A composite accumulates
//!   the parametric ranges of its parent curves (8.9.3.20.1, after
//!   ISO 10303-42), which are not lengths (an `IfcCircle` counts its angle,
//!   8.9.3.18.1; an `IfcClothoid` `u = s / (A sqrt(pi))`, 8.9.3.19.1), while
//!   `IfcCurveSegment` (8.9.3.28.1) states that no parametric space is yet
//!   defined for its parent curves and measures segments by length. The
//!   parameter of an alignment centreline is therefore undefined, and is
//!   refused by name rather than handed to an evaluator, whose own parameter
//!   on that curve is plan distance (axiolid ADR 0082), a different quantity.
//!   An `IfcAlignment` basis names the same centreline and is refused alike.
//!   This matches the station lowering, which refuses a parameter too.

use axiolid_contracts::GeomError;
use axiolid_core::Frame3;
use axiolid_curve::Curve3;
use axiolid_curve_evaluate_contract::{CurveEvaluator, CurveMeasure as KernelMeasure};
use axiolid_model::GeometryNode;
use ifc_alignment::{AlignmentUnits, CurveMeasure, PointByDistance};
use ifc_model::{EntityId, Model};

use crate::error::{GeometryError, GeometryResult};
use crate::lower::curve::lower_curve_node;
use crate::lower::session::LoweringSession;
use crate::transform::Transform;
use crate::units::UnitScale;

mod polyline;

/// Resolve an `IfcLinearPlacement` by evaluating its basis curve.
///
/// `evaluator` supplies the capability; this function supplies the IFC
/// reading and the unit handling. The distance is converted to metres
/// before it crosses the boundary, because the kernel is unitless.
///
/// Refuses, rather than approximating, when:
///
/// - the basis curve is not one this bridge can lower to a `Curve3`
/// - the evaluator reports it cannot measure distance on that curve
/// - the authored value is an `IfcParameterValue` on a basis curve whose
///   IFC parameterisation is undefined (an alignment centreline)
/// - roll is undefined because the tangent is vertical
pub fn derive_placement_transform(
    model: &Model,
    units: &UnitScale,
    placement: EntityId,
    expression: &PointByDistance,
    evaluator: &dyn CurveEvaluator,
) -> GeometryResult<Transform> {
    let parameter_requested = matches!(expression.distance_along, CurveMeasure::Parameter(_));
    let curve = basis_curve3(
        model,
        units,
        placement,
        expression.basis_curve,
        parameter_requested,
    )?;

    // `IfcCurveMeasureSelect` says which method of measurement the file
    // means. Carry that across rather than collapsing it to a number: a
    // parameter passed as a distance places the product plausibly wrong.
    let at = match expression.distance_along {
        CurveMeasure::Length(value) => KernelMeasure::Distance(units.length(value)),
        CurveMeasure::Parameter(value) => KernelMeasure::Parameter(value),
    };

    let frame = evaluator
        .frame_at(&curve, at)
        .map_err(|error| GeometryError::Unsupported {
            entity: placement,
            type_name: "IFCLINEARPLACEMENT".into(),
            detail: refusal_detail(&error),
        })?;

    Ok(offset_frame(frame, expression, units))
}

/// The basis curve as a neutral `Curve3`.
///
/// An alignment centreline is the case that matters: `IfcGradientCurve`
/// pairs a plan with a vertical profile, which `ifc-alignment` already
/// composes exactly. Straight-segment curves (`IfcPolyline`, a line-only
/// `IfcIndexedPolyCurve`) lower to the neutral polyline, whose arc length is
/// an exact finite sum. Other curve families -- an ellipse, a B-spline -- are
/// refused by name here rather than lowered approximately, because a
/// placement derived from a curve we guessed at is worse than one we
/// declined to derive.
fn basis_curve3(
    model: &Model,
    units: &UnitScale,
    placement: EntityId,
    basis: EntityId,
    parameter_requested: bool,
) -> GeometryResult<Curve3> {
    let entity = model.get(basis).ok_or(GeometryError::MissingEntity {
        referrer: placement,
        missing: basis,
    })?;
    let alignment_units = AlignmentUnits {
        length_to_metres: units.length_to_metres,
        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
    // file that uses one is not less valid than one that uses the other.
    match entity.type_name.as_ref() {
        // The curve representation lowers exactly through the same path a
        // representation item takes. `ifc_alignment::gradient_curve3` reads an
        // `IfcAlignment` only, so handing it the curve always refused.
        // Refused before lowering: the answer does not depend on the curve.
        "IFCGRADIENTCURVE" | "IFCALIGNMENT" if parameter_requested => {
            Err(GeometryError::Unsupported {
                entity: basis,
                type_name: entity.type_name.to_string(),
                detail: UNDEFINED_ALIGNMENT_PARAMETER,
            })
        }
        "IFCGRADIENTCURVE" => gradient_curve(model, units, basis),
        "IFCALIGNMENT" => {
            ifc_alignment::gradient_curve3(model, basis, alignment_units).map_err(|_error| {
                GeometryError::Unsupported {
                    entity: placement,
                    type_name: entity.type_name.to_string(),
                    detail: "basis curve does not compose an exact centreline",
                }
            })
        }
        "IFCPOLYLINE" => polyline::polyline(model, units, basis, entity),
        "IFCINDEXEDPOLYCURVE" => {
            polyline::indexed_polycurve(model, units, basis, entity, parameter_requested)
        }
        other => Err(GeometryError::Unsupported {
            entity: placement,
            type_name: other.to_owned(),
            detail: "deriving a placement frame needs an alignment centreline or a \
                     straight-segment polyline as basis curve",
        }),
    }
}

/// Why an `IfcParameterValue` along an alignment centreline is refused.
///
/// See the module documentation for the IFC4.3 ADD2 clauses.
const UNDEFINED_ALIGNMENT_PARAMETER: &str =
    "an IfcParameterValue DistanceAlong on an alignment centreline: the parameter \
     space of a composite of IfcCurveSegments is undefined in IFC4.3 ADD2; state an \
     IfcLengthMeasure";

/// An `IfcGradientCurve` basis curve as its exact `Curve3::Elevated`, in metres.
///
/// A refusal names the gradient curve (or the nested entity) and its own
/// reason, such as a vertical arc with no neutral elevation law.
fn gradient_curve(model: &Model, units: &UnitScale, basis: EntityId) -> GeometryResult<Curve3> {
    let mut session = LoweringSession::new(model, units);
    let root = lower_curve_node(&mut session, basis, Transform::identity())?;
    let lowered = session.finish(root)?;
    match lowered.graph.get(lowered.root) {
        Some(GeometryNode::Curve3(curve)) => Ok(curve.clone()),
        _ => Err(GeometryError::Unsupported {
            entity: basis,
            type_name: "IFCGRADIENTCURVE".into(),
            detail: "the gradient curve did not lower to a single neutral Curve3",
        }),
    }
}

/// Apply the authored lateral, vertical and longitudinal offsets.
///
/// The frame axes carry the convention: `x` is the tangent, `z` is right,
/// `y` is up. Offsets are applied along those axes, so a lateral offset
/// moves across the carriageway regardless of heading.
fn offset_frame(frame: Frame3, expression: &PointByDistance, units: &UnitScale) -> Transform {
    let lateral = units.length(expression.offset_lateral.unwrap_or(0.0));
    let vertical = units.length(expression.offset_vertical.unwrap_or(0.0));
    let longitudinal = units.length(expression.offset_longitudinal.unwrap_or(0.0));

    let origin = [
        frame.origin.x + frame.z.x * lateral + frame.y.x * vertical + frame.x.x * longitudinal,
        frame.origin.y + frame.z.y * lateral + frame.y.y * vertical + frame.x.y * longitudinal,
        frame.origin.z + frame.z.z * lateral + frame.y.z * vertical + frame.x.z * longitudinal,
    ];
    Transform {
        basis: [
            [frame.x.x, frame.x.y, frame.x.z],
            [frame.y.x, frame.y.y, frame.y.z],
            [frame.z.x, frame.z.y, frame.z.z],
        ],
        origin,
    }
}

/// Name why the evaluator declined.
///
/// The kernel distinguishes an unsupported operation from a rejected
/// measure and from a degenerate curve, and that difference is actionable:
/// the first means the file needs a different curve family, the others mean
/// the placement itself is unusable. Collapsing them to one message would
/// hide that. The reference evaluator reports an undefined roll (tangent
/// parallel to the up reference) and a distance past the curve's end as
/// invalid input, and a degenerate curve piece as degenerate.
fn refusal_detail(error: &GeomError) -> &'static str {
    match error {
        GeomError::Unsupported { .. } | GeomError::UnsupportedInput { .. } => {
            "the evaluator cannot measure distance on this curve family"
        }
        GeomError::InvalidInput(_) => {
            "the evaluator rejected the measure: it lies off the curve, or roll is \
             undefined because the curve tangent is parallel to the up reference"
        }
        GeomError::Degenerate(_) => "the evaluator found the basis curve degenerate there",
        _ => "the evaluator refused this placement",
    }
}