use crate::error::GeometryResult;
use crate::resource::point::CartesianPoint;
use crate::resource::resolve;
use crate::slots::Slots;
use ifc_model::{Entity, EntityId, Model, Value};
pub(crate) mod slot {
pub const DEGREE: usize = 0;
pub const CONTROL_POINTS: usize = 1;
pub const CURVE_FORM: usize = 2;
pub const CLOSED_CURVE: usize = 3;
pub const SELF_INTERSECT: usize = 4;
pub const KNOT_MULTIPLICITIES: usize = 5;
pub const KNOTS: usize = 6;
pub const KNOT_SPEC: usize = 7;
pub const WEIGHTS_DATA: usize = 8;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BSplineCurveForm {
Polyline,
CircularArc,
EllipticArc,
ParabolicArc,
HyperbolicArc,
Unspecified,
}
impl BSplineCurveForm {
pub fn from_token(token: &str) -> Option<Self> {
match token.to_ascii_uppercase().as_str() {
"POLYLINE_FORM" => Some(Self::Polyline),
"CIRCULAR_ARC" => Some(Self::CircularArc),
"ELLIPTIC_ARC" => Some(Self::EllipticArc),
"PARABOLIC_ARC" => Some(Self::ParabolicArc),
"HYPERBOLIC_ARC" => Some(Self::HyperbolicArc),
"UNSPECIFIED" => Some(Self::Unspecified),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KnotType {
Uniform,
QuasiUniform,
PiecewiseBezier,
Unspecified,
}
impl KnotType {
pub fn from_token(token: &str) -> Option<Self> {
match token.to_ascii_uppercase().as_str() {
"UNIFORM_KNOTS" => Some(Self::Uniform),
"QUASI_UNIFORM_KNOTS" => Some(Self::QuasiUniform),
"PIECEWISE_BEZIER_KNOTS" => Some(Self::PiecewiseBezier),
"UNSPECIFIED" => Some(Self::Unspecified),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BSplineCurve<'m> {
slots: Slots<'m>,
}
impl<'m> BSplineCurve<'m> {
pub fn new(id: EntityId, entity: &'m Entity) -> Self {
Self {
slots: Slots::new(id, entity),
}
}
pub fn id(&self) -> EntityId {
self.slots.id()
}
pub fn degree(&self) -> GeometryResult<usize> {
let raw = self.slots.req_i64(slot::DEGREE, "Degree")?;
if raw < 1 {
return Err(self
.slots
.degenerate(format!("Degree must be at least 1, found {raw}")));
}
let degree = usize::try_from(raw)
.map_err(|_| self.slots.degenerate("Degree exceeds platform limits"))?;
let control_count = self.control_point_refs()?.len();
if degree >= control_count {
return Err(self.slots.degenerate(format!(
"Degree {degree} must be smaller than the {control_count} control points"
)));
}
Ok(degree)
}
pub fn control_point_refs(&self) -> GeometryResult<Vec<EntityId>> {
let points = self
.slots
.req_ref_list(slot::CONTROL_POINTS, "ControlPointsList")?;
if points.len() < 2 {
return Err(self.slots.degenerate(format!(
"ControlPointsList needs at least 2 points, found {}",
points.len()
)));
}
Ok(points)
}
pub fn control_points<'v>(&self, model: &'v Model) -> GeometryResult<Vec<CartesianPoint<'v>>> {
resolve::cartesian_points(model, self.id(), &self.control_point_refs()?)
}
pub fn curve_form(&self) -> BSplineCurveForm {
self.slots
.opt_enum(slot::CURVE_FORM)
.and_then(BSplineCurveForm::from_token)
.unwrap_or(BSplineCurveForm::Unspecified)
}
pub fn closed_curve(&self) -> Option<bool> {
self.slots.opt_bool(slot::CLOSED_CURVE)
}
pub fn self_intersect(&self) -> Option<bool> {
self.slots.opt_bool(slot::SELF_INTERSECT)
}
pub fn knot_spec(&self) -> KnotType {
self.slots
.opt_enum(slot::KNOT_SPEC)
.and_then(KnotType::from_token)
.unwrap_or(KnotType::Unspecified)
}
pub fn has_knots(&self) -> bool {
self.slots.opt(slot::KNOTS).is_some()
}
pub fn is_rational(&self) -> bool {
self.slots
.type_name()
.eq_ignore_ascii_case("IFCRATIONALBSPLINECURVEWITHKNOTS")
}
pub fn knots(&self) -> GeometryResult<Option<KnotVector>> {
if !self.has_knots() {
return Ok(None);
}
let values = self.slots.req_f64_list(slot::KNOTS, "Knots")?;
let multiplicities = self.integer_list(slot::KNOT_MULTIPLICITIES, "KnotMultiplicities")?;
if values.len() != multiplicities.len() {
return Err(self.slots.degenerate(format!(
"Knots has {} entries but KnotMultiplicities has {}; \
they are parallel lists",
values.len(),
multiplicities.len()
)));
}
if values.is_empty() {
return Err(self.slots.degenerate("Knots is empty"));
}
for (i, m) in multiplicities.iter().enumerate() {
if *m < 1 {
return Err(self.slots.degenerate(format!(
"knot multiplicity {m} at position {i} is not positive"
)));
}
}
for (index, value) in values.iter().enumerate() {
if !value.is_finite() {
return Err(self
.slots
.degenerate(format!("Knots[{index}] must be finite, found {value}")));
}
}
for pair in values.windows(2) {
if pair[1] <= pair[0] {
return Err(self.slots.degenerate(format!(
"Knots must be strictly increasing; found {} after {}",
pair[1], pair[0]
)));
}
}
let multiplicities = multiplicities
.into_iter()
.map(|value| {
usize::try_from(value).map_err(|_| {
self.slots
.degenerate("knot multiplicity exceeds platform limits")
})
})
.collect::<GeometryResult<Vec<_>>>()?;
let total = multiplicities.iter().try_fold(0usize, |total, &value| {
total.checked_add(value).ok_or_else(|| {
self.slots
.degenerate("knot multiplicity total overflows usize")
})
})?;
let expected = self
.control_point_refs()?
.len()
.checked_add(self.degree()?)
.and_then(|value| value.checked_add(1))
.ok_or_else(|| self.slots.degenerate("expected knot count overflows usize"))?;
if total != expected {
return Err(self.slots.degenerate(format!(
"knot multiplicities sum to {total} but must equal \
ControlPoints + Degree + 1 = {expected}"
)));
}
Ok(Some(KnotVector {
values,
multiplicities,
}))
}
pub fn weights(&self) -> GeometryResult<Option<Vec<f64>>> {
let supplied = self.slots.opt(slot::WEIGHTS_DATA).is_some();
match (self.is_rational(), supplied) {
(false, false) => return Ok(None),
(true, false) => {
return Err(self
.slots
.degenerate("rational B-spline curve is missing WeightsData"));
}
(false, true) => {
return Err(self
.slots
.degenerate("polynomial B-spline curve must not carry WeightsData"));
}
(true, true) => {}
}
let weights = self.slots.req_f64_list(slot::WEIGHTS_DATA, "WeightsData")?;
let control_points = self.control_point_refs()?.len();
if weights.len() != control_points {
return Err(self.slots.degenerate(format!(
"WeightsData has {} entries but there are {control_points} control points",
weights.len()
)));
}
for (i, w) in weights.iter().enumerate() {
if !w.is_finite() {
return Err(self
.slots
.degenerate(format!("weight {w} at control point {i} must be finite")));
}
if *w <= 0.0 {
return Err(self
.slots
.degenerate(format!("weight {w} at control point {i} must be positive")));
}
}
Ok(Some(weights))
}
fn integer_list(&self, index: usize, name: &'static str) -> GeometryResult<Vec<i64>> {
let value = self.slots.req(index, name)?;
let items = value
.as_list()
.ok_or_else(|| self.slots.degenerate(format!("{name} must be a list")))?;
items
.iter()
.map(|v| match v.unwrap_typed() {
Value::Integer(i) => Ok(*i),
other => Err(self
.slots
.degenerate(format!("{name} entry is not an integer: {other:?}"))),
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct KnotVector {
pub values: Vec<f64>,
pub multiplicities: Vec<usize>,
}
impl KnotVector {
pub fn expanded(&self) -> Option<Vec<f64>> {
let total = self.total_multiplicity()?;
let mut out = Vec::with_capacity(total);
for (value, multiplicity) in self.values.iter().zip(&self.multiplicities) {
out.extend(std::iter::repeat_n(*value, *multiplicity));
}
Some(out)
}
pub fn total_multiplicity(&self) -> Option<usize> {
self.multiplicities
.iter()
.try_fold(0usize, |acc, m| acc.checked_add(*m))
}
pub fn is_clamped(&self, degree: usize) -> bool {
let want = degree + 1;
self.multiplicities.first() == Some(&want) && self.multiplicities.last() == Some(&want)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn refs(n: usize) -> Value {
Value::List((0..n).map(|i| Value::Ref(EntityId(i as u64 + 1))).collect())
}
fn integers(values: &[i64]) -> Value {
Value::List(values.iter().map(|i| Value::Integer(*i)).collect())
}
fn reals(values: &[f64]) -> Value {
Value::List(values.iter().map(|r| Value::Real(*r)).collect())
}
fn with_knots(control_points: usize, multiplicities: &[i64], knots: &[f64]) -> Entity {
Entity::new(
"IFCBSPLINECURVEWITHKNOTS",
vec![
Value::Integer(3),
refs(control_points),
Value::Enum("UNSPECIFIED".into()),
Value::Bool(false),
Value::Bool(false),
integers(multiplicities),
reals(knots),
Value::Enum("UNSPECIFIED".into()),
],
)
}
fn rational(control_points: usize, weights: &[f64]) -> Entity {
let mut attributes = with_knots(control_points, &[4, 4], &[0.0, 1.0]).attributes;
attributes.push(reals(weights));
Entity::new("IFCRATIONALBSPLINECURVEWITHKNOTS", attributes)
}
#[test]
fn control_points_resolve_in_order_and_fail_on_a_dangling_one() {
let e = with_knots(4, &[4, 4], &[0.0, 1.0]);
let view = BSplineCurve::new(EntityId(9), &e);
let model_with = |ids: &[u64]| {
let mut model = Model::new();
for &i in ids {
let point = Entity::new("IFCCARTESIANPOINT", vec![reals(&[i as f64, 0.0])]);
model.insert(EntityId(i), point);
}
model
};
let model = model_with(&[1, 2, 3, 4]);
let points = view.control_points(&model).unwrap();
assert_eq!(points[3].coordinates().unwrap(), vec![4.0, 0.0]);
let err = view.control_points(&model_with(&[1, 2, 4])).unwrap_err();
assert!(matches!(
err,
crate::GeometryError::MissingEntity {
referrer: EntityId(9),
missing: EntityId(3)
}
));
}
#[test]
fn inherited_bspline_slots_are_read_before_the_subtype_own_slots() {
let e = with_knots(4, &[4, 4], &[0.0, 1.0]);
let view = BSplineCurve::new(EntityId(1), &e);
assert_eq!(view.degree().unwrap(), 3);
assert_eq!(view.control_point_refs().unwrap().len(), 4);
assert!(view.has_knots());
assert!(!view.is_rational());
}
#[test]
fn a_valid_knot_vector_expands_to_control_points_plus_degree_plus_one() {
let e = with_knots(4, &[4, 4], &[0.0, 1.0]);
let knots = BSplineCurve::new(EntityId(1), &e).knots().unwrap().unwrap();
assert_eq!(
knots.expanded(),
Some(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0])
);
assert_eq!(knots.total_multiplicity(), Some(4 + 3 + 1));
assert!(knots.is_clamped(3));
}
#[test]
fn a_knot_multiplicity_sum_that_disagrees_with_the_degree_is_rejected() {
let e = with_knots(4, &[4, 3], &[0.0, 1.0]);
let err = BSplineCurve::new(EntityId(6), &e).knots().unwrap_err();
assert!(err.to_string().contains("must equal"), "got: {err}");
assert!(err.to_string().contains("#6"), "got: {err}");
}
#[test]
fn multiplicities_summing_past_usize_report_rather_than_panic() {
let kv = KnotVector {
values: vec![0.0, 1.0],
multiplicities: vec![usize::MAX, 2],
};
assert_eq!(kv.total_multiplicity(), None);
assert_eq!(kv.expanded(), None);
}
#[test]
fn knots_and_multiplicities_of_different_lengths_are_rejected() {
let e = with_knots(4, &[4, 4], &[0.0, 0.5, 1.0]);
let err = BSplineCurve::new(EntityId(1), &e).knots().unwrap_err();
assert!(err.to_string().contains("parallel"), "got: {err}");
}
#[test]
fn non_increasing_knot_values_are_rejected() {
let e = with_knots(5, &[4, 1, 4], &[0.0, 1.0, 1.0]);
let err = BSplineCurve::new(EntityId(1), &e).knots().unwrap_err();
assert!(err.to_string().contains("increasing"), "got: {err}");
}
#[test]
fn non_finite_knot_values_are_rejected_by_the_typed_view() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let values = if bad.is_sign_negative() {
[bad, 1.0]
} else {
[0.0, bad]
};
let e = with_knots(4, &[4, 4], &values);
let err = BSplineCurve::new(EntityId(13), &e).knots().unwrap_err();
assert!(err.to_string().contains("finite"), "knot {bad}: {err}");
}
}
#[test]
fn an_unclamped_knot_vector_is_recognised_as_such() {
let e = Entity::new(
"IFCBSPLINECURVEWITHKNOTS",
vec![
Value::Integer(3),
refs(6),
Value::Enum("UNSPECIFIED".into()),
Value::Bool(false),
Value::Bool(false),
integers(&[1; 10]),
reals(&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]),
Value::Enum("UNIFORM_KNOTS".into()),
],
);
let view = BSplineCurve::new(EntityId(1), &e);
let knots = view.knots().unwrap().unwrap();
assert!(!knots.is_clamped(3));
assert_eq!(view.knot_spec(), KnotType::Uniform);
}
#[test]
fn a_curve_without_knots_reports_none_rather_than_failing() {
let e = Entity::new(
"IFCBSPLINECURVE",
vec![
Value::Integer(3),
refs(4),
Value::Enum("UNSPECIFIED".into()),
Value::Bool(false),
Value::Bool(false),
],
);
let view = BSplineCurve::new(EntityId(1), &e);
assert_eq!(view.knots().unwrap(), None);
assert_eq!(view.weights().unwrap(), None);
}
#[test]
fn rational_weights_are_read_from_the_slot_after_the_knot_attributes() {
let e = rational(4, &[1.0, 0.5, 0.5, 1.0]);
let view = BSplineCurve::new(EntityId(1), &e);
assert!(view.is_rational());
assert_eq!(view.weights().unwrap().unwrap(), vec![1.0, 0.5, 0.5, 1.0]);
}
#[test]
fn a_zero_weight_is_degenerate() {
let e = rational(4, &[1.0, 0.0, 1.0, 1.0]);
let err = BSplineCurve::new(EntityId(2), &e).weights().unwrap_err();
assert!(err.to_string().contains("positive"), "got: {err}");
}
#[test]
fn a_negative_weight_is_degenerate() {
let e = rational(4, &[1.0, -1.0, 1.0, 1.0]);
assert!(BSplineCurve::new(EntityId(1), &e).weights().is_err());
}
#[test]
fn non_finite_weights_are_rejected_by_the_typed_view() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let e = rational(4, &[1.0, bad, 1.0, 1.0]);
let err = BSplineCurve::new(EntityId(14), &e).weights().unwrap_err();
assert!(err.to_string().contains("finite"), "weight {bad}: {err}");
}
}
#[test]
fn a_weight_count_that_differs_from_the_control_point_count_is_rejected() {
let e = rational(4, &[1.0, 1.0, 1.0]);
let err = BSplineCurve::new(EntityId(1), &e).weights().unwrap_err();
assert!(err.to_string().contains("control points"), "got: {err}");
}
#[test]
fn multiplicity_overflow_is_a_typed_error_not_a_panic() {
let e = with_knots(4, &[i64::MAX, i64::MAX, i64::MAX], &[0.0, 0.5, 1.0]);
let err = BSplineCurve::new(EntityId(8), &e).knots().unwrap_err();
assert!(err.to_string().contains("overflow"), "got: {err}");
}
#[test]
fn degree_must_not_exceed_the_control_point_upper_index() {
let mut e = with_knots(4, &[4, 4], &[0.0, 1.0]);
e.attributes[slot::DEGREE] = Value::Integer(4);
let err = BSplineCurve::new(EntityId(9), &e).degree().unwrap_err();
assert!(err.to_string().contains("control points"), "got: {err}");
}
#[test]
fn rational_subtype_requires_weights_and_polynomial_rejects_them() {
let attributes = with_knots(4, &[4, 4], &[0.0, 1.0]).attributes;
let rational = Entity::new("IFCRATIONALBSPLINECURVEWITHKNOTS", attributes.clone());
assert!(BSplineCurve::new(EntityId(10), &rational)
.weights()
.unwrap_err()
.to_string()
.contains("missing WeightsData"));
let mut polynomial_attributes = attributes;
polynomial_attributes.push(reals(&[1.0; 4]));
let polynomial = Entity::new("IFCBSPLINECURVEWITHKNOTS", polynomial_attributes);
assert!(BSplineCurve::new(EntityId(11), &polynomial)
.weights()
.unwrap_err()
.to_string()
.contains("must not carry WeightsData"));
}
#[test]
fn degree_zero_is_rejected_because_it_describes_no_curve() {
let mut e = with_knots(4, &[4, 4], &[0.0, 1.0]);
e.attributes[0] = Value::Integer(0);
assert!(BSplineCurve::new(EntityId(1), &e).degree().is_err());
}
#[test]
fn curve_form_tokens_parse_without_altering_the_control_points() {
assert_eq!(
BSplineCurveForm::from_token("CIRCULAR_ARC"),
Some(BSplineCurveForm::CircularArc)
);
assert_eq!(BSplineCurveForm::from_token("SPIRAL"), None);
assert_eq!(
KnotType::from_token("PIECEWISE_BEZIER_KNOTS"),
Some(KnotType::PiecewiseBezier)
);
assert_eq!(KnotType::from_token("WEIRD"), None);
}
}