Skip to main content

ifc_geometry/authoring/
curve.rs

1//! Curves: the parametric geometry profiles and sweeps are built from.
2//!
3//! Most of these are a placement plus a radius. Three are not, and they
4//! are where a writer earns its keep:
5//!
6//! - **`IfcLine`** carries direction *and parameter scale* in one
7//!   `IfcVector`. The vector's `Magnitude` is not cosmetic: it decides
8//!   what parameter 1 means, so a unit direction with magnitude 5 is a
9//!   different curve from the same direction with magnitude 1.
10//! - **`IfcTrimmedCurve`** takes trims as a SET of one or two values,
11//!   mixing a parameter and a point. `MasterRepresentation` says which
12//!   to believe when both are given, and writing the wrong one silently
13//!   changes where the curve starts.
14//! - **B-splines** carry a knot vector whose multiplicities must sum to
15//!   `Degree + |ControlPoints| + 1`. That is an arithmetic invariant,
16//!   checkable without evaluating anything, so this module checks it.
17//!
18//! Everything here stays kernel-free: no curve is evaluated, sampled or
19//! tested for self-intersection. `SelfIntersect` is written as the
20//! caller states it, because deciding it needs an evaluator.
21
22use ifc_model::{Entity, EntityId, Transaction, Value};
23
24use crate::curve::bspline::slot as bspline_slot;
25use crate::curve::composite::{curve_slot, segment_slot};
26use crate::curve::conic::{circle_slot, ellipse_slot};
27use crate::curve::line::slot as line_slot;
28use crate::curve::polyline::indexed_slot;
29use crate::curve::trimmed::{slot as trimmed_slot, Trim};
30use crate::curve::{TransitionCode, TrimmingPreference};
31use crate::error::GeometryError;
32
33use super::std_profile::positive;
34use super::{invalid, reals, refs, require_finite};
35
36/// Stage an `IfcVector`: a direction with a magnitude.
37///
38/// # Errors
39///
40/// Refuses a non-finite or non-positive magnitude. A zero-magnitude
41/// vector gives the line it parameterizes no scale at all.
42pub fn vector(
43    tx: &mut Transaction,
44    orientation: EntityId,
45    magnitude: f64,
46) -> Result<EntityId, GeometryError> {
47    positive("IFCVECTOR", "Magnitude", magnitude)?;
48    let attrs = vec![Value::Ref(orientation), Value::Real(magnitude)];
49    Ok(tx.create(Entity::new("IFCVECTOR", attrs)))
50}
51
52/// Stage an `IfcLine` through a point along a vector.
53///
54/// The vector sets both direction and parameterization; see the module
55/// note. Pass a vector authored by [`vector`].
56pub fn line(tx: &mut Transaction, point: EntityId, dir: EntityId) -> EntityId {
57    let mut attrs = vec![Value::Null; 2];
58    attrs[line_slot::PNT] = Value::Ref(point);
59    attrs[line_slot::DIR] = Value::Ref(dir);
60    tx.create(Entity::new("IFCLINE", attrs))
61}
62
63/// Stage an `IfcCircle`.
64///
65/// `position` may be a 2D or 3D placement: `IfcConic.Position` is an
66/// `IfcAxis2Placement` select, and the dimensionality of the curve
67/// follows the placement rather than being stated separately.
68///
69/// # Errors
70///
71/// Refuses a non-positive or non-finite radius.
72pub fn circle(
73    tx: &mut Transaction,
74    position: EntityId,
75    radius: f64,
76) -> Result<EntityId, GeometryError> {
77    positive("IFCCIRCLE", "Radius", radius)?;
78    let mut attrs = vec![Value::Null; 2];
79    attrs[circle_slot::POSITION] = Value::Ref(position);
80    attrs[circle_slot::RADIUS] = Value::Real(radius);
81    Ok(tx.create(Entity::new("IFCCIRCLE", attrs)))
82}
83
84/// Stage an `IfcEllipse`.
85///
86/// # Errors
87///
88/// Refuses a non-positive or non-finite semi-axis.
89pub fn ellipse(
90    tx: &mut Transaction,
91    position: EntityId,
92    semi_axis_1: f64,
93    semi_axis_2: f64,
94) -> Result<EntityId, GeometryError> {
95    positive("IFCELLIPSE", "SemiAxis1", semi_axis_1)?;
96    positive("IFCELLIPSE", "SemiAxis2", semi_axis_2)?;
97    let mut attrs = vec![Value::Null; 3];
98    attrs[ellipse_slot::POSITION] = Value::Ref(position);
99    attrs[ellipse_slot::SEMI_AXIS_1] = Value::Real(semi_axis_1);
100    attrs[ellipse_slot::SEMI_AXIS_2] = Value::Real(semi_axis_2);
101    Ok(tx.create(Entity::new("IFCELLIPSE", attrs)))
102}
103
104/// Encode one end of a trim as a `SET [1:2] OF IfcTrimmingSelect`.
105///
106/// Reuses [`crate::curve::trimmed::Trim`] -- the type the reader already
107/// returns -- rather than declaring a second shape for the same thing.
108/// A trim written here reads back as an equal value.
109fn trim_members(trim: Trim, which: &'static str) -> Result<Value, GeometryError> {
110    let mut set = Vec::with_capacity(2);
111    if let Some(point) = trim.cartesian {
112        set.push(Value::Ref(point));
113    }
114    if let Some(parameter) = trim.parameter {
115        require_finite("IFCTRIMMEDCURVE", which, &[parameter])?;
116        // The select member must carry its measure type. A bare real is
117        // tolerated by lenient readers but is not conforming, and dropping
118        // the wrapper loses the only marker distinguishing a parameter
119        // from anything else numeric in the set.
120        set.push(Value::Typed {
121            type_name: "IFCPARAMETERVALUE".into(),
122            value: Box::new(Value::Real(parameter)),
123        });
124    }
125    if set.is_empty() {
126        return Err(invalid(
127            "IFCTRIMMEDCURVE",
128            which,
129            "a trim needs at least a Cartesian point or a parameter",
130        ));
131    }
132    Ok(Value::List(set))
133}
134
135/// Stage an `IfcTrimmedCurve`.
136///
137/// `sense_agreement` states whether the trimmed curve runs in the basis
138/// curve's own direction. `master` says which trim form is
139/// authoritative where both a point and a parameter are given.
140///
141/// # Errors
142///
143/// Refuses a trim that states neither a point nor a parameter: the
144/// schema's `SET [1:2]` has a lower bound of one, so an empty trim is
145/// not expressible.
146pub fn trimmed_curve(
147    tx: &mut Transaction,
148    basis: EntityId,
149    trim_1: Trim,
150    trim_2: Trim,
151    sense_agreement: bool,
152    master: TrimmingPreference,
153) -> Result<EntityId, GeometryError> {
154    let mut attrs = vec![Value::Null; 5];
155    attrs[trimmed_slot::BASIS_CURVE] = Value::Ref(basis);
156    attrs[trimmed_slot::TRIM_1] = trim_members(trim_1, "Trim1")?;
157    attrs[trimmed_slot::TRIM_2] = trim_members(trim_2, "Trim2")?;
158    attrs[trimmed_slot::SENSE_AGREEMENT] = Value::Bool(sense_agreement);
159    attrs[trimmed_slot::MASTER_REPRESENTATION] = Value::Enum(master.token().into());
160    Ok(tx.create(Entity::new("IFCTRIMMEDCURVE", attrs)))
161}
162
163/// Stage an `IfcCompositeCurveSegment`.
164///
165/// `transition` describes what holds where this segment meets the
166/// *next* one, so the last segment of an open curve is `Discontinuous`.
167pub fn composite_curve_segment(
168    tx: &mut Transaction,
169    transition: TransitionCode,
170    same_sense: bool,
171    parent_curve: EntityId,
172) -> EntityId {
173    let mut attrs = vec![Value::Null; 3];
174    attrs[segment_slot::TRANSITION] = Value::Enum(transition.token().into());
175    attrs[segment_slot::SAME_SENSE] = Value::Bool(same_sense);
176    attrs[segment_slot::PARENT_CURVE] = Value::Ref(parent_curve);
177    tx.create(Entity::new("IFCCOMPOSITECURVESEGMENT", attrs))
178}
179
180/// Stage an `IfcCompositeCurve` over existing segments.
181///
182/// # Errors
183///
184/// Refuses an empty segment list: `LIST [1:?]`. Whether the segments
185/// actually join is not checked -- that needs an evaluator, and the
186/// `Transition` codes are the file's own claim about it.
187pub fn composite_curve(
188    tx: &mut Transaction,
189    segments: &[EntityId],
190    self_intersect: Option<bool>,
191) -> Result<EntityId, GeometryError> {
192    if segments.is_empty() {
193        return Err(invalid(
194            "IFCCOMPOSITECURVE",
195            "Segments",
196            "expected at least one segment",
197        ));
198    }
199    let mut attrs = vec![Value::Null; 2];
200    attrs[curve_slot::SEGMENTS] = refs(segments);
201    attrs[curve_slot::SELF_INTERSECT] = match self_intersect {
202        Some(value) => Value::Bool(value),
203        // IfcLogical has a third state, and it is the honest answer
204        // when nobody evaluated the curve.
205        None => Value::LogicalUnknown,
206    };
207    Ok(tx.create(Entity::new("IFCCOMPOSITECURVE", attrs)))
208}
209
210/// Stage an `IfcOffsetCurve2D`.
211///
212/// A negative distance offsets the other way and is legal:
213/// `IfcLengthMeasure`, not `IfcPositiveLengthMeasure`.
214///
215/// # Errors
216///
217/// Refuses a non-finite distance.
218pub fn offset_curve_2d(
219    tx: &mut Transaction,
220    basis: EntityId,
221    distance: f64,
222    self_intersect: Option<bool>,
223) -> Result<EntityId, GeometryError> {
224    require_finite("IFCOFFSETCURVE2D", "Distance", &[distance])?;
225    let attrs = vec![
226        Value::Ref(basis),
227        Value::Real(distance),
228        logical(self_intersect),
229    ];
230    Ok(tx.create(Entity::new("IFCOFFSETCURVE2D", attrs)))
231}
232
233/// Stage an `IfcOffsetCurve3D`.
234///
235/// `ref_direction` fixes the offset plane; in 3D the offset is
236/// otherwise ambiguous.
237///
238/// # Errors
239///
240/// Refuses a non-finite distance.
241pub fn offset_curve_3d(
242    tx: &mut Transaction,
243    basis: EntityId,
244    distance: f64,
245    self_intersect: Option<bool>,
246    ref_direction: EntityId,
247) -> Result<EntityId, GeometryError> {
248    require_finite("IFCOFFSETCURVE3D", "Distance", &[distance])?;
249    let attrs = vec![
250        Value::Ref(basis),
251        Value::Real(distance),
252        logical(self_intersect),
253        Value::Ref(ref_direction),
254    ];
255    Ok(tx.create(Entity::new("IFCOFFSETCURVE3D", attrs)))
256}
257
258/// An `IfcLogical`, where absence means `UNKNOWN` rather than false.
259fn logical(value: Option<bool>) -> Value {
260    match value {
261        Some(value) => Value::Bool(value),
262        None => Value::LogicalUnknown,
263    }
264}
265
266/// The knot vector of a B-spline curve.
267#[derive(Debug, Clone, Copy)]
268pub struct KnotVector<'a> {
269    /// How many times each distinct knot repeats.
270    pub multiplicities: &'a [i64],
271    /// The distinct knot values, strictly increasing.
272    pub knots: &'a [f64],
273    /// `IfcKnotType`, e.g. `UNSPECIFIED` or `QUASI_UNIFORM_KNOTS`.
274    pub spec: &'a str,
275}
276
277/// Stage an `IfcBSplineCurveWithKnots`.
278///
279/// # The invariant this checks
280///
281/// A B-spline is only well formed when
282///
283/// ```text
284/// sum(KnotMultiplicities) = Degree + |ControlPointsList| + 1
285/// ```
286///
287/// That is arithmetic, not geometry: it needs no evaluator, and a file
288/// violating it describes no curve at all. Checking it here turns a
289/// silent downstream failure into a refusal at the point of authoring.
290///
291/// # Errors
292///
293/// Refuses a degree below one, fewer than two control points, a
294/// multiplicity/knot length mismatch, non-increasing knots, a
295/// non-positive multiplicity, or a knot sum that breaks the identity
296/// above.
297pub fn bspline_curve_with_knots(
298    tx: &mut Transaction,
299    degree: i64,
300    control_points: &[EntityId],
301    curve_form: &str,
302    knots: KnotVector<'_>,
303) -> Result<EntityId, GeometryError> {
304    const T: &str = "IFCBSPLINECURVEWITHKNOTS";
305    let attrs = bspline_attrs(T, degree, control_points, curve_form, knots)?;
306    Ok(tx.create(Entity::new(T, attrs)))
307}
308
309/// Stage an `IfcRationalBSplineCurveWithKnots`.
310///
311/// # Errors
312///
313/// Everything [`bspline_curve_with_knots`] refuses, plus a weight count
314/// that does not match the control points: the schema requires one
315/// weight per control point.
316pub fn rational_bspline_curve_with_knots(
317    tx: &mut Transaction,
318    degree: i64,
319    control_points: &[EntityId],
320    curve_form: &str,
321    knots: KnotVector<'_>,
322    weights: &[f64],
323) -> Result<EntityId, GeometryError> {
324    const T: &str = "IFCRATIONALBSPLINECURVEWITHKNOTS";
325    if weights.len() != control_points.len() {
326        return Err(invalid(
327            T,
328            "WeightsData",
329            format!(
330                "{} weights for {} control points",
331                weights.len(),
332                control_points.len()
333            ),
334        ));
335    }
336    require_finite(T, "WeightsData", weights)?;
337    let mut attrs = bspline_attrs(T, degree, control_points, curve_form, knots)?;
338    attrs.push(Value::List(
339        weights.iter().copied().map(Value::Real).collect(),
340    ));
341    Ok(tx.create(Entity::new(T, attrs)))
342}
343
344/// Build the eight shared B-spline slots, enforcing the knot identity.
345fn bspline_attrs(
346    type_name: &'static str,
347    degree: i64,
348    control_points: &[EntityId],
349    curve_form: &str,
350    knots: KnotVector<'_>,
351) -> Result<Vec<Value>, GeometryError> {
352    if degree < 1 {
353        return Err(invalid(
354            type_name,
355            "Degree",
356            format!("expected a degree of at least 1, got {degree}"),
357        ));
358    }
359    if control_points.len() < 2 {
360        return Err(invalid(
361            type_name,
362            "ControlPointsList",
363            format!(
364                "expected at least 2 control points, got {}",
365                control_points.len()
366            ),
367        ));
368    }
369    if knots.multiplicities.len() != knots.knots.len() {
370        return Err(invalid(
371            type_name,
372            "KnotMultiplicities",
373            format!(
374                "{} multiplicities for {} knots",
375                knots.multiplicities.len(),
376                knots.knots.len()
377            ),
378        ));
379    }
380    if knots.knots.len() < 2 {
381        return Err(invalid(
382            type_name,
383            "Knots",
384            "expected at least 2 distinct knots",
385        ));
386    }
387    require_finite(type_name, "Knots", knots.knots)?;
388    if let Some(bad) = knots.multiplicities.iter().position(|m| *m < 1) {
389        return Err(invalid(
390            type_name,
391            "KnotMultiplicities",
392            format!("multiplicity at index {bad} is not positive"),
393        ));
394    }
395    if let Some(bad) = knots.knots.windows(2).position(|w| w[1] <= w[0]) {
396        return Err(invalid(
397            type_name,
398            "Knots",
399            format!("knots must strictly increase; index {bad} does not"),
400        ));
401    }
402
403    // The identity that makes the knot vector describe this curve and
404    // not some other one. See the doc comment on the public writers.
405    let total: i64 = knots.multiplicities.iter().sum();
406    let expected = degree + control_points.len() as i64 + 1;
407    if total != expected {
408        return Err(invalid(
409            type_name,
410            "KnotMultiplicities",
411            format!(
412                "multiplicities sum to {total}, but degree {degree} with {} control points requires {expected}",
413                control_points.len()
414            ),
415        ));
416    }
417
418    let mut attrs = vec![Value::Null; 8];
419    attrs[bspline_slot::DEGREE] = Value::Integer(degree);
420    attrs[bspline_slot::CONTROL_POINTS] = refs(control_points);
421    attrs[bspline_slot::CURVE_FORM] = Value::Enum(curve_form.into());
422    // ClosedCurve and SelfIntersect both need an evaluator to decide, so
423    // the honest value is IfcLogical UNKNOWN rather than a guess.
424    attrs[bspline_slot::CLOSED_CURVE] = Value::LogicalUnknown;
425    attrs[bspline_slot::SELF_INTERSECT] = Value::LogicalUnknown;
426    attrs[bspline_slot::KNOT_MULTIPLICITIES] = Value::List(
427        knots
428            .multiplicities
429            .iter()
430            .copied()
431            .map(Value::Integer)
432            .collect(),
433    );
434    attrs[bspline_slot::KNOTS] =
435        Value::List(knots.knots.iter().copied().map(Value::Real).collect());
436    attrs[bspline_slot::KNOT_SPEC] = Value::Enum(knots.spec.into());
437    Ok(attrs)
438}
439
440/// Stage an `IfcIndexedPolyCurve` over a point list.
441///
442/// Segments are optional: without them the curve is the polyline
443/// through every point in order. With them, each segment indexes into
444/// the list -- and those indices are 1-based, like everything else
445/// index-shaped in IFC.
446///
447/// # Errors
448///
449/// Refuses an index outside the point list.
450pub fn indexed_poly_curve(
451    tx: &mut Transaction,
452    points: EntityId,
453    segments: Option<&[PolyCurveSegment<'_>]>,
454    point_count: usize,
455    self_intersect: Option<bool>,
456) -> Result<EntityId, GeometryError> {
457    const T: &str = "IFCINDEXEDPOLYCURVE";
458    let mut attrs = vec![Value::Null; 3];
459    attrs[indexed_slot::POINTS] = Value::Ref(points);
460    if let Some(segments) = segments {
461        let mut list = Vec::with_capacity(segments.len());
462        for segment in segments {
463            list.push(segment.to_value(T, point_count)?);
464        }
465        attrs[indexed_slot::SEGMENTS] = Value::List(list);
466    }
467    attrs[indexed_slot::SELF_INTERSECT] = logical(self_intersect);
468    Ok(tx.create(Entity::new(T, attrs)))
469}
470
471/// One member of an `IfcSegmentIndexSelect`.
472///
473/// Indices are given 0-based here and written 1-based, matching the
474/// tessellation writers: the schema counts from one, the API does not,
475/// and the conversion happens in exactly one place.
476#[derive(Debug, Clone, Copy)]
477pub enum PolyCurveSegment<'a> {
478    /// `IfcLineIndex`: two or more points joined by straight segments.
479    Line(&'a [usize]),
480    /// `IfcArcIndex`: exactly three points -- start, on-arc, end.
481    Arc([usize; 3]),
482}
483
484impl PolyCurveSegment<'_> {
485    /// Encode as a typed list of 1-based indices.
486    fn to_value(self, type_name: &'static str, point_count: usize) -> Result<Value, GeometryError> {
487        let (label, indices): (&str, &[usize]) = match self {
488            Self::Line(indices) => ("IFCLINEINDEX", indices),
489            Self::Arc(ref indices) => ("IFCARCINDEX", indices),
490        };
491        if let Self::Line(indices) = self {
492            if indices.len() < 2 {
493                return Err(invalid(
494                    type_name,
495                    "Segments",
496                    format!(
497                        "a line index needs at least 2 points, got {}",
498                        indices.len()
499                    ),
500                ));
501            }
502        }
503        for index in indices {
504            if *index >= point_count {
505                return Err(invalid(
506                    type_name,
507                    "Segments",
508                    format!("index {index} is past the {point_count} point list"),
509                ));
510            }
511        }
512        let encoded = indices
513            .iter()
514            .map(|index| Value::Integer(*index as i64 + 1))
515            .collect();
516        Ok(Value::Typed {
517            type_name: label.into(),
518            value: Box::new(Value::List(encoded)),
519        })
520    }
521}
522
523/// The per-axis coefficient lists of an [`polynomial_curve`].
524///
525/// Each is `LIST [2:?] OF IfcReal`, lowest degree first. At least two
526/// axes must be present; see `ValidCoefficients`.
527#[derive(Debug, Clone, Copy, Default)]
528pub struct PolynomialCoefficients<'a> {
529    /// `CoefficientsX`.
530    pub x: Option<&'a [f64]>,
531    /// `CoefficientsY`.
532    pub y: Option<&'a [f64]>,
533    /// `CoefficientsZ`. Requires a 3D position.
534    pub z: Option<&'a [f64]>,
535}
536
537/// Stage an `IfcPolynomialCurve`.
538///
539/// Each coefficient list is `LIST [2:?] OF IfcReal`, lowest degree
540/// first, and at least two of the three axes must be given
541/// (`ValidCoefficients`): a curve defined along one axis alone is a
542/// line segment expressed as a curve, which the schema declines to
543/// call a polynomial curve.
544///
545/// # Errors
546///
547/// Refuses fewer than two coefficient lists, a list shorter than two
548/// entries, a non-finite coefficient, and `CoefficientsZ` against a 2D
549/// placement (`CorrectPositionDim`) when `position_is_3d` is false.
550pub fn polynomial_curve(
551    tx: &mut Transaction,
552    position: EntityId,
553    coefficients: PolynomialCoefficients<'_>,
554    position_is_3d: bool,
555) -> Result<EntityId, GeometryError> {
556    const ENTITY: &str = "IFCPOLYNOMIALCURVE";
557    let PolynomialCoefficients { x, y, z } = coefficients;
558    if z.is_some() && !position_is_3d {
559        return Err(invalid(
560            ENTITY,
561            "CoefficientsZ",
562            "a 2D position cannot carry Z coefficients",
563        ));
564    }
565    let given = [x, y, z].iter().filter(|c| c.is_some()).count();
566    if given < 2 {
567        return Err(invalid(
568            ENTITY,
569            "Coefficients",
570            "expected at least two of X, Y, Z, per ValidCoefficients",
571        ));
572    }
573    for (values, attribute) in [
574        (x, "CoefficientsX"),
575        (y, "CoefficientsY"),
576        (z, "CoefficientsZ"),
577    ] {
578        let Some(values) = values else { continue };
579        if values.len() < 2 {
580            return Err(invalid(ENTITY, attribute, "expected LIST [2:?]"));
581        }
582        require_finite(ENTITY, attribute, values)?;
583    }
584    let attrs = vec![
585        Value::Ref(position),
586        x.map_or(Value::Null, reals),
587        y.map_or(Value::Null, reals),
588        z.map_or(Value::Null, reals),
589    ];
590    Ok(tx.create(Entity::new(ENTITY, attrs)))
591}
592
593/// Stage an `IfcOffsetCurveByDistances`.
594///
595/// Unlike [`offset_curve_2d`] and [`offset_curve_3d`], which offset by
596/// one constant, this varies the offset along the basis curve: each
597/// `IfcPointByDistanceExpression` fixes a distance at a station, and
598/// the offset interpolates between them. That is what alignment
599/// widenings need -- a lay-by is not a constant offset.
600///
601/// # Errors
602///
603/// Refuses an empty `offset_values` list, which is `LIST [1:?]`.
604pub fn offset_curve_by_distances(
605    tx: &mut Transaction,
606    basis: EntityId,
607    offset_values: &[EntityId],
608    tag: Option<&str>,
609) -> Result<EntityId, GeometryError> {
610    const ENTITY: &str = "IFCOFFSETCURVEBYDISTANCES";
611    if offset_values.is_empty() {
612        return Err(invalid(
613            ENTITY,
614            "OffsetValues",
615            "expected at least one offset, per LIST [1:?]",
616        ));
617    }
618    let attrs = vec![
619        Value::Ref(basis),
620        refs(offset_values),
621        tag.map_or(Value::Null, |t| Value::Text(t.into())),
622    ];
623    Ok(tx.create(Entity::new(ENTITY, attrs)))
624}
625
626/// Stage an `IfcSegmentedReferenceCurve`.
627///
628/// A cant curve: the segments describe how a rail pair tilts along the
629/// base curve. `SelfIntersect` is an `IfcLogical`, so an unstated value
630/// is UNKNOWN rather than false -- claiming a curve does not self
631/// intersect is a different assertion from not having checked.
632///
633/// # Errors
634///
635/// Refuses an empty `segments` list, which is `LIST [1:?]`.
636pub fn segmented_reference_curve(
637    tx: &mut Transaction,
638    segments: &[EntityId],
639    self_intersect: Option<bool>,
640    base_curve: EntityId,
641    end_point: Option<EntityId>,
642) -> Result<EntityId, GeometryError> {
643    const ENTITY: &str = "IFCSEGMENTEDREFERENCECURVE";
644    if segments.is_empty() {
645        return Err(invalid(
646            ENTITY,
647            "Segments",
648            "expected at least one segment, per LIST [1:?]",
649        ));
650    }
651    let attrs = vec![
652        refs(segments),
653        logical(self_intersect),
654        Value::Ref(base_curve),
655        end_point.map_or(Value::Null, Value::Ref),
656    ];
657    Ok(tx.create(Entity::new(ENTITY, attrs)))
658}