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