Skip to main content

ifc_geometry/lower/
surface.rs

1//! Surface lowering: the LOW-EXACT surface half.
2//!
3//! # Scope
4//!
5//! Covers the surface families implemented by this adapter: elementary,
6//! swept, bounded, and the explicit-knot `IfcBSplineSurfaceWithKnots` /
7//! `IfcRationalBSplineSurfaceWithKnots` subtypes. Convention-only
8//! `IfcBSplineSurface` remains unsupported because lowering it would require
9//! inventing a knot convention absent from the record. B-splines are lowered
10//! as neutral values only; evaluation remains an Axiolid provider concern.
11//!
12//! # `Depth` is a hint, not a bound
13//!
14//! `IfcSurfaceOfLinearExtrusion` carries a `Depth`, but the surface it
15//! defines is **unbounded** in the extrusion parameter: the schema's own
16//! definition sweeps the curve infinitely and `Depth` exists so a viewer can
17//! draw something finite. The neutral `SurfaceRelation::LinearExtrusion`
18//! therefore has no depth field.
19//!
20//! Scaling the direction by `Depth` to "keep" the information would change
21//! the surface's parameterisation: a point at parameter `v` would move to
22//! `v * depth`, silently reparameterising every trim taken against this
23//! surface. The direction is lowered as a unit-magnitude direction and the
24//! depth is deliberately dropped, which is lossy in exactly the way the
25//! schema intends.
26
27use axiolid_core::Vec3;
28use axiolid_curve::KnotSpec;
29use axiolid_model::{GeometryNode, NodeId, SurfaceRelation};
30use axiolid_surface::{
31    BSplineSurface as KernelBSpline, Cylinder, Plane as KernelPlane, Sphere, Surface, Torus,
32};
33use ifc_model::EntityId;
34
35use crate::curve::bspline::KnotType;
36use crate::error::GeometryResult;
37use crate::lower::curve::{finite_values, lower_curve_node};
38use crate::lower::session::LoweringSession;
39use crate::resource::direction::resolve_unit;
40use crate::resource::placement::axis_placement_transform;
41use crate::resource::placement::Axis1Placement;
42use crate::resource::point::CartesianPoint;
43use crate::surface::bounded::{CurveBoundedPlane, CurveBoundedSurface, RectangularTrimmedSurface};
44use crate::surface::bspline::BSplineSurface;
45use crate::surface::elementary::{CylindricalSurface, Plane, SphericalSurface, ToroidalSurface};
46use crate::surface::swept::SurfaceOfLinearExtrusion;
47use crate::surface::swept::SurfaceOfRevolution;
48use crate::transform::Transform;
49
50/// Family label used for surface memoization.
51const SURFACE: &str = "surface";
52
53/// Lower any supported `IfcSurface` into a node.
54///
55/// Kept as one entry point so a caller holding only an `IfcSurface` reference
56/// (a half space's `BaseSurface`, a swept solid's `ReferenceSurface`) does not
57/// have to re-dispatch on the concrete subtype itself.
58pub fn lower_surface_node(
59    session: &mut LoweringSession<'_>,
60    id: EntityId,
61    frame: Transform,
62) -> GeometryResult<NodeId> {
63    if let Some(existing) = session.memoized(id, SURFACE, frame) {
64        return Ok(existing);
65    }
66    session.enter(id, SURFACE)?;
67    let result = (|| -> GeometryResult<NodeId> {
68        let type_name = session.type_name(id)?.to_ascii_uppercase();
69        match type_name.as_str() {
70            "IFCPLANE" => lower_plane(session, id, frame),
71            "IFCSURFACEOFLINEAREXTRUSION" => lower_linear_extrusion(session, id, frame),
72            "IFCCYLINDRICALSURFACE" => lower_cylinder(session, id, frame),
73            "IFCSPHERICALSURFACE" => lower_sphere(session, id, frame),
74            "IFCTOROIDALSURFACE" => lower_torus(session, id, frame),
75            "IFCBSPLINESURFACEWITHKNOTS" | "IFCRATIONALBSPLINESURFACEWITHKNOTS" => {
76                lower_bspline(session, id, frame)
77            }
78            "IFCSURFACEOFREVOLUTION" => lower_revolution(session, id, frame),
79            "IFCRECTANGULARTRIMMEDSURFACE" => lower_rectangular_trimmed(session, id, frame),
80            "IFCCURVEBOUNDEDPLANE" => lower_curve_bounded(session, id, frame),
81            "IFCCURVEBOUNDEDSURFACE" => lower_curve_bounded_surface(session, id, frame),
82            "IFCSECTIONEDSURFACE" => {
83                crate::lower::sectioned::lower_sectioned_surface_node(session, id)
84            }
85            other => Err(session.unsupported(id, other, "curved and B-spline surfaces")),
86        }
87    })();
88    session.exit(id);
89    let node = result?;
90    session.memoize(id, SURFACE, frame, node);
91    Ok(node)
92}
93
94/// Lower an `IfcPlane` into a kernel plane.
95///
96/// The placement's Z axis is the normal and its origin the reference point;
97/// both are placed by `frame` and the origin converted to metres. The normal
98/// takes the linear part only, so an off-origin plane keeps its orientation.
99pub fn lower_plane(
100    session: &mut LoweringSession<'_>,
101    id: EntityId,
102    frame: Transform,
103) -> GeometryResult<NodeId> {
104    let entity = session.entity(id, id)?;
105    let view = Plane::new(id, entity);
106    let position_id = view.position_ref()?;
107    let position = session.entity(id, position_id)?;
108    let local = axis_placement_transform(session.model(), position_id, position)?
109        .to_metres(session.units());
110    let placed = frame.compose(&local);
111
112    let normal = placed.apply_direction([0.0, 0.0, 1.0]);
113    let length = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
114    if !length.is_finite() || length <= f64::EPSILON {
115        return Err(session.degenerate(
116            id,
117            "IFCPLANE",
118            "plane normal is zero-length or non-finite",
119        ));
120    }
121
122    let plane = KernelPlane {
123        frame: placed.to_geom_frame(id)?,
124    };
125    session.node_for(id, GeometryNode::Surface(Surface::Plane(plane)))
126}
127
128/// Lower an `IfcSurfaceOfLinearExtrusion` into a linear-extrusion relation.
129///
130/// `Depth` is intentionally not carried: see the module docs. The swept curve
131/// is lowered first so the relation can reference its node.
132pub fn lower_linear_extrusion(
133    session: &mut LoweringSession<'_>,
134    id: EntityId,
135    frame: Transform,
136) -> GeometryResult<NodeId> {
137    let entity = session.entity(id, id)?;
138    let view = SurfaceOfLinearExtrusion::new(id, entity);
139
140    // An optional Position places the whole swept surface.
141    let placed = match view.position_ref() {
142        Some(position_id) => {
143            let position = session.entity(id, position_id)?;
144            let local = axis_placement_transform(session.model(), position_id, position)?
145                .to_metres(session.units());
146            frame.compose(&local)
147        }
148        None => frame,
149    };
150
151    let curve_id = view.swept_curve_ref()?;
152    let swept_curve = swept_generatrix(session, id, curve_id, placed)?;
153
154    // ExtrudedDirection is already unit: `resolve_unit` normalizes at the IFC
155    // boundary, which is where this crate's contract says directions are
156    // normalized exactly once. Under a rigid placement `apply_direction`
157    // preserves that, so re-normalizing here would be dead code. Only the
158    // degenerate case still needs rejecting.
159    let raw = resolve_unit(session.model(), id, view.extruded_direction_ref()?)?;
160    let direction = placed.apply_direction(raw);
161    let length =
162        (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
163            .sqrt();
164    if !length.is_finite() || length <= f64::EPSILON {
165        return Err(session.degenerate(
166            id,
167            "IFCSURFACEOFLINEAREXTRUSION",
168            "extruded direction is zero-length or non-finite",
169        ));
170    }
171
172    session.node_for(
173        id,
174        GeometryNode::SurfaceRelation(SurfaceRelation::LinearExtrusion {
175            swept_curve,
176            direction: Vec3::from_array(direction),
177        }),
178    )
179}
180
181#[cfg(test)]
182mod tests;
183
184/// Compose an entity's `Position` with the caller's frame, in metres.
185///
186/// Every elementary surface places itself the same way; sharing this keeps the
187/// unit conversion in exactly one place. A radius converted with a different
188/// factor than its own frame origin puts the surface somewhere the file never
189/// described.
190fn placed_frame(
191    session: &mut LoweringSession<'_>,
192    owner: EntityId,
193    position_id: EntityId,
194    frame: Transform,
195) -> GeometryResult<axiolid_core::Frame3> {
196    let position = session.entity(owner, position_id)?;
197    let local = axis_placement_transform(session.model(), position_id, position)?
198        .to_metres(session.units());
199    frame.compose(&local).to_geom_frame(owner)
200}
201
202/// Convert one length-valued scalar into metres.
203fn to_metres(session: &LoweringSession<'_>, value: f64) -> f64 {
204    value * session.units().length_to_metres
205}
206
207/// Choose the unit factor for a trim parameter by basis-surface family.
208///
209/// A revolved or conic direction is parameterised by angle; a plane by length.
210/// Returning a closure keeps the decision at the one place that knows the
211/// basis type, instead of leaking a bool to every caller.
212pub(crate) fn trim_converter(
213    session: &LoweringSession<'_>,
214    basis_kind: &str,
215) -> impl Fn(f64) -> f64 {
216    let angular = matches!(
217        basis_kind,
218        "IFCCYLINDRICALSURFACE"
219            | "IFCSPHERICALSURFACE"
220            | "IFCTOROIDALSURFACE"
221            | "IFCSURFACEOFREVOLUTION"
222    );
223    let factor = if angular {
224        session.units().angle_to_radians
225    } else {
226        session.units().length_to_metres
227    };
228    move |value: f64| value * factor
229}
230
231/// Read an `IfcCartesianPoint`'s three coordinates, as written.
232fn cartesian_point_3d(
233    session: &LoweringSession<'_>,
234    referrer: EntityId,
235    id: EntityId,
236) -> GeometryResult<[f64; 3]> {
237    let entity = session.entity(referrer, id)?;
238    let point = CartesianPoint::new(id, entity);
239    point.coordinates_3d()
240}
241
242/// Lower an `IfcCylindricalSurface` into a kernel cylinder.
243///
244/// The radius is a length and converts to metres; the frame axes do not.
245/// Scaling the axes as well would turn a unit basis into a millimetre-long
246/// one and every parameter measured against it would be off by that factor.
247pub fn lower_cylinder(
248    session: &mut LoweringSession<'_>,
249    id: EntityId,
250    frame: Transform,
251) -> GeometryResult<NodeId> {
252    let entity = session.entity(id, id)?;
253    let view = CylindricalSurface::new(id, entity);
254    let placed = placed_frame(session, id, view.position_ref()?, frame)?;
255    let radius = to_metres(session, view.radius()?);
256    session.node_for(
257        id,
258        GeometryNode::Surface(Surface::Cylinder(Cylinder {
259            frame: placed,
260            radius,
261        })),
262    )
263}
264
265/// Lower an `IfcSphericalSurface` into a kernel sphere.
266pub fn lower_sphere(
267    session: &mut LoweringSession<'_>,
268    id: EntityId,
269    frame: Transform,
270) -> GeometryResult<NodeId> {
271    let entity = session.entity(id, id)?;
272    let view = SphericalSurface::new(id, entity);
273    let placed = placed_frame(session, id, view.position_ref()?, frame)?;
274    let radius = to_metres(session, view.radius()?);
275    session.node_for(
276        id,
277        GeometryNode::Surface(Surface::Sphere(Sphere {
278            frame: placed,
279            radius,
280        })),
281    )
282}
283
284/// Lower an `IfcToroidalSurface` into a kernel torus.
285///
286/// Both radii are lengths. A torus with `minor >= major` self-intersects into
287/// a spindle; that is legal IFC and the reader exposes it, so it is preserved
288/// rather than rejected -- discarding it would silently change the shape.
289pub fn lower_torus(
290    session: &mut LoweringSession<'_>,
291    id: EntityId,
292    frame: Transform,
293) -> GeometryResult<NodeId> {
294    let entity = session.entity(id, id)?;
295    let view = ToroidalSurface::new(id, entity);
296    let placed = placed_frame(session, id, view.position_ref()?, frame)?;
297    let major_radius = to_metres(session, view.major_radius()?);
298    let minor_radius = to_metres(session, view.minor_radius()?);
299    session.node_for(
300        id,
301        GeometryNode::Surface(Surface::Torus(Torus {
302            frame: placed,
303            major_radius,
304            minor_radius,
305        })),
306    )
307}
308
309/// Map the IFC knot enumeration onto the kernel's.
310///
311/// The variants correspond one to one, so this is a rename, not a decision.
312fn knot_spec(source: KnotType) -> KnotSpec {
313    match source {
314        KnotType::Uniform => KnotSpec::Uniform,
315        KnotType::QuasiUniform => KnotSpec::QuasiUniform,
316        KnotType::PiecewiseBezier => KnotSpec::PiecewiseBezier,
317        KnotType::Unspecified => KnotSpec::Unspecified,
318    }
319}
320
321/// Lower an `IfcBSplineSurfaceWithKnots` into a kernel B-spline patch.
322///
323/// The control net stays row-major in `u` then `v`, matching both the IFC
324/// `ControlPointsList` and the kernel field. A transposed net still evaluates
325/// and still looks like a surface, so the per-direction degrees, knots and
326/// multiplicities are carried through verbatim rather than inferred from the
327/// net's shape.
328///
329/// `WeightsData` is present only on the rational subtype. Defaulting absent
330/// weights to 1.0 would turn a polynomial patch into a rational one that
331/// happens to agree, so `None` is preserved as `None`.
332pub fn lower_bspline(
333    session: &mut LoweringSession<'_>,
334    id: EntityId,
335    frame: Transform,
336) -> GeometryResult<NodeId> {
337    let entity = session.entity(id, id)?;
338    let view = BSplineSurface::new(id, entity);
339    let type_name = session.type_name(id)?;
340    let u_degree = u16::try_from(view.u_degree()?)
341        .map_err(|_| session.degenerate(id, &type_name, "UDegree exceeds u16"))?;
342    let v_degree = u16::try_from(view.v_degree()?)
343        .map_err(|_| session.degenerate(id, &type_name, "VDegree exceeds u16"))?;
344
345    // Budget the declared aggregate sizes BEFORE materializing anything. The
346    // knot totals are cheap sums of file integers, so they are checked first;
347    // the grid check then bounds the loop below, which is what actually
348    // allocates. Doing this after the loop would defeat the purpose.
349    let u = view.u_knots()?.ok_or_else(|| {
350        session.unsupported(id, &type_name, "B-spline surface without explicit u knots")
351    })?;
352    let v = view.v_knots()?.ok_or_else(|| {
353        session.unsupported(id, &type_name, "B-spline surface without explicit v knots")
354    })?;
355    let u_declared: u128 = u.multiplicities.iter().map(|m| *m as u128).sum();
356    session.check_aggregate(id, &type_name, "u knot multiplicities", u_declared)?;
357    let v_declared: u128 = v.multiplicities.iter().map(|m| *m as u128).sum();
358    session.check_aggregate(id, &type_name, "v knot multiplicities", v_declared)?;
359
360    let grid = view.control_points()?;
361    // The control grid is u_count * v_count points. Both come from the file,
362    // so compute the product in u128: a wrapped usize would pass the budget.
363    session.check_aggregate(
364        id,
365        &type_name,
366        "control grid points",
367        grid.u_count() as u128 * grid.v_count() as u128,
368    )?;
369    let mut control_points = Vec::with_capacity(grid.u_count());
370    for row in grid.rows() {
371        let mut out_row = Vec::with_capacity(row.len());
372        for point_id in row {
373            let raw = cartesian_point_3d(session, id, *point_id)?;
374            let placed = frame.apply(raw.map(|value| to_metres(session, value)));
375            finite_values(
376                session,
377                id,
378                &type_name,
379                "transformed control point",
380                &placed,
381            )?;
382            out_row.push(axiolid_core::Point3::from_array(placed));
383        }
384        control_points.push(out_row);
385    }
386
387    finite_values(session, id, &type_name, "UKnots", &u.values)?;
388    finite_values(session, id, &type_name, "VKnots", &v.values)?;
389    let u_multiplicities = multiplicities(session, id, &type_name, u.multiplicities)?;
390    let v_multiplicities = multiplicities(session, id, &type_name, v.multiplicities)?;
391    let weights = view.weights()?;
392    if let Some(rows) = weights.as_ref() {
393        for row in rows {
394            finite_values(session, id, &type_name, "WeightsData", row)?;
395        }
396    }
397    let u_closed = view.u_closed().ok_or_else(|| {
398        session.unsupported(id, &type_name, "unknown UClosed is not lossless in bool")
399    })?;
400    let v_closed = view.v_closed().ok_or_else(|| {
401        session.unsupported(id, &type_name, "unknown VClosed is not lossless in bool")
402    })?;
403
404    let surface = KernelBSpline {
405        u_degree,
406        v_degree,
407        control_points,
408        u_knots: u.values,
409        u_multiplicities,
410        v_knots: v.values,
411        v_multiplicities,
412        weights,
413        u_closed,
414        v_closed,
415        self_intersect: view.self_intersect(),
416        knot_spec: knot_spec(view.knot_spec()),
417    };
418    session.node_for(id, GeometryNode::Surface(Surface::BSpline(surface)))
419}
420
421fn multiplicities(
422    session: &LoweringSession<'_>,
423    id: EntityId,
424    type_name: &str,
425    values: Vec<usize>,
426) -> GeometryResult<Vec<u32>> {
427    values
428        .into_iter()
429        .map(|value| {
430            u32::try_from(value)
431                .map_err(|_| session.degenerate(id, type_name, "multiplicity exceeds u32"))
432        })
433        .collect()
434}
435
436/// Resolve a swept surface's `SweptCurve` to the curve it actually names.
437///
438/// IFC types this slot as `IfcProfileDef`, so a plain curve arrives wrapped in
439/// an `IfcArbitraryOpenProfileDef`. The wrapper carries no geometry of its own
440/// here -- for a swept SURFACE the profile is a generatrix, not an area -- so
441/// it is unwrapped rather than lowered as a profile. Lowering it as one would
442/// demand a closed contour and reject a legitimately open generatrix.
443fn swept_generatrix(
444    session: &mut LoweringSession<'_>,
445    _owner: EntityId,
446    swept_curve: EntityId,
447    frame: Transform,
448) -> GeometryResult<NodeId> {
449    let kind = session.type_name(swept_curve)?.to_ascii_uppercase();
450    let curve_id = if kind == "IFCARBITRARYOPENPROFILEDEF" || kind == "IFCARBITRARYCLOSEDPROFILEDEF"
451    {
452        // Slot 2 is Curve on IfcArbitraryOpenProfileDef and OuterCurve on
453        // the closed form; both name the generatrix.
454        session.slots(swept_curve)?.req_ref(2, "Curve")?
455    } else {
456        swept_curve
457    };
458    lower_curve_node(session, curve_id, frame)
459}
460
461/// Lower an `IfcSurfaceOfRevolution` into a revolution relation.
462///
463/// `AxisPosition` is an `IfcAxis1Placement`, not the `IfcAxis2Placement3D`
464/// every other surface family uses. Both carry a Location, so reading the
465/// wrong one still yields a surface -- just one revolved about the wrong line.
466/// The axis origin is a length and converts; the direction is placed by the
467/// linear part only so an off-origin frame does not translate it.
468pub fn lower_revolution(
469    session: &mut LoweringSession<'_>,
470    id: EntityId,
471    frame: Transform,
472) -> GeometryResult<NodeId> {
473    let entity = session.entity(id, id)?;
474    let view = SurfaceOfRevolution::new(id, entity);
475
476    let swept_curve = swept_generatrix(session, id, view.swept_curve_ref()?, frame)?;
477
478    let axis_id = view.axis_position_ref()?;
479    let axis_entity = session.entity(id, axis_id)?;
480    let axis = Axis1Placement::new(axis_id, axis_entity);
481    let raw_origin = axis.location(session.model())?;
482    let origin = frame.apply([
483        to_metres(session, raw_origin[0]),
484        to_metres(session, raw_origin[1]),
485        to_metres(session, raw_origin[2]),
486    ]);
487    let direction = frame.apply_direction(axis.axis(session.model())?);
488
489    session.node_for(
490        id,
491        GeometryNode::SurfaceRelation(SurfaceRelation::Revolution {
492            swept_curve,
493            axis_origin: axiolid_core::Point3::from_array(origin),
494            axis_direction: axiolid_core::Vec3::from_array(direction),
495        }),
496    )
497}
498
499/// Lower an `IfcRectangularTrimmedSurface` into a parameter-space trim.
500///
501/// The trim parameters are NOT lengths. On a conic or revolved direction they
502/// are angles in the model's plane-angle unit, which is degrees in many real
503/// exports; on a plane they are lengths. Scaling everything by the length
504/// factor turns a 90-degree patch into a sliver and still renders.
505pub fn lower_rectangular_trimmed(
506    session: &mut LoweringSession<'_>,
507    id: EntityId,
508    frame: Transform,
509) -> GeometryResult<NodeId> {
510    let entity = session.entity(id, id)?;
511    let view = RectangularTrimmedSurface::new(id, entity);
512    let basis_id = view.basis_surface_ref()?;
513    let basis = lower_surface_node(session, basis_id, frame)?;
514    let rect = view.rectangle()?;
515
516    let basis_kind = session.type_name(basis_id)?.to_ascii_uppercase();
517    let convert = trim_converter(session, &basis_kind);
518
519    session.node_for(
520        id,
521        GeometryNode::SurfaceRelation(SurfaceRelation::RectangularTrimmed {
522            basis,
523            u: (convert(rect.u1), convert(rect.u2)),
524            v: (convert(rect.v1), convert(rect.v2)),
525            u_sense: rect.usense,
526            v_sense: rect.vsense,
527        }),
528    )
529}
530
531/// Lower an `IfcCurveBoundedPlane` into a curve-bounded relation.
532///
533/// The outer boundary is explicit here, unlike `IfcCurveBoundedSurface` where
534/// it may be implicit, so `implicit_outer` is false and the outer curve leads
535/// the boundary list.
536///
537/// Only the basis plane takes `frame`. The boundaries are authored in the
538/// plane's own parameter space, where a point `(u, v)` lies at `u` along the
539/// plane's x axis and `v` along its y axis from its `Location`. The neutral
540/// relation reads them the same way, so the plane's placement already carries
541/// them to the frame. Placing them too moved every boundary a second time
542/// (#163).
543pub fn lower_curve_bounded(
544    session: &mut LoweringSession<'_>,
545    id: EntityId,
546    frame: Transform,
547) -> GeometryResult<NodeId> {
548    let entity = session.entity(id, id)?;
549    let view = CurveBoundedPlane::new(id, entity);
550    let basis = lower_surface_node(session, view.basis_surface_ref()?, frame)?;
551
552    let parameter_space = Transform::identity();
553    let mut boundaries = vec![lower_curve_node(
554        session,
555        view.outer_boundary_ref()?,
556        parameter_space,
557    )?];
558    for inner in view.inner_boundary_refs() {
559        boundaries.push(lower_curve_node(session, inner, parameter_space)?);
560    }
561
562    session.node_for(
563        id,
564        GeometryNode::SurfaceRelation(SurfaceRelation::CurveBounded {
565            basis,
566            boundaries,
567            implicit_outer: false,
568        }),
569    )
570}
571
572/// Lower a generic `IfcCurveBoundedSurface`, preserving boundary order and
573/// whether the source omitted an explicit outer boundary.
574pub fn lower_curve_bounded_surface(
575    session: &mut LoweringSession<'_>,
576    id: EntityId,
577    frame: Transform,
578) -> GeometryResult<NodeId> {
579    let entity = session.entity(id, id)?;
580    let view = CurveBoundedSurface::new(id, entity);
581    let basis = lower_surface_node(session, view.basis_surface_ref()?, frame)?;
582    let refs = view.boundary_refs()?;
583    let mut boundaries = Vec::with_capacity(refs.len());
584    for boundary in refs {
585        boundaries.push(lower_curve_node(session, boundary, frame)?);
586    }
587
588    session.node_for(
589        id,
590        GeometryNode::SurfaceRelation(SurfaceRelation::CurveBounded {
591            basis,
592            boundaries,
593            implicit_outer: view.implicit_outer(),
594        }),
595    )
596}