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