Skip to main content

ifc_geometry/surface/
bspline.rs

1//! `IfcBSplineSurface` and its knotted / rational refinements.
2//!
3//! # The same job as [`crate::curve::bspline`], one dimension up
4//!
5//! Read the parameters, check the invariants, evaluate nothing. What changes
6//! in 2D is that every invariant now has a u form and a v form, and the
7//! control points are a *grid* rather than a list. The grid is where files go
8//! wrong: `ControlPointsList` is `LIST OF LIST OF IfcCartesianPoint`, and a
9//! ragged inner list means the surface is not a tensor product at all.
10//!
11//! # Which index is which
12//!
13//! `ControlPointsList[i][j]` has `i` running along **u** and `j` along **v**.
14//! So the outer list length is the u control point count and the inner length
15//! the v count. Transposing them yields a surface that is plausible, is not
16//! the one in the file, and passes every count check because the two knot
17//! vectors are usually the same length in test data. This module's accessors
18//! are named `u_*` and `v_*` for exactly that reason, and the row/column
19//! convention is asserted in the tests.
20//!
21//! # Invariants checked here
22//!
23//! - Control point grid is rectangular (no ragged rows).
24//! - `sum(UMultiplicities) = u control points + UDegree + 1`, and the v form.
25//! - `UKnots` / `UMultiplicities` are parallel lists, and the v form.
26//! - Weights form a grid of the same shape as the control points, all positive.
27
28use crate::curve::bspline::{KnotType, KnotVector};
29use crate::error::GeometryResult;
30use crate::slots::Slots;
31use ifc_model::{Entity, EntityId, Value};
32
33/// `IfcBSplineSurface` family attribute slots.
34///
35/// From IFC4 ADD2 TC1. Slots 0-6 come from `IfcBSplineSurface`, 7-11 from
36/// `IfcBSplineSurfaceWithKnots`, and 12 from
37/// `IfcRationalBSplineSurfaceWithKnots`.
38pub(crate) mod slot {
39    /// `UDegree`: `IfcInteger`.
40    pub const U_DEGREE: usize = 0;
41    /// `VDegree`: `IfcInteger`.
42    pub const V_DEGREE: usize = 1;
43    /// `ControlPointsList`: `LIST OF LIST OF IfcCartesianPoint`, u outer.
44    pub const CONTROL_POINTS: usize = 2;
45    /// `SurfaceForm`: `IfcBSplineSurfaceForm`.
46    pub const SURFACE_FORM: usize = 3;
47    /// `UClosed`: `IfcLogical`.
48    pub const U_CLOSED: usize = 4;
49    /// `VClosed`: `IfcLogical`.
50    pub const V_CLOSED: usize = 5;
51    /// `SelfIntersect`: `IfcLogical`.
52    pub const SELF_INTERSECT: usize = 6;
53    /// `UMultiplicities`, from `IfcBSplineSurfaceWithKnots`.
54    pub const U_MULTIPLICITIES: usize = 7;
55    /// `VMultiplicities`, from `IfcBSplineSurfaceWithKnots`.
56    pub const V_MULTIPLICITIES: usize = 8;
57    /// `UKnots`, from `IfcBSplineSurfaceWithKnots`.
58    pub const U_KNOTS: usize = 9;
59    /// `VKnots`, from `IfcBSplineSurfaceWithKnots`.
60    pub const V_KNOTS: usize = 10;
61    /// `KnotSpec`: `IfcKnotType`, from `IfcBSplineSurfaceWithKnots`.
62    pub const KNOT_SPEC: usize = 11;
63    /// `WeightsData`, from `IfcRationalBSplineSurfaceWithKnots`.
64    pub const WEIGHTS_DATA: usize = 12;
65}
66
67/// `IfcBSplineSurfaceForm`: what shape the surface originally was.
68///
69/// Informational only, exactly like [`crate::curve::BSplineCurveForm`]. A
70/// `CYLINDRICAL_SURF` form is not a licence to substitute an
71/// `IfcCylindricalSurface`: the control points are what the file actually
72/// means.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum BSplineSurfaceForm {
75    /// Originally a plane.
76    PlaneSurf,
77    /// Originally a cylinder.
78    CylindricalSurf,
79    /// Originally a cone.
80    ConicalSurf,
81    /// Originally a sphere.
82    SphericalSurf,
83    /// Originally a torus.
84    ToroidalSurf,
85    /// Originally a surface of revolution.
86    SurfOfRevolution,
87    /// Originally a ruled surface.
88    RuledSurf,
89    /// Originally a generalised cone.
90    GeneralisedCone,
91    /// Originally a quadric.
92    QuadricSurf,
93    /// Originally a linear extrusion.
94    SurfOfLinearExtrusion,
95    /// No original form is claimed.
96    Unspecified,
97}
98
99impl BSplineSurfaceForm {
100    /// Parse the enumeration token, `None` if unrecognised.
101    pub fn from_token(token: &str) -> Option<Self> {
102        match token.to_ascii_uppercase().as_str() {
103            "PLANE_SURF" => Some(Self::PlaneSurf),
104            "CYLINDRICAL_SURF" => Some(Self::CylindricalSurf),
105            "CONICAL_SURF" => Some(Self::ConicalSurf),
106            "SPHERICAL_SURF" => Some(Self::SphericalSurf),
107            "TOROIDAL_SURF" => Some(Self::ToroidalSurf),
108            "SURF_OF_REVOLUTION" => Some(Self::SurfOfRevolution),
109            "RULED_SURF" => Some(Self::RuledSurf),
110            "GENERALISED_CONE" => Some(Self::GeneralisedCone),
111            "QUADRIC_SURF" => Some(Self::QuadricSurf),
112            "SURF_OF_LINEAR_EXTRUSION" => Some(Self::SurfOfLinearExtrusion),
113            "UNSPECIFIED" => Some(Self::Unspecified),
114            _ => None,
115        }
116    }
117}
118
119/// The control point grid, `[u][v]`.
120///
121/// A distinct type rather than a bare `Vec<Vec<EntityId>>` so the u/v
122/// convention is stated once and the rectangularity check cannot be skipped.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct ControlPointGrid {
125    rows: Vec<Vec<EntityId>>,
126}
127
128impl ControlPointGrid {
129    /// Number of control points along u; the outer list length.
130    pub fn u_count(&self) -> usize {
131        self.rows.len()
132    }
133
134    /// Number of control points along v; the inner list length.
135    pub fn v_count(&self) -> usize {
136        self.rows.first().map_or(0, Vec::len)
137    }
138
139    /// The control point at `(u_index, v_index)`.
140    pub fn get(&self, u_index: usize, v_index: usize) -> Option<EntityId> {
141        self.rows.get(u_index)?.get(v_index).copied()
142    }
143
144    /// The rows, each a constant-u run of control points.
145    pub fn rows(&self) -> &[Vec<EntityId>] {
146        &self.rows
147    }
148}
149
150/// A borrowed view of any `IfcBSplineSurface` subtype.
151#[derive(Debug, Clone, Copy)]
152pub struct BSplineSurface<'m> {
153    slots: Slots<'m>,
154}
155
156impl<'m> BSplineSurface<'m> {
157    /// Wrap an entity known to be an `IfcBSplineSurface` subtype.
158    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
159        Self {
160            slots: Slots::new(id, entity),
161        }
162    }
163
164    /// The entity id.
165    pub fn id(&self) -> EntityId {
166        self.slots.id()
167    }
168
169    /// The u polynomial degree, guaranteed to fit the u control count.
170    pub fn u_degree(&self) -> GeometryResult<usize> {
171        self.degree(slot::U_DEGREE, "UDegree", self.control_points()?.u_count())
172    }
173
174    /// The v polynomial degree, guaranteed to fit the v control count.
175    pub fn v_degree(&self) -> GeometryResult<usize> {
176        self.degree(slot::V_DEGREE, "VDegree", self.control_points()?.v_count())
177    }
178
179    /// The control point grid, checked to be rectangular.
180    ///
181    /// A ragged grid is not a tensor-product surface, so it cannot be
182    /// evaluated at all; catching it here beats an out-of-bounds read in a
183    /// kernel's inner loop.
184    // TODO: `resource::point` will provide a typed point view to resolve these.
185    pub fn control_points(&self) -> GeometryResult<ControlPointGrid> {
186        let value = self.slots.req(slot::CONTROL_POINTS, "ControlPointsList")?;
187        let outer = value.as_list().ok_or_else(|| {
188            self.slots
189                .degenerate("ControlPointsList must be a list of lists")
190        })?;
191        if outer.len() < 2 {
192            return Err(self.slots.degenerate(format!(
193                "ControlPointsList needs at least 2 rows along u, found {}",
194                outer.len()
195            )));
196        }
197
198        let mut rows: Vec<Vec<EntityId>> = Vec::with_capacity(outer.len());
199        for (u_index, row_value) in outer.iter().enumerate() {
200            let inner = row_value.as_list().ok_or_else(|| {
201                self.slots
202                    .degenerate(format!("ControlPointsList row {u_index} is not a list"))
203            })?;
204            let mut row = Vec::with_capacity(inner.len());
205            for (v_index, point) in inner.iter().enumerate() {
206                let id = point.as_ref_id().ok_or_else(|| {
207                    self.slots.degenerate(format!(
208                        "ControlPointsList[{u_index}][{v_index}] is not an entity reference"
209                    ))
210                })?;
211                row.push(id);
212            }
213            rows.push(row);
214        }
215
216        let v_count = rows[0].len();
217        if v_count < 2 {
218            return Err(self.slots.degenerate(format!(
219                "ControlPointsList needs at least 2 columns along v, found {v_count}"
220            )));
221        }
222        for (u_index, row) in rows.iter().enumerate() {
223            if row.len() != v_count {
224                return Err(self.slots.degenerate(format!(
225                    "ControlPointsList row {u_index} has {} points but row 0 has {v_count}; \
226                     the grid must be rectangular",
227                    row.len()
228                )));
229            }
230        }
231        Ok(ControlPointGrid { rows })
232    }
233
234    /// The declared original form, defaulting to `Unspecified`.
235    pub fn surface_form(&self) -> BSplineSurfaceForm {
236        self.slots
237            .opt_enum(slot::SURFACE_FORM)
238            .and_then(BSplineSurfaceForm::from_token)
239            .unwrap_or(BSplineSurfaceForm::Unspecified)
240    }
241
242    /// The asserted `UClosed` flag; `None` for `.U.` or absent.
243    pub fn u_closed(&self) -> Option<bool> {
244        self.slots.opt_bool(slot::U_CLOSED)
245    }
246
247    /// The asserted `VClosed` flag; `None` for `.U.` or absent.
248    pub fn v_closed(&self) -> Option<bool> {
249        self.slots.opt_bool(slot::V_CLOSED)
250    }
251
252    /// The asserted `SelfIntersect` flag; `None` for `.U.` or absent.
253    pub fn self_intersect(&self) -> Option<bool> {
254        self.slots.opt_bool(slot::SELF_INTERSECT)
255    }
256
257    /// The declared knot type, defaulting to `Unspecified`.
258    pub fn knot_spec(&self) -> KnotType {
259        self.slots
260            .opt_enum(slot::KNOT_SPEC)
261            .and_then(KnotType::from_token)
262            .unwrap_or(KnotType::Unspecified)
263    }
264
265    /// Is this the knotted subtype?
266    pub fn has_knots(&self) -> bool {
267        self.slots.opt(slot::U_KNOTS).is_some()
268    }
269
270    /// Whether the entity declares the rational subtype.
271    pub fn is_rational(&self) -> bool {
272        self.slots
273            .type_name()
274            .eq_ignore_ascii_case("IFCRATIONALBSPLINESURFACEWITHKNOTS")
275    }
276
277    /// The u knot vector, checked against the u control point count.
278    ///
279    /// `Ok(None)` for a surface without knots.
280    pub fn u_knots(&self) -> GeometryResult<Option<KnotVector>> {
281        if !self.has_knots() {
282            return Ok(None);
283        }
284        let expected = self
285            .control_points()?
286            .u_count()
287            .checked_add(self.u_degree()?)
288            .and_then(|value| value.checked_add(1))
289            .ok_or_else(|| self.slots.degenerate("u knot count overflows usize"))?;
290        self.knot_vector(
291            slot::U_KNOTS,
292            "UKnots",
293            slot::U_MULTIPLICITIES,
294            "UMultiplicities",
295            expected,
296        )
297        .map(Some)
298    }
299
300    /// The v knot vector, checked against the v control point count.
301    ///
302    /// `Ok(None)` for a surface without knots.
303    pub fn v_knots(&self) -> GeometryResult<Option<KnotVector>> {
304        if !self.has_knots() {
305            return Ok(None);
306        }
307        let expected = self
308            .control_points()?
309            .v_count()
310            .checked_add(self.v_degree()?)
311            .and_then(|value| value.checked_add(1))
312            .ok_or_else(|| self.slots.degenerate("v knot count overflows usize"))?;
313        self.knot_vector(
314            slot::V_KNOTS,
315            "VKnots",
316            slot::V_MULTIPLICITIES,
317            "VMultiplicities",
318            expected,
319        )
320        .map(Some)
321    }
322
323    /// The weight grid, matching the control point grid exactly.
324    ///
325    /// `Ok(None)` for a non-rational surface. Every weight must be finite and positive:
326    /// a zero divides by zero at that control point and a negative one flips
327    /// the patch through infinity.
328    pub fn weights(&self) -> GeometryResult<Option<Vec<Vec<f64>>>> {
329        let supplied = self.slots.opt(slot::WEIGHTS_DATA).is_some();
330        match (self.is_rational(), supplied) {
331            (false, false) => return Ok(None),
332            (true, false) => {
333                return Err(self
334                    .slots
335                    .degenerate("rational B-spline surface is missing WeightsData"));
336            }
337            (false, true) => {
338                return Err(self
339                    .slots
340                    .degenerate("polynomial B-spline surface must not carry WeightsData"));
341            }
342            (true, true) => {}
343        }
344        let grid = self.control_points()?;
345        let value = self.slots.req(slot::WEIGHTS_DATA, "WeightsData")?;
346        let outer = value
347            .as_list()
348            .ok_or_else(|| self.slots.degenerate("WeightsData must be a list of lists"))?;
349
350        if outer.len() != grid.u_count() {
351            return Err(self.slots.degenerate(format!(
352                "WeightsData has {} rows but the control point grid has {}",
353                outer.len(),
354                grid.u_count()
355            )));
356        }
357
358        let mut weights = Vec::with_capacity(outer.len());
359        for (u_index, row_value) in outer.iter().enumerate() {
360            let inner = row_value.as_list().ok_or_else(|| {
361                self.slots
362                    .degenerate(format!("WeightsData row {u_index} is not a list"))
363            })?;
364            if inner.len() != grid.v_count() {
365                return Err(self.slots.degenerate(format!(
366                    "WeightsData row {u_index} has {} entries but the grid has {}",
367                    inner.len(),
368                    grid.v_count()
369                )));
370            }
371            let mut row = Vec::with_capacity(inner.len());
372            for (v_index, w) in inner.iter().enumerate() {
373                let weight = w.unwrap_typed().as_f64().ok_or_else(|| {
374                    self.slots
375                        .degenerate(format!("WeightsData[{u_index}][{v_index}] is not a number"))
376                })?;
377                if !weight.is_finite() {
378                    return Err(self.slots.degenerate(format!(
379                        "weight {weight} at control point [{u_index}][{v_index}] must be finite"
380                    )));
381                }
382                if weight <= 0.0 {
383                    return Err(self.slots.degenerate(format!(
384                        "weight {weight} at control point [{u_index}][{v_index}] must be positive"
385                    )));
386                }
387                row.push(weight);
388            }
389            weights.push(row);
390        }
391        Ok(Some(weights))
392    }
393
394    fn degree(
395        &self,
396        index: usize,
397        name: &'static str,
398        control_count: usize,
399    ) -> GeometryResult<usize> {
400        let raw = self.slots.req_i64(index, name)?;
401        if raw < 1 {
402            return Err(self
403                .slots
404                .degenerate(format!("{name} must be at least 1, found {raw}")));
405        }
406        let degree = usize::try_from(raw).map_err(|_| {
407            self.slots
408                .degenerate(format!("{name} exceeds platform limits"))
409        })?;
410        if degree >= control_count {
411            return Err(self.slots.degenerate(format!(
412                "{name} {degree} must be smaller than the {control_count} control points"
413            )));
414        }
415        Ok(degree)
416    }
417
418    /// Read one knot vector and check it against `expected` total multiplicity.
419    fn knot_vector(
420        &self,
421        knots_index: usize,
422        knots_name: &'static str,
423        mult_index: usize,
424        mult_name: &'static str,
425        expected: usize,
426    ) -> GeometryResult<KnotVector> {
427        let values = self.slots.req_f64_list(knots_index, knots_name)?;
428        let raw = self.slots.req(mult_index, mult_name)?;
429        let items = raw
430            .as_list()
431            .ok_or_else(|| self.slots.degenerate(format!("{mult_name} must be a list")))?;
432
433        let mut multiplicities = Vec::with_capacity(items.len());
434        for item in items {
435            match item.unwrap_typed() {
436                Value::Integer(i) if *i >= 1 => {
437                    multiplicities.push(usize::try_from(*i).map_err(|_| {
438                        self.slots
439                            .degenerate(format!("{mult_name} exceeds platform limits"))
440                    })?);
441                }
442                other => {
443                    return Err(self.slots.degenerate(format!(
444                        "{mult_name} entry must be a positive integer, found {other:?}"
445                    )));
446                }
447            }
448        }
449
450        if values.len() != multiplicities.len() {
451            return Err(self.slots.degenerate(format!(
452                "{knots_name} has {} entries but {mult_name} has {}; they are parallel lists",
453                values.len(),
454                multiplicities.len()
455            )));
456        }
457        for (index, value) in values.iter().enumerate() {
458            if !value.is_finite() {
459                return Err(self.slots.degenerate(format!(
460                    "{knots_name}[{index}] must be finite, found {value}"
461                )));
462            }
463        }
464        for pair in values.windows(2) {
465            if pair[1] <= pair[0] {
466                return Err(self.slots.degenerate(format!(
467                    "{knots_name} must be strictly increasing; found {} after {}",
468                    pair[1], pair[0]
469                )));
470            }
471        }
472        let total = multiplicities.iter().try_fold(0usize, |total, &value| {
473            total.checked_add(value).ok_or_else(|| {
474                self.slots
475                    .degenerate(format!("{mult_name} total overflows usize"))
476            })
477        })?;
478        if total != expected {
479            return Err(self.slots.degenerate(format!(
480                "{mult_name} sums to {total} but must equal control points + degree + 1 = {expected}"
481            )));
482        }
483        Ok(KnotVector {
484            values,
485            multiplicities,
486        })
487    }
488}
489
490/// Marker kept so the curve module's knot types are visibly reused.
491///
492/// The knot representation is identical in one and two dimensions, so
493/// duplicating [`KnotVector`] here would create two types that must be kept in
494/// sync for no benefit.
495pub type SurfaceKnotVector = KnotVector;
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    /// A `u_count` by `v_count` grid whose ids encode their position as
502    /// `u * 10 + v`, so a transposition is visible in the assertion.
503    fn grid(u_count: usize, v_count: usize) -> Value {
504        Value::List(
505            (0..u_count)
506                .map(|u| {
507                    Value::List(
508                        (0..v_count)
509                            .map(|v| Value::Ref(EntityId((u * 10 + v) as u64)))
510                            .collect(),
511                    )
512                })
513                .collect(),
514        )
515    }
516
517    fn integers(values: &[i64]) -> Value {
518        Value::List(values.iter().map(|i| Value::Integer(*i)).collect())
519    }
520
521    fn reals(values: &[f64]) -> Value {
522        Value::List(values.iter().map(|r| Value::Real(*r)).collect())
523    }
524
525    /// Degree 1 in both directions, 2x2 control points, clamped knots.
526    fn bilinear() -> Entity {
527        Entity::new(
528            "IFCBSPLINESURFACEWITHKNOTS",
529            vec![
530                Value::Integer(1),
531                Value::Integer(1),
532                grid(2, 2),
533                Value::Enum("UNSPECIFIED".into()),
534                Value::Bool(false),
535                Value::Bool(false),
536                Value::Bool(false),
537                integers(&[2, 2]),
538                integers(&[2, 2]),
539                reals(&[0.0, 1.0]),
540                reals(&[0.0, 1.0]),
541                Value::Enum("UNSPECIFIED".into()),
542            ],
543        )
544    }
545
546    #[test]
547    fn inherited_surface_slots_precede_the_knot_and_weight_slots() {
548        let e = bilinear();
549        let view = BSplineSurface::new(EntityId(1), &e);
550        assert_eq!(view.u_degree().unwrap(), 1);
551        assert_eq!(view.v_degree().unwrap(), 1);
552        assert!(view.has_knots());
553        assert!(!view.is_rational());
554        assert_eq!(view.knot_spec(), KnotType::Unspecified);
555    }
556
557    /// The outer list runs along u and the inner along v. A transposed read
558    /// still typechecks and still passes count checks on a square grid, so it
559    /// is pinned by position-encoded ids on a non-square grid.
560    #[test]
561    fn the_outer_control_point_list_runs_along_u_and_the_inner_along_v() {
562        let e = Entity::new(
563            "IFCBSPLINESURFACE",
564            vec![
565                Value::Integer(1),
566                Value::Integer(1),
567                grid(3, 5),
568                Value::Enum("UNSPECIFIED".into()),
569                Value::Bool(false),
570                Value::Bool(false),
571                Value::Bool(false),
572            ],
573        );
574        let points = BSplineSurface::new(EntityId(1), &e)
575            .control_points()
576            .unwrap();
577        assert_eq!(points.u_count(), 3, "outer list length is the u count");
578        assert_eq!(points.v_count(), 5, "inner list length is the v count");
579        // Id u*10 + v: (2, 4) must be 24, not 42.
580        assert_eq!(points.get(2, 4), Some(EntityId(24)));
581        assert_eq!(points.rows().len(), 3);
582    }
583
584    /// A ragged grid is not a tensor-product surface and cannot be evaluated.
585    #[test]
586    fn a_ragged_control_point_grid_is_rejected() {
587        let mut e = bilinear();
588        e.attributes[slot::CONTROL_POINTS] = Value::List(vec![
589            Value::List(vec![Value::Ref(EntityId(1)), Value::Ref(EntityId(2))]),
590            Value::List(vec![Value::Ref(EntityId(3))]),
591        ]);
592        let err = BSplineSurface::new(EntityId(7), &e)
593            .control_points()
594            .unwrap_err();
595        assert!(err.to_string().contains("rectangular"), "got: {err}");
596        assert!(err.to_string().contains("#7"), "got: {err}");
597    }
598
599    #[test]
600    fn both_knot_vectors_are_checked_against_their_own_control_point_count() {
601        let e = bilinear();
602        let view = BSplineSurface::new(EntityId(1), &e);
603        let u = view.u_knots().unwrap().unwrap();
604        let v = view.v_knots().unwrap().unwrap();
605        assert_eq!(u.expanded(), Some(vec![0.0, 0.0, 1.0, 1.0]));
606        assert_eq!(v.expanded(), Some(vec![0.0, 0.0, 1.0, 1.0]));
607        assert!(u.is_clamped(1));
608    }
609
610    /// The u and v checks must be independent: a wrong v multiplicity must not
611    /// be masked by a correct u one.
612    #[test]
613    fn a_wrong_v_multiplicity_sum_is_caught_even_when_u_is_right() {
614        let mut e = bilinear();
615        e.attributes[slot::V_MULTIPLICITIES] = integers(&[2, 3]);
616        let view = BSplineSurface::new(EntityId(1), &e);
617        assert!(view.u_knots().is_ok(), "u is untouched and must still pass");
618        let err = view.v_knots().unwrap_err();
619        assert!(err.to_string().contains("VMultiplicities"), "got: {err}");
620    }
621
622    #[test]
623    fn parallel_knot_lists_of_different_lengths_are_rejected() {
624        let mut e = bilinear();
625        e.attributes[slot::U_KNOTS] = reals(&[0.0, 0.5, 1.0]);
626        let err = BSplineSurface::new(EntityId(1), &e).u_knots().unwrap_err();
627        assert!(err.to_string().contains("parallel"), "got: {err}");
628    }
629
630    #[test]
631    fn non_increasing_knot_values_are_rejected() {
632        let mut e = bilinear();
633        e.attributes[slot::U_KNOTS] = reals(&[1.0, 0.0]);
634        let err = BSplineSurface::new(EntityId(1), &e).u_knots().unwrap_err();
635        assert!(err.to_string().contains("increasing"), "got: {err}");
636    }
637
638    #[test]
639    fn a_surface_without_knots_reports_none_rather_than_failing() {
640        let e = Entity::new(
641            "IFCBSPLINESURFACE",
642            vec![
643                Value::Integer(1),
644                Value::Integer(1),
645                grid(2, 2),
646                Value::Enum("UNSPECIFIED".into()),
647                Value::Bool(false),
648                Value::Bool(false),
649                Value::Bool(false),
650            ],
651        );
652        let view = BSplineSurface::new(EntityId(1), &e);
653        assert_eq!(view.u_knots().unwrap(), None);
654        assert_eq!(view.v_knots().unwrap(), None);
655        assert_eq!(view.weights().unwrap(), None);
656    }
657
658    #[test]
659    fn rational_weights_form_a_grid_of_the_same_shape_as_the_control_points() {
660        let mut attributes = bilinear().attributes;
661        attributes.push(Value::List(vec![reals(&[1.0, 0.5]), reals(&[0.5, 1.0])]));
662        let e = Entity::new("IFCRATIONALBSPLINESURFACEWITHKNOTS", attributes);
663        let view = BSplineSurface::new(EntityId(1), &e);
664        assert!(view.is_rational());
665        assert_eq!(
666            view.weights().unwrap().unwrap(),
667            vec![vec![1.0, 0.5], vec![0.5, 1.0]]
668        );
669    }
670
671    #[test]
672    fn a_weight_grid_of_the_wrong_shape_is_rejected() {
673        let mut attributes = bilinear().attributes;
674        attributes.push(Value::List(vec![reals(&[1.0, 1.0])]));
675        let e = Entity::new("IFCRATIONALBSPLINESURFACEWITHKNOTS", attributes);
676        let err = BSplineSurface::new(EntityId(1), &e).weights().unwrap_err();
677        assert!(err.to_string().contains("rows"), "got: {err}");
678    }
679
680    #[test]
681    fn a_non_positive_weight_anywhere_in_the_grid_is_degenerate() {
682        for bad in [0.0, -1.0] {
683            let mut attributes = bilinear().attributes;
684            attributes.push(Value::List(vec![reals(&[1.0, 1.0]), reals(&[1.0, bad])]));
685            let e = Entity::new("IFCRATIONALBSPLINESURFACEWITHKNOTS", attributes);
686            let err = BSplineSurface::new(EntityId(1), &e).weights().unwrap_err();
687            assert!(err.to_string().contains("positive"), "weight {bad}: {err}");
688            assert!(err.to_string().contains("[1][1]"), "weight {bad}: {err}");
689        }
690    }
691
692    #[test]
693    fn multiplicity_overflow_is_a_typed_error_not_a_panic() {
694        let mut e = bilinear();
695        e.attributes[slot::U_MULTIPLICITIES] = integers(&[i64::MAX, i64::MAX, i64::MAX]);
696        e.attributes[slot::U_KNOTS] = reals(&[0.0, 0.5, 1.0]);
697        let err = BSplineSurface::new(EntityId(8), &e).u_knots().unwrap_err();
698        assert!(err.to_string().contains("overflow"), "got: {err}");
699    }
700
701    #[test]
702    fn each_degree_must_not_exceed_its_control_point_upper_index() {
703        let mut e = bilinear();
704        e.attributes[slot::U_DEGREE] = Value::Integer(2);
705        assert!(BSplineSurface::new(EntityId(9), &e)
706            .u_degree()
707            .unwrap_err()
708            .to_string()
709            .contains("control points"));
710
711        let mut e = bilinear();
712        e.attributes[slot::V_DEGREE] = Value::Integer(2);
713        assert!(BSplineSurface::new(EntityId(10), &e)
714            .v_degree()
715            .unwrap_err()
716            .to_string()
717            .contains("control points"));
718    }
719
720    #[test]
721    fn rational_subtype_requires_weights_and_polynomial_rejects_them() {
722        let attributes = bilinear().attributes;
723        let rational = Entity::new("IFCRATIONALBSPLINESURFACEWITHKNOTS", attributes.clone());
724        assert!(BSplineSurface::new(EntityId(11), &rational)
725            .weights()
726            .unwrap_err()
727            .to_string()
728            .contains("missing WeightsData"));
729
730        let mut polynomial_attributes = attributes;
731        polynomial_attributes.push(Value::List(vec![reals(&[1.0, 1.0]), reals(&[1.0, 1.0])]));
732        let polynomial = Entity::new("IFCBSPLINESURFACEWITHKNOTS", polynomial_attributes);
733        assert!(BSplineSurface::new(EntityId(12), &polynomial)
734            .weights()
735            .unwrap_err()
736            .to_string()
737            .contains("must not carry WeightsData"));
738    }
739
740    #[test]
741    fn degree_zero_in_either_direction_is_rejected() {
742        let mut e = bilinear();
743        e.attributes[slot::U_DEGREE] = Value::Integer(0);
744        assert!(BSplineSurface::new(EntityId(1), &e).u_degree().is_err());
745
746        let mut e = bilinear();
747        e.attributes[slot::V_DEGREE] = Value::Integer(0);
748        assert!(BSplineSurface::new(EntityId(1), &e).v_degree().is_err());
749    }
750
751    /// The form is provenance, never a licence to swap in an analytic surface.
752    #[test]
753    fn surface_form_tokens_parse_without_replacing_the_control_points() {
754        assert_eq!(
755            BSplineSurfaceForm::from_token("SURF_OF_LINEAR_EXTRUSION"),
756            Some(BSplineSurfaceForm::SurfOfLinearExtrusion)
757        );
758        assert_eq!(
759            BSplineSurfaceForm::from_token("CYLINDRICAL_SURF"),
760            Some(BSplineSurfaceForm::CylindricalSurf)
761        );
762        assert_eq!(BSplineSurfaceForm::from_token("BLOB"), None);
763    }
764
765    #[test]
766    fn closure_flags_are_read_independently_for_u_and_v() {
767        let mut e = bilinear();
768        e.attributes[slot::U_CLOSED] = Value::Bool(true);
769        e.attributes[slot::V_CLOSED] = Value::LogicalUnknown;
770        let view = BSplineSurface::new(EntityId(1), &e);
771        assert_eq!(view.u_closed(), Some(true));
772        assert_eq!(view.v_closed(), None, ".U. must not become false");
773    }
774}