diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 043a5cba79..53fe967ad6 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -676,6 +676,7 @@ pub fn read(s: &str) -> Result { sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; sketch.validate_patterns().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + sketch.validate_spline_knots().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; } if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind && [from, to].into_iter().any(|id| !component_ids.contains(id)) @@ -1811,7 +1812,7 @@ mod tests { let first = pts[0]; let neighbor = pts[1]; let closing = pts[3]; - sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap(); + sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true, nurbs: None }, None).unwrap(); let mut document = Document::new("periodic file validation"); document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); assert_eq!(read(&document.to_json()).unwrap(), document); diff --git a/crates/doc/src/format/pending/spline-knots.rs b/crates/doc/src/format/pending/spline-knots.rs new file mode 100644 index 0000000000..8fd839e950 --- /dev/null +++ b/crates/doc/src/format/pending/spline-knots.rs @@ -0,0 +1,6 @@ +//! A sketch's control spline may save `nurbs`: its own `knots` (clamped, the point count plus +//! the degree plus one) and `weights` (one per point; none: all 1). A projected free-form edge +//! comes in as that one exact curve, where it came in as one spline per Bézier span. An older +//! design has none: its splines are the clamped uniform B-splines they were, and its projected +//! edges are read again as they rebuild. The last release refuses a design with this change +//! (the newer format number). diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index e629aa0249..6b22d6c82c 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -587,8 +587,8 @@ fn link_geom(s: &Seg2) -> Option { LinkGeom::Arc { c: center, a, b } } Seg2::Conic { a, apex, b, w } => LinkGeom::Conic { a, apex, b, rho: w / (1. + w) }, - Seg2::Cubic { p0, p1, p2, p3 } => LinkGeom::Spline { pts: vec![p0, p1, p2, p3], control: true, degree: 3, periodic: false }, - Seg2::Bezier { n, p } => LinkGeom::Spline { pts: p.get(..=usize::from(n))?.to_vec(), control: true, degree: n, periodic: false }, + Seg2::Cubic { p0, p1, p2, p3 } => LinkGeom::Spline { pts: vec![p0, p1, p2, p3], control: true, degree: 3, periodic: false, nurbs: None }, + Seg2::Bezier { n, p } => LinkGeom::Spline { pts: p.get(..=usize::from(n))?.to_vec(), control: true, degree: n, periodic: false, nurbs: None }, }) } @@ -1088,9 +1088,10 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec (Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }], _ => Vec::new(), }, - Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => { + Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) if parallel => { let p: Option> = pts.iter().map(|q| sk.point(*q).map(map)).collect(); - p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default() + p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic, nurbs: nurbs.clone() }]) + .unwrap_or_default() } Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) { (Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }], @@ -1206,7 +1207,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option { <= tol }); if ok { - return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false }); + return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false, nurbs: None }); } } None diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs index a12510aaf9..6542c12fa8 100644 --- a/crates/doc/src/project3d.rs +++ b/crates/doc/src/project3d.rs @@ -247,7 +247,9 @@ fn link_polyline(g: &solidcraft_sketch::LinkGeom) -> Vec { LinkGeom::Ellipse { c, major, minor } => { (0..=96).map(|i| solidcraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect() } - LinkGeom::Spline { ref pts, control, degree, periodic } => solidcraft_sketch::spline_polyline_mode(pts, control, degree, periodic), + LinkGeom::Spline { ref pts, control, degree, periodic, ref nurbs } => { + solidcraft_sketch::spline_polyline_mode(pts, control, degree, periodic, nurbs.as_ref()) + } LinkGeom::Point(_) => Vec::new(), } } diff --git a/crates/doc/src/project_order.rs b/crates/doc/src/project_order.rs index 2964468c13..70b8a736d9 100644 --- a/crates/doc/src/project_order.rs +++ b/crates/doc/src/project_order.rs @@ -251,7 +251,13 @@ mod tests { vec![ line(0., 1.), line(1., 2.), - LinkGeom::Spline { pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], control: false, degree: 2, periodic: false }, + LinkGeom::Spline { + pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], + control: false, + degree: 2, + periodic: false, + nurbs: None, + }, ], vec![line(0., 1.), LinkGeom::Arc { c: Vec2::new(1., 1.), a: Vec2::new(1., 0.), b: Vec2::new(2., 1.) }], vec![line(0., 1.), line(1.00001, 2.)], diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 189e7e6817..3bb5c09d9d 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -186,7 +186,17 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), - CurveKind::Spline { ref pts, control, degree, periodic } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => { + let mut v = json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}); + // A control spline with its own knots (a projected free-form edge) says them. + if let (Some(n), Some(o)) = (nurbs, v.as_object_mut()) { + o.insert("knots".into(), json!(n.knots)); + if !n.weights.is_empty() { + o.insert("weights".into(), json!(n.weights)); + } + } + v + } CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), }; if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) { diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 6088487029..eac8769427 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -505,7 +505,7 @@ fn blend(s: &mut Session, p: &Value) -> Result { let (sp, cons) = if order <= 1 { let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; - let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false, nurbs: None }, None)?; let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; (sp, cons) } else { @@ -527,7 +527,7 @@ fn blend(s: &mut Session, p: &Value) -> Result { }); } let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?; - let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false }, None)?; + let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false, nurbs: None }, None)?; (sp, Vec::new()) }; solve_new_only(sk, first_new); @@ -655,7 +655,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R { pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?); } - let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic }, None)?; + let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic, nurbs: None }, None)?; mark_construction(sk, &[c], p); Ok((ids_of(sk, &[c]), Vec::new())) })?; @@ -1344,13 +1344,13 @@ mod blend_tests { let mut broken = solidcraft_sketch::Sketch::new(); let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap(); let p1 = broken.add_point(Vec2::ZERO, None).unwrap(); - let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false }, None).unwrap(); + let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false, nurbs: None }, None).unwrap(); broken.points[p0].pos = Vec2::new(f64::NAN, 4.0); assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry"); let mut flat = solidcraft_sketch::Sketch::new(); let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); - let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false }, None).unwrap(); + let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false, nurbs: None }, None).unwrap(); let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string(); assert!(e.contains("no tangent"), "{e}"); assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4"); diff --git a/crates/engine/src/cmd/sketch_freeform.rs b/crates/engine/src/cmd/sketch_freeform.rs index 6d8763a33c..ceda14e56a 100644 --- a/crates/engine/src/cmd/sketch_freeform.rs +++ b/crates/engine/src/cmd/sketch_freeform.rs @@ -156,7 +156,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< Seg2::Line { .. } => CurveKind::Line { a: prev, b: to }, Seg2::Cubic { p1, p2, .. } => { let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?); - CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false } + CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false, nurbs: None } } Seg2::Conic { apex, w, .. } => { let x = sk.add_point(apex, None)?; @@ -169,7 +169,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< pts.push(sk.add_point(*q, None)?); } pts.push(to); - CurveKind::Spline { pts, control: true, degree: n, periodic: false } + CurveKind::Spline { pts, control: true, degree: n, periodic: false, nurbs: None } } Seg2::Arc { .. } => return Err(bad("trim", "unexpected arc piece")), }; diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index c7efde07a0..d9d3e9c505 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -101,7 +101,7 @@ pub(super) fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> idx.push(i); } } - (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false }) + (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false, nurbs: None }) } Geom2::Conic { a, apex, b, rho } => { let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?); diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 482b1cce9e..f42a9959c4 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -1764,7 +1764,7 @@ fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[( idx.push(pb); idx.dedup(); if idx.len() >= 2 { - made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?); + made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false, nurbs: None }, None)?); } } Piece::Line(..) => { diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index 66a7fb2725..e35bc07116 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -190,7 +190,12 @@ pub fn fit_beziers(p: &[Vec2]) -> Vec<[Vec2; 4]> { pub fn fit_beziers_mode(p: &[Vec2], periodic: bool) -> crate::model::Result> { if periodic { crate::periodic_beziers(p) } else { Ok(fit_beziers(p)) } } -pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, t: f64) -> Vec2 { +/// Point of a spline at `t` in [0, 1]: through its points, over them as a clamped uniform +/// B-spline, or with its own knots and weights (`nurbs`). +pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, nurbs: Option<&crate::Nurbs>, t: f64) -> Vec2 { + if let Some(nurbs) = nurbs { + return nurbs.point(p, degree, t).unwrap_or_else(|| p.first().copied().unwrap_or_default()); + } if !periodic { return spline_point(p, control, degree, t); } @@ -211,7 +216,11 @@ pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, let t = (remaining / lengths[i]).clamp(0.0, 1.0); a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3) } -pub fn spline_polyline_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool) -> Vec { +/// Polyline of a spline, as [`spline_point_mode`] reads it. +pub fn spline_polyline_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, nurbs: Option<&crate::Nurbs>) -> Vec { + if let Some(nurbs) = nurbs { + return nurbs.polyline(p, degree); + } if !periodic { return spline_polyline(p, control, degree); } @@ -307,6 +316,7 @@ pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec { } /// Unit tangent of a spline at its start (`at_end == false`) or end, pointing along the curve. +/// A control spline's runs along its end leg whatever its knots and (positive) weights. pub fn spline_end_tangent(p: &[Vec2], control: bool, at_end: bool) -> Option { let n = p.len(); if n < 2 { diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs index fde3254ae7..68bf0d8e6d 100644 --- a/crates/sketch/src/freeform_offset.rs +++ b/crates/sketch/src/freeform_offset.rs @@ -439,7 +439,7 @@ impl Sketch { } let output = self.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?; match &self.curves[output].kind { - CurveKind::Spline { pts, control: false, degree: 3, periodic } + CurveKind::Spline { pts, control: false, degree: 3, periodic, .. } if *periodic == (record.certificate == 2) && (record.certificate != 2 || record.closed) && pts.len() == record.pieces * record.divisions + 1 @@ -484,7 +484,7 @@ impl Sketch { p.fixed = true; } } - let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: closed }, None)?; + let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: closed, nurbs: None }, None)?; candidate.freeform_offsets.push(FreeformOffset { source: source_id, output: candidate.curves[output].id.clone(), @@ -551,7 +551,7 @@ impl Sketch { return Err(bad("offset source topology changed")); } let old_indices = match &candidate.curves[output].kind { - CurveKind::Spline { pts, control: false, degree: 3, periodic } + CurveKind::Spline { pts, control: false, degree: 3, periodic, .. } if *periodic == (record.certificate == 2) && (record.certificate != 2 || record.closed) && pts.len() == record.pieces * record.divisions + 1 => @@ -583,7 +583,8 @@ impl Sketch { p.pos = pos; p.fixed = true; } - candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: record.certificate == 2 }; + candidate.curves[output].kind = + CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: record.certificate == 2, nurbs: None }; candidate.freeform_offsets[record_index].divisions = divisions; candidate.freeform_offsets[record_index].pieces = pieces; } @@ -632,7 +633,7 @@ mod tests { .into_iter() .map(|(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()) .collect(); - let source = sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap(); + let source = sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true, nurbs: None }, None).unwrap(); let original = sk.clone(); for (forward, distance) in [(true, 2.0), (false, -2.0)] { let mut sk = original.clone(); diff --git a/crates/sketch/src/freeform_offset/trim.rs b/crates/sketch/src/freeform_offset/trim.rs index 380f7ec710..070e3b60d0 100644 --- a/crates/sketch/src/freeform_offset/trim.rs +++ b/crates/sketch/src/freeform_offset/trim.rs @@ -1080,7 +1080,7 @@ impl Sketch { pts.push(candidate.add_point(*p, None)?); } pts.push(end); - CurveKind::Spline { pts, control: false, degree: 3, periodic: false } + CurveKind::Spline { pts, control: false, degree: 3, periodic: false, nurbs: None } } Out::Arc { center, sweep, .. } => { let c = candidate.add_point(*center, None)?; @@ -1111,7 +1111,7 @@ mod tests { fn spline(coords: &[(f64, f64)], periodic: bool) -> (Sketch, usize) { let mut sk = Sketch::new(); let pts = coords.iter().map(|(x, y)| sk.add_point(Vec2::new(*x, *y), None).unwrap()).collect(); - let source = sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic }, None).unwrap(); + let source = sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic, nurbs: None }, None).unwrap(); (sk, source) } @@ -1210,8 +1210,9 @@ mod tests { for (k, b) in beziers.iter().enumerate() { let mid = [1, 2].map(|i| sk.add_point(Vec2::new(b[i].0, b[i].1), None).unwrap()); let end = if k + 1 == beziers.len() { first } else { sk.add_point(Vec2::new(b[3].0, b[3].1), None).unwrap() }; - let c = - sk.add_curve(CurveKind::Spline { pts: vec![start, mid[0], mid[1], end], control: true, degree: 3, periodic: false }, None).unwrap(); + let c = sk + .add_curve(CurveKind::Spline { pts: vec![start, mid[0], mid[1], end], control: true, degree: 3, periodic: false, nurbs: None }, None) + .unwrap(); order.push((c, true)); start = end; } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 91d2ec79c8..ca01480a8a 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -27,6 +27,7 @@ pub mod linalg; mod link; mod loci; mod model; +mod nurbs; mod offset; mod offset_topology; mod pattern; @@ -55,6 +56,7 @@ pub use model::{ Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_COORD, MAX_CURVES, MAX_POINTS, PointExprs, SPoint, Sketch, SketchError, SketchView, TwoCircles, }; +pub use nurbs::Nurbs; pub use offset::OffsetChain; pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology}; pub use pattern::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index ca5a576261..d8b49172a6 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -238,12 +238,14 @@ pub enum LinkGeom { major: Vec2, minor: f64, }, - /// Spline through (`control == false`) or over (`control`) the points. + /// Spline through (`control == false`) or over (`control`) the points; a control spline + /// with its own knots and weights where it has them ([`crate::Nurbs`]). Spline { pts: Vec, control: bool, degree: u8, periodic: bool, + nurbs: Option, }, } @@ -257,8 +259,9 @@ impl LinkGeom { LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL, LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6, LinkGeom::Ellipse { c, major, minor } => ok(c) && ok(major) && c.dist(*major) > 1e-9 && radius_in_range(*minor), - LinkGeom::Spline { pts, control, degree, periodic } => { + LinkGeom::Spline { pts, control, degree, periodic, nurbs } => { (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) + && nurbs.as_ref().is_none_or(|k| *control && !*periodic && k.check(pts.len(), *degree).is_ok()) && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) && (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree) @@ -275,7 +278,7 @@ struct Layout { curves: Vec, } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] enum LCurve { Line(usize, usize), Circle(usize, f64), @@ -283,8 +286,8 @@ enum LCurve { Conic(usize, usize, usize, f64), Ellipse(usize, usize, f64), /// Start, end, first interior point and interior count (interior points are consecutive - /// and never shared), control flag, degree. - Spline(usize, usize, usize, usize, bool, u8, bool), + /// and never shared), control flag, degree, periodic, own knots and weights. + Spline(usize, usize, usize, usize, bool, u8, bool, Option), } fn layout(geom: &[LinkGeom]) -> Result { @@ -339,14 +342,14 @@ fn layout(geom: &[LinkGeom]) -> Result { l.curves.push(LCurve::Ellipse(c, m, minor)); } } - LinkGeom::Spline { pts, control, degree, periodic } => { + LinkGeom::Spline { pts, control, degree, periodic, nurbs } => { let (Some(first), Some(last)) = (pts.first(), pts.last()) else { continue }; let s0 = pt(&mut l.pts, *first); let first_in = l.pts.len(); let inner = pts.get(1..pts.len() - 1).unwrap_or_default(); l.pts.extend(inner.iter().copied()); let s1 = pt(&mut l.pts, *last); - l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree, periodic)); + l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree, periodic, nurbs)); } } } @@ -402,6 +405,16 @@ fn same_shape(a: &LCurve, b: &LCurve) -> bool { ) } +/// Whether two splines' own knots and weights ([`crate::Nurbs`]) are the same within `close`. +fn same_nurbs(a: Option<&crate::Nurbs>, b: Option<&crate::Nurbs>, close: impl Fn(f64, f64) -> bool) -> bool { + let all = |x: &[f64], y: &[f64]| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| close(*p, *q)); + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => all(&a.knots, &b.knots) && all(&a.weights, &b.weights), + _ => false, + } +} + /// Do two curve sets describe the same geometry, regardless of the order the curves come in or /// the direction a loop was walked? Lines match with either end first; circles by centre and /// radius; arcs by centre and their (start, end) pair; conics by all their points and rho. @@ -422,7 +435,9 @@ fn same_geometry(a: &[(LCurve, Vec)], b: &[(LCurve, Vec)]) -> bool { (LCurve::Arc(..), LCurve::Arc(..)) => all(), (LCurve::Conic(.., ra), LCurve::Conic(.., rb)) => all() && close(*ra, *rb), (LCurve::Ellipse(.., ra), LCurve::Ellipse(.., rb)) => all() && close(*ra, *rb), - (LCurve::Spline(.., ca, da, pa), LCurve::Spline(.., cb, db, pb)) => ca == cb && da == db && pa == pb && all(), + (LCurve::Spline(.., ca, da, pa, ka), LCurve::Spline(.., cb, db, pb, kb)) => { + ca == cb && da == db && pa == pb && same_nurbs(ka.as_ref(), kb.as_ref(), close) && all() + } _ => false, } }; @@ -666,10 +681,10 @@ impl Sketch { let at = |i: usize| self.points.get(i).map(|p| p.pos); let near = |i: usize, q: Option| lay.pts.get(i).zip(q).is_some_and(|(p, q)| p.dist(q) <= MERGE_TOL); let curve_present = |c: &LCurve| { - self.curves.iter().filter(|k| k.link.is_some()).any(|k| match (*c, &k.kind) { - (LCurve::Line(a, b), CurveKind::Line { a: p, b: q }) => (near(a, at(*p)) && near(b, at(*q))) || (near(a, at(*q)) && near(b, at(*p))), - (LCurve::Circle(c, r), CurveKind::Circle { c: p, r: s }) => near(c, at(*p)) && (r - s).abs() <= MERGE_TOL, - (LCurve::Arc(c, a, b), CurveKind::Arc { c: p, a: q, b: e }) => near(c, at(*p)) && near(a, at(*q)) && near(b, at(*e)), + self.curves.iter().filter(|k| k.link.is_some()).any(|k| match (c, &k.kind) { + (&LCurve::Line(a, b), CurveKind::Line { a: p, b: q }) => (near(a, at(*p)) && near(b, at(*q))) || (near(a, at(*q)) && near(b, at(*p))), + (&LCurve::Circle(c, r), CurveKind::Circle { c: p, r: s }) => near(c, at(*p)) && (r - s).abs() <= MERGE_TOL, + (&LCurve::Arc(c, a, b), CurveKind::Arc { c: p, a: q, b: e }) => near(c, at(*p)) && near(a, at(*q)) && near(b, at(*e)), _ => false, }) }; @@ -716,13 +731,13 @@ impl Sketch { LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"), LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"), LCurve::Ellipse(c, m, r) => (CurveKind::Ellipse { c: point(self, c)?, m: point(self, m)?, r }, "e"), - LCurve::Spline(a, b, f, n, control, degree, periodic) => { + LCurve::Spline(a, b, f, n, control, degree, periodic, ref nurbs) => { let mut pts = vec![point(self, a)?]; for i in f..f + n { pts.push(point(self, i)?); } pts.push(point(self, b)?); - (CurveKind::Spline { pts, control, degree, periodic }, "s") + (CurveKind::Spline { pts, control, degree, periodic, nurbs: nurbs.clone() }, "s") } }; let cid = self.fresh(prefix)?; @@ -775,7 +790,7 @@ impl Sketch { Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)), Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)), Some(CurveKind::Ellipse { c, m, r }) => Some(LCurve::Ellipse(local(*c)?, local(*m)?, *r)), - Some(CurveKind::Spline { pts, control, degree, periodic }) => { + Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) => { let (a, b) = (local(*pts.first()?)?, local(*pts.last()?)?); let inner: Vec = pts.get(1..pts.len().saturating_sub(1)).unwrap_or_default().iter().map(|q| local(*q)).collect::>()?; @@ -784,7 +799,7 @@ impl Sketch { if inner.iter().enumerate().any(|(k, x)| *x != f + k) { return None; } - Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic)) + Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic, nurbs.clone())) } _ => None, }) @@ -840,7 +855,7 @@ impl Sketch { LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)), LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r), LCurve::Ellipse(c, m, r) => LCurve::Ellipse(renum(c), renum(m), r), - LCurve::Spline(a, b, f, n, ct, d, periodic) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic), + LCurve::Spline(a, b, f, n, ct, d, periodic, ref k) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic, k.clone()), }) .collect(); let same = match &cur { @@ -855,8 +870,15 @@ impl Sketch { (LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Ellipse(a0, a1, _), LCurve::Ellipse(b0, b1, _)) => a0 == b0 && a1 == b1, - (LCurve::Spline(a0, a1, a2, a3, a4, a5, a6), LCurve::Spline(b0, b1, b2, b3, b4, b5, b6)) => { - a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 && a6 == b6 + (LCurve::Spline(a0, a1, a2, a3, a4, a5, a6, a7), LCurve::Spline(b0, b1, b2, b3, b4, b5, b6, b7)) => { + a0 == b0 + && a1 == b1 + && (a2 == b2 || *a3 == 0) + && a3 == b3 + && a4 == b4 + && a5 == b5 + && a6 == b6 + && a7.is_some() == b7.is_some() } _ => false, } @@ -873,12 +895,14 @@ impl Sketch { let pos = |pi: usize| self.points.get(pi).map(|p| p.pos); let old: Option)>> = cur .iter() - .map(|c| curve_points(c).into_iter().map(|li| points.get(li).and_then(|pi| pos(*pi))).collect::>>().map(|ps| (*c, ps))) + .map(|c| { + curve_points(c).into_iter().map(|li| points.get(li).and_then(|pi| pos(*pi))).collect::>>().map(|ps| (c.clone(), ps)) + }) .collect(); let fresh: Option)>> = lay .curves .iter() - .map(|c| curve_points(c).into_iter().map(|li| lay.pts.get(li).copied()).collect::>>().map(|ps| (*c, ps))) + .map(|c| curve_points(c).into_iter().map(|li| lay.pts.get(li).copied()).collect::>>().map(|ps| (c.clone(), ps))) .collect(); let lone_old: Vec = points.iter().filter_map(|pi| pos(*pi)).collect(); if let (Some(old), Some(fresh)) = (old, fresh) @@ -1028,6 +1052,11 @@ impl Sketch { { *r = *value; } + if let (Some(Curve { kind: CurveKind::Spline { nurbs, .. }, .. }), Some(LCurve::Spline(.., value))) = + (self.curves.get_mut(*ci), lay.curves.get(li)) + { + nurbs.clone_from(value); + } } return Ok(false); } @@ -1056,6 +1085,9 @@ impl Sketch { if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { *slot = *r; } + if let (LCurve::Spline(.., k), Some(Curve { kind: CurveKind::Spline { nurbs, .. }, .. })) = (c, self.curves.get_mut(*ci)) { + nurbs.clone_from(k); + } } return Ok(false); } diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 29190e515d..15a9c402ba 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -104,6 +104,10 @@ pub enum CurveKind { /// Explicit cyclic C2 fit interpolation; absent retains natural endpoints. #[serde(default, skip_serializing_if = "std::ops::Not::not")] periodic: bool, + /// A control spline's own knots and weights (a projected free-form edge as the exact + /// curve it is); none: the clamped uniform B-spline over its points, all weights 1. + #[serde(default, skip_serializing_if = "Option::is_none")] + nurbs: Option, }, /// Conic from `a` to `b` shaped by the `apex` point and `rho` in (0, 1) (0.5 parabola, /// less elliptical, more hyperbolic). @@ -1463,8 +1467,9 @@ impl Sketch { } /// Exact boundary segments of a free-form curve: ellipses as four conics, conics, fit - /// splines and control splines up to `MAX_BEZIER_DEGREE` as Béziers. None for other curves - /// (and for higher-degree splines, which stay polylines). + /// splines and control splines up to `MAX_BEZIER_DEGREE` as Béziers (one a knot span for a + /// spline with its own knots, conics for a rational quadratic one). None for other curves + /// (and for higher-degree or other rational splines, which stay polylines). pub fn exact_segs(&self, ci: usize) -> Option> { let cu = self.curves.get(ci)?; let pt = |i: &usize| self.point(*i); @@ -1491,7 +1496,11 @@ impl Sketch { crate::curves::fit_beziers_mode(&p, *periodic).ok()?.into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect(), ) } - CurveKind::Spline { pts, control: true, degree, .. } if (*degree as usize) <= solidcraft_geom::MAX_BEZIER_DEGREE => { + CurveKind::Spline { pts, control: true, degree, nurbs: Some(nurbs), .. } => { + let p: Vec = pts.iter().map(pt).collect::>()?; + nurbs.segs(&p, *degree) + } + CurveKind::Spline { pts, control: true, degree, nurbs: None, .. } if (*degree as usize) <= solidcraft_geom::MAX_BEZIER_DEGREE => { let p: Vec = pts.iter().map(pt).collect::>()?; let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1); Some(crate::curves::bspline_beziers(&p, k).iter().filter_map(|c| solidcraft_geom::bezier(c)).collect()) @@ -1521,9 +1530,9 @@ impl Sketch { (Some(c), Some(m)) => crate::curves::ellipse_polyline(c, m, *r), _ => Vec::new(), }, - CurveKind::Spline { pts, control, degree, periodic } => { + CurveKind::Spline { pts, control, degree, periodic, nurbs } => { let p: Option> = pts.iter().map(pt).collect(); - p.map(|p| crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)).unwrap_or_default() + p.map(|p| crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic, nurbs.as_ref())).unwrap_or_default() } CurveKind::Conic { a, b, apex, rho } => match (pt(a), pt(apex), pt(b)) { (Some(a), Some(x), Some(b)) => crate::curves::conic_polyline(a, x, b, *rho), @@ -1559,7 +1568,13 @@ impl Sketch { } "e" } - CurveKind::Spline { pts, control, degree, periodic } => { + CurveKind::Spline { pts, control, degree, periodic, nurbs } => { + if let Some(nurbs) = nurbs { + if !*control || *periodic { + return Err(SketchError::Invalid("only an open control spline has its own knots".into())); + } + nurbs.check(pts.len(), *degree)?; + } if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree) { return Err(SketchError::Invalid(format!( "a spline needs 2 to 500 points and degree 1 to {}", diff --git a/crates/sketch/src/nurbs.rs b/crates/sketch/src/nurbs.rs new file mode 100644 index 0000000000..a3eae65110 --- /dev/null +++ b/crates/sketch/src/nurbs.rs @@ -0,0 +1,200 @@ +//! A control spline's own knots and weights ([`Nurbs`]): the exact form of a free-form edge a +//! sketch projects, whose knots need not be evenly spaced and whose weights need not be 1. A +//! control spline without them is the clamped uniform B-spline over its points. + +use serde::{Deserialize, Serialize}; +use solidcraft_geom::bspline::{self, HPoint}; +use solidcraft_geom::{Seg2, Vec2, Vec3}; + +use crate::model::{Result, SketchError}; + +/// Most knots a spline may have (its points are capped at [`crate::MAX_SPLINE_POINTS`]). +const MAX_KNOTS: usize = crate::curves::MAX_SPLINE_POINTS + solidcraft_geom::MAX_BEZIER_DEGREE + 1; +/// Most a weight may exceed another by: past it the curve is all but its heaviest points. +const MAX_WEIGHT_RATIO: f64 = 1e12; +/// Polyline samples per knot span. +const SPAN_SAMPLES: usize = 24; + +/// The knot vector and weights of a control spline of `degree` over `n` points: clamped (each +/// end knot `degree + 1` times, no interior knot more than `degree` times), `n + degree + 1` +/// non-decreasing knots, and one positive weight per point (none: all 1). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Nurbs { + pub knots: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub weights: Vec, +} + +fn bad(why: &str) -> Result { + Err(SketchError::Invalid(format!("spline knots: {why}"))) +} + +impl Nurbs { + /// Whether these knots and weights fit a control spline of `degree` over `n` points. + pub fn check(&self, n: usize, degree: u8) -> Result<()> { + let d = usize::from(degree); + if !(1..=solidcraft_geom::MAX_BEZIER_DEGREE).contains(&d) || n <= d || n > crate::curves::MAX_SPLINE_POINTS { + return bad("a knotted spline needs more points than its degree, 1 to 9"); + } + let k = &self.knots; + if k.len() != n + d + 1 || k.len() > MAX_KNOTS { + return bad("a spline needs its point count plus its degree plus one knots"); + } + if k.iter().any(|x| !x.is_finite()) || k.windows(2).any(|w| w[1] < w[0]) { + return bad("knots must be finite and never decrease"); + } + let (lo, hi) = (k[d], k[n]); + let span = hi - lo; + if !(span > 0.0 && span.is_finite()) || k[..=d].iter().any(|x| *x != lo) || k[n..].iter().any(|x| *x != hi) { + return bad("the knots must be clamped at both ends"); + } + let mut run = 0usize; + for (i, x) in k.iter().enumerate().take(n).skip(d + 1) { + run = if k.get(i - 1) == Some(x) { run + 1 } else { 1 }; + if *x == lo || *x == hi || run > d { + return bad("no knot inside the spline may repeat more than its degree"); + } + } + if !self.weights.is_empty() { + if self.weights.len() != n || self.weights.iter().any(|w| !(w.is_finite() && *w > 0.0)) { + return bad("a rational spline needs one positive weight per point"); + } + let (min, max) = self.weights.iter().fold((f64::INFINITY, 0.0f64), |(a, b), w| (a.min(*w), b.max(*w))); + if max / min > MAX_WEIGHT_RATIO { + return bad("the weights differ too much"); + } + } + Ok(()) + } + + fn weight(&self, i: usize) -> f64 { + self.weights.get(i).copied().unwrap_or(1.0) + } + + /// The control points `p` with their weights, homogeneous. + fn homogeneous(&self, p: &[Vec2]) -> Vec { + p.iter().enumerate().map(|(i, q)| HPoint::weighted(Vec3::new(q.x, q.y, 0.0), self.weight(i))).collect() + } + + /// The knot domain `[lo, hi]` for `n` points of `degree`. + fn domain(&self, n: usize, degree: usize) -> Option<(f64, f64)> { + Some((*self.knots.get(degree)?, *self.knots.get(n)?)) + } + + /// The same curve run the other way: the knots mirrored, the weights reversed (the points + /// reverse with them). + pub fn reversed(&self) -> Nurbs { + let sum = self.knots.first().copied().unwrap_or(0.0) + self.knots.last().copied().unwrap_or(0.0); + Nurbs { knots: self.knots.iter().rev().map(|k| sum - k).collect(), weights: self.weights.iter().rev().copied().collect() } + } + + /// The point of the spline over `p` (of `degree`) at `t` in [0, 1] across its knot domain. + /// `None` when the knots don't fit the points. + pub fn point(&self, p: &[Vec2], degree: u8, t: f64) -> Option { + self.check(p.len(), degree).ok()?; + let d = usize::from(degree); + let (lo, hi) = self.domain(p.len(), d)?; + self.eval(&self.homogeneous(p), d, lo + (hi - lo) * t.clamp(0.0, 1.0)) + } + + /// The point at knot parameter `u` of the spline of degree `d` over the homogeneous points + /// `h` (de Boor), the knots already checked. + fn eval(&self, h: &[HPoint], d: usize, u: f64) -> Option { + let k = &self.knots; + let n = h.len(); + // The span holding u: the last knot ≤ u that starts a non-empty span. + let mut s = d; + while s + 1 < n && k.get(s + 1).is_some_and(|x| *x <= u) { + s += 1; + } + let mut c: Vec = h.get(s - d..=s)?.to_vec(); + for r in 1..=d { + for j in (r..=d).rev() { + let i = s + j - d; + let (a, b) = (k.get(i).copied()?, k.get(i + d + 1 - r).copied()?); + let alpha = if b > a { (u - a) / (b - a) } else { 0.0 }; + c[j] = c[j - 1] * (1.0 - alpha) + c[j] * alpha; + } + } + let q = c.get(d)?.project()?; + Some(Vec2::new(q.x, q.y)) + } + + /// The Bézier spans of the spline over `p`, homogeneous, in parameter order: every + /// interior knot inserted until it repeats `degree` times (Boehm). Exact up to rounding. + fn spans(&self, p: &[Vec2], degree: u8) -> Option>> { + self.check(p.len(), degree).ok()?; + let d = usize::from(degree); + let (mut k, mut c) = (self.knots.clone(), self.homogeneous(p)); + let (lo, hi) = self.domain(p.len(), d)?; + let mut interior: Vec = self.knots.iter().copied().filter(|x| *x > lo && *x < hi).collect(); + interior.dedup(); + for u in interior { + while k.iter().filter(|x| **x == u).count() < d { + (k, c) = bspline::insert_knot(d, &k, &c, u).ok()?; + } + } + let mut spans = Vec::new(); + for i in d..c.len() { + if k.get(i)? < k.get(i + 1)? { + spans.push(c.get(i - d..=i)?.to_vec()); + } + } + Some(spans) + } + + /// The spline over `p` as its exact [`Seg2`] pieces, one a knot span: Béziers, or conics + /// for a rational quadratic. `None` for other rational splines, which have no such pieces. + pub fn segs(&self, p: &[Vec2], degree: u8) -> Option> { + let spans = self.spans(p, degree)?; + let rational = self.weights.windows(2).any(|w| w[0] != w[1]); + let flat = |q: &HPoint| q.project().map(|v| Vec2::new(v.x, v.y)); + spans + .iter() + .map(|span| { + let pts: Vec = span.iter().map(flat).collect::>()?; + match (rational, pts.as_slice()) { + (false, _) => solidcraft_geom::bezier(&pts), + (true, [a, apex, b]) => { + let w = span.get(1)?.w / (span.first()?.w * span.get(2)?.w).sqrt(); + (w.is_finite() && w > 0.0).then_some(Seg2::Conic { a: *a, apex: *apex, b: *b, w }) + } + _ => None, + } + }) + .collect() + } + + /// The spline over `p`, sampled evenly within each knot span. + pub fn polyline(&self, p: &[Vec2], degree: u8) -> Vec { + let d = usize::from(degree); + let Some((lo, hi)) = self.check(p.len(), degree).ok().and_then(|()| self.domain(p.len(), d)) else { return Vec::new() }; + let mut breaks: Vec = self.knots.iter().copied().filter(|x| *x >= lo && *x <= hi).collect(); + breaks.dedup(); + let h = self.homogeneous(p); + let mut out = Vec::with_capacity(breaks.len() * SPAN_SAMPLES + 1); + for w in breaks.windows(2) { + for i in 0..SPAN_SAMPLES { + out.extend(self.eval(&h, d, w[0] + (w[1] - w[0]) * i as f64 / SPAN_SAMPLES as f64)); + } + } + out.extend(self.eval(&h, d, hi)); + out + } +} + +impl crate::Sketch { + /// Every spline's own knots and weights fit it: on an open control spline, the right count, + /// clamped, with positive weights (a loaded design is checked before it is used). + pub fn validate_spline_knots(&self) -> Result<()> { + for curve in &self.curves { + if let crate::CurveKind::Spline { pts, control, degree, periodic, nurbs: Some(nurbs) } = &curve.kind { + if !*control || *periodic { + return bad("only an open control spline has its own knots"); + } + nurbs.check(pts.len(), *degree)?; + } + } + Ok(()) + } +} diff --git a/crates/sketch/src/periodic.rs b/crates/sketch/src/periodic.rs index 698e58a585..b4f4ff2f42 100644 --- a/crates/sketch/src/periodic.rs +++ b/crates/sketch/src/periodic.rs @@ -102,7 +102,7 @@ impl Sketch { pub fn validate_periodic_splines(&self) -> Result<()> { let mut work = 0usize; for curve in &self.curves { - if let crate::CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { + if let crate::CurveKind::Spline { pts, control, degree, periodic: true, .. } = &curve.kind { work = work.saturating_add(pts.len()); if *control || *degree != 3 || work > 1_000_000 || pts.len() > MAX_SPLINE_POINTS { return Err(bad()); @@ -136,7 +136,7 @@ mod tests { (legacy[0][1] - legacy[0][0]).normalized().unwrap().dist((legacy.last().unwrap()[3] - legacy.last().unwrap()[2]).normalized().unwrap()) > 0.01 ); - let global = |t| crate::spline_point_mode(&p, false, 3, true, t); + let global = |t| crate::spline_point_mode(&p, false, 3, true, None, t); let h = 1e-5; assert!(((global(h) - global(0.0)) / h - (global(1.0) - global(1.0 - h)) / h).len() < 0.002); } @@ -160,8 +160,9 @@ mod tests { let c = sk.add_point(Vec2::new(-4.0, 0.0), None).unwrap(); let d = sk.add_point(Vec2::new(-3.0, -2.0), None).unwrap(); let last = sk.add_point(Vec2::ZERO, None).unwrap(); - sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap(); - sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap(); + sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true, nurbs: None }, None) + .unwrap(); + sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true, nurbs: None }, None).unwrap(); sk.points[first].pos = Vec2::new(0.2, 0.1); assert!(crate::solve_with(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }, None, crate::Analysis::Full, None).ok()); assert_eq!(sk.point(first), sk.point(seam)); @@ -186,7 +187,7 @@ mod tests { let p = vec![Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::ZERO]; let mut sk = Sketch::new(); let source = crate::LinkSource::SketchCurve { sketch: 99, curve: "source".into() }; - let geometry = crate::LinkGeom::Spline { pts: p.clone(), control: false, degree: 3, periodic: true }; + let geometry = crate::LinkGeom::Spline { pts: p.clone(), control: false, degree: 3, periodic: true, nurbs: None }; let id = sk.add_link(crate::LinkKind::Project, source, &[geometry]).unwrap(); let curve = sk.link_curves(&id)[0]; assert!(matches!(sk.curves[curve].kind, crate::CurveKind::Spline { periodic: true, .. })); @@ -197,7 +198,8 @@ mod tests { let mut singular = Sketch::new(); let points = [Vec2::ZERO, Vec2::X, Vec2::ZERO, Vec2::new(0.0, 1.0), Vec2::ZERO].into_iter().map(|p| singular.add_point(p, None).unwrap()).collect(); - let curve = singular.add_curve(crate::CurveKind::Spline { pts: points, control: false, degree: 3, periodic: true }, None).unwrap(); + let curve = + singular.add_curve(crate::CurveKind::Spline { pts: points, control: false, degree: 3, periodic: true, nurbs: None }, None).unwrap(); let before = singular.clone(); assert!(singular.add_freeform_offset(curve, true, 0.1).is_err()); assert_eq!(singular, before); diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 40ea39a1e4..c714ae5345 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -258,9 +258,9 @@ impl State<'_> { }; crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r) } - Some(CurveKind::Spline { pts, control, degree, periodic }) => { + Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) => { let p = self.spline_points(pts, *periodic); - crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic) + crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic, nurbs.as_ref()) } Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho), _ => Vec::new(), @@ -293,12 +293,14 @@ impl State<'_> { let y = delta.cross(u) / (a * r); return (x.hypot(y) - 1.0) * a.min(r); } - Some(CurveKind::Spline { pts, control, degree, periodic }) => { + Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) => { let p = self.spline_points(pts, *periodic); if p.len() < 2 { return q.dist(p.first().copied().unwrap_or_default()); } - if *control { + if let Some(nurbs) = nurbs { + nurbs.segs(&p, *degree).unwrap_or_default() + } else if *control { let k = (*degree as usize).min(solidcraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1); crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solidcraft_geom::bezier(span)).collect() } else { @@ -393,10 +395,10 @@ impl State<'_> { let (cc, r) = self.round(c); (cc - self.p(p)) / (r * r).max(1e-24) } - CurveKind::Spline { pts, control, degree, periodic } => { + CurveKind::Spline { pts, control, degree, periodic, nurbs } => { let q = self.spline_points(pts, *periodic); let at_end = pts.last() == Some(&p) && pts.first() != Some(&p); - crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, t), at_end) + crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, nurbs.as_ref(), t), at_end) } CurveKind::Conic { a, b, apex, rho } => { let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex)); @@ -2938,7 +2940,7 @@ fn solve_inner( } } for curve in &sk.curves { - if let CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { + if let CurveKind::Spline { pts, control, degree, periodic: true, .. } = &curve.kind { let points = pts.iter().map(|i| positions.get(*i).copied()).collect::>>(); if *control || *degree != 3 || points.as_ref().is_none_or(|p| crate::periodic::validate_periodic_points(p).is_err()) { valid = false; diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 1e5e61f093..77d82e2311 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -657,7 +657,7 @@ fn near_overlaps_that_are_not_one_path_keep_their_regions() { .iter() .map(|p| sk.add_point(*p, None).unwrap()) .collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 3, periodic: false }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 3, periodic: false, nurbs: None }, None).unwrap(); sk.add_line_pts(ids[3], ids[0], None).unwrap(); let ps = find_profiles(&sk); assert_eq!(ps.len(), 1, "{:?}", ps.iter().map(|p| p.area).collect::>()); @@ -953,7 +953,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() { .iter() .map(|p| sk.add_point(*p, None).unwrap()) .collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false, nurbs: None }, None).unwrap(); sk.add_line_pts(ids[5], ids[0], None).unwrap(); let pr = find_profiles(&sk); assert_eq!(pr.len(), 1); @@ -962,7 +962,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() { // A fit spline arch over a base line, cut by a vertical line at x = 10. let mut sk = Sketch::default(); let ids: Vec = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false, nurbs: None }, None).unwrap(); sk.add_line_pts(ids[3], ids[0], None).unwrap(); let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap()); sk.add_line_pts(a, b, None).unwrap(); @@ -1033,7 +1033,7 @@ fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { let bump = [v(0.0, 0.0), v(1.5, 4.0), v(3.0, 7.0), v(5.0, 9.5), v(7.5, 11.0), v(12.5, 11.0), v(15.0, 9.5), v(17.0, 7.0), v(18.5, 4.0), v(20.0, 0.0)]; let pts: Vec = bump.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); - let s = sk.add_curve(CurveKind::Spline { pts: pts.clone(), control: true, degree: 9, periodic: false }, None).unwrap(); + let s = sk.add_curve(CurveKind::Spline { pts: pts.clone(), control: true, degree: 9, periodic: false, nurbs: None }, None).unwrap(); let pieces = sk.exact_pieces(s); assert_eq!(pieces.len(), 1, "{pieces:?}"); let Seg2::Bezier { n, p } = pieces[0] else { panic!("{:?}", pieces[0]) }; @@ -1068,7 +1068,7 @@ fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { // A degree above the cap is still refused. let mut high: Vec = pts.clone(); high.push(sk.add_point(v(21.0, -2.0), None).unwrap()); - assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false }, None).is_err()); + assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false, nurbs: None }, None).is_err()); } /// #755: a line drawn through a junction of a loop, but 2e-5 off it, still splits the loop. The @@ -1295,3 +1295,67 @@ fn bounds_reach_the_extremes_of_circles_and_arcs() { let (lo, hi) = sk.bounds().unwrap(); assert!(lo.dist(v(0.0, 0.0)) < 1e-9 && hi.dist(v(10.0, 10.0)) < 1e-9, "{lo:?} {hi:?}"); } + +/// A control spline with its own knots and weights (a projected free-form edge): its exact pieces +/// are its knot spans, a rational quadratic's are conics, its points follow its knots, it reads +/// back from a saved design the same, and knots that don't fit it are refused. +#[test] +fn a_spline_with_its_own_knots_is_its_spans_exactly() { + let mut sk = Sketch::new(); + // Two cubic Béziers end to end (the joint knot three times), the second twice as long in + // parameter: (0,0) (1,2) (2,2) (3,0) then (3,0) (4,-2) (5,-2) (6,0). + let ctrl = [v(0.0, 0.0), v(1.0, 2.0), v(2.0, 2.0), v(3.0, 0.0), v(4.0, -2.0), v(5.0, -2.0), v(6.0, 0.0)]; + let nurbs = Nurbs { knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0], weights: Vec::new() }; + let pts: Vec = ctrl.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); + let kind = CurveKind::Spline { pts: pts.clone(), control: true, degree: 3, periodic: false, nurbs: Some(nurbs.clone()) }; + let s = sk.add_curve(kind.clone(), None).unwrap(); + let pieces = sk.exact_pieces(s); + assert_eq!(pieces.len(), 2, "{pieces:?}"); + let Seg2::Cubic { p0, p1, p2, p3 } = pieces[1] else { panic!("{:?}", pieces[1]) }; + for (got, want) in [(p0, ctrl[3]), (p1, ctrl[4]), (p2, ctrl[5]), (p3, ctrl[6])] { + assert!(got.dist(want) < 1e-12, "{got:?} {want:?}"); + } + // t runs over the knots: a third of the way is the joint, where the uniform spline over the + // same points would not be. + assert!(nurbs.point(&ctrl, 3, 1.0 / 3.0).unwrap().dist(ctrl[3]) < 1e-12); + assert!(crate::curves::spline_point(&ctrl, true, 3, 1.0 / 3.0).dist(ctrl[3]) > 1e-3); + let back = nurbs.reversed(); + let rev: Vec = ctrl.iter().rev().copied().collect(); + for k in 0..=12 { + let t = k as f64 / 12.0; + assert!(nurbs.point(&ctrl, 3, t).unwrap().dist(back.point(&rev, 3, 1.0 - t).unwrap()) < 1e-12, "t={t}"); + } + let poly = sk.polyline(s); + assert!(poly.first().unwrap().dist(ctrl[0]) < 1e-12 && poly.last().unwrap().dist(ctrl[6]) < 1e-12); + // Saved and read back: the same curve. + let json = serde_json::to_string(&kind).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), kind); + // A quarter circle of radius 2 as a rational quadratic: one conic, its middle point on the circle. + let w = std::f64::consts::FRAC_1_SQRT_2; + let quarter = Nurbs { knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], weights: vec![1.0, w, 1.0] }; + let arc = [v(2.0, 0.0), v(2.0, 2.0), v(0.0, 2.0)]; + let [Seg2::Conic { w: cw, .. }] = quarter.segs(&arc, 2).unwrap()[..] else { panic!("conic") }; + assert!((cw - w).abs() < 1e-12); + assert!((quarter.point(&arc, 2, 0.5).unwrap().len() - 2.0).abs() < 1e-12); + // Knots that don't fit: the wrong count, not clamped, decreasing, not finite, an interior knot + // past the degree, a weight that isn't positive; and knots on a fit spline. + let bad = [ + Nurbs { knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], weights: Vec::new() }, + Nurbs { knots: vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0], weights: Vec::new() }, + Nurbs { knots: vec![0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0], weights: Vec::new() }, + Nurbs { knots: vec![0.0, 0.0, 0.0, 0.0, f64::NAN, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0], weights: Vec::new() }, + Nurbs { knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0], weights: Vec::new() }, + Nurbs { knots: nurbs.knots.clone(), weights: vec![1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0] }, + ]; + for (i, k) in bad.iter().enumerate() { + assert!(k.check(ctrl.len(), 3).is_err(), "{i}"); + assert!( + sk.add_curve(CurveKind::Spline { pts: pts.clone(), control: true, degree: 3, periodic: false, nurbs: Some(k.clone()) }, None).is_err(), + "{i}" + ); + assert!(k.point(&ctrl, 3, 0.5).is_none() && k.segs(&ctrl, 3).is_none() && k.polyline(&ctrl, 3).is_empty(), "{i}"); + } + let fourfold = Nurbs { knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0], weights: Vec::new() }; + assert!(fourfold.check(8, 3).is_err()); + assert!(sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: false, nurbs: Some(nurbs) }, None).is_err()); +} diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs index 7327fb680f..e36cab4e9c 100644 --- a/crates/sketch/src/text.rs +++ b/crates/sketch/src/text.rs @@ -53,7 +53,7 @@ impl Outline { self.line(to); return; } - self.out.push(LinkGeom::Spline { pts: vec![a, c1, c2, b], control: true, degree: 3, periodic: false }); + self.out.push(LinkGeom::Spline { pts: vec![a, c1, c2, b], control: true, degree: 3, periodic: false, nurbs: None }); self.cur = to; } } diff --git a/crates/sketch/src/thin.rs b/crates/sketch/src/thin.rs index 1d9fb51242..5ca8b85f3e 100644 --- a/crates/sketch/src/thin.rs +++ b/crates/sketch/src/thin.rs @@ -378,7 +378,7 @@ fn outline_sketch(boundary: &[Seg2]) -> Result { } Seg2::Cubic { p0, p1, p2, p3 } => { let pts = [p0, p1, p2, p3].iter().map(|p| sk.add_point(*p, None)).collect::>>()?; - sk.add_curve(CurveKind::Spline { pts, control: true, degree: 3, periodic: false }, None)?; + sk.add_curve(CurveKind::Spline { pts, control: true, degree: 3, periodic: false, nurbs: None }, None)?; } _ => return Err(bad("not supported yet: this kind of wall curve")), } diff --git a/crates/sketch/src/virtual_corner_tests.rs b/crates/sketch/src/virtual_corner_tests.rs index 2103875c02..b5357661e0 100644 --- a/crates/sketch/src/virtual_corner_tests.rs +++ b/crates/sketch/src/virtual_corner_tests.rs @@ -51,7 +51,7 @@ fn curve_centres_and_spline_interior_points_are_used_and_invalid_incidence_is_sa let last = point(&mut sk, 21., 21.); let mut spline = sk.curves[a].clone(); spline.id = "interior_spline".into(); - spline.kind = CurveKind::Spline { pts: vec![first, interior, last], control: false, degree: 3, periodic: false }; + spline.kind = CurveKind::Spline { pts: vec![first, interior, last], control: false, degree: 3, periodic: false, nurbs: None }; sk.curves.push(spline); for p in [centre, interior] { for c in [a, b] { @@ -91,7 +91,7 @@ fn oversized_reference_input_is_refused_without_scanning_or_mutating_it() { let mut sk = Sketch::new(); let a = sk.add_line(Vec2::new(0., 1.), Vec2::new(2., 1.), None, None, None).unwrap(); let before = sk.points.clone(); - sk.curves[a].kind = CurveKind::Spline { pts: vec![usize::MAX; 1_000_001], control: false, degree: 3, periodic: false }; + sk.curves[a].kind = CurveKind::Spline { pts: vec![usize::MAX; 1_000_001], control: false, degree: 3, periodic: false, nurbs: None }; assert!(sk.virtual_corners().is_empty()); assert_eq!(sk.points, before); assert_eq!(sk.curves[a].kind.point_ids().len(), 1_000_001); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 0c47fae74a..9051b27f3e 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -976,6 +976,7 @@ pub(crate) fn preview_curve( control: t.cmd != "sketch.spline.fit_point", degree: if t.cmd == "sketch.spline.control_point_5" { 5 } else { 3 }, periodic, + nurbs: None, }, None, )