refactor(curve): draw a fit spline and an offset preview from the curve

Two places were left describing geometry for themselves.

A fit-point SPLINE was drawn from a C² cubic solved in spline.rs while
TRIM and snap cut a separate interpolation from the kernel. Near enough
to look right, far enough apart that a trim landed beside the line it
was aimed at. It now draws the curve those commands work on, and falls
back to the local solve only where the kernel declines — a fit spline
through points in space is not a planar curve, so there is nothing for
it to say.

Closed fit splines were worse than that: the kernel's interpolation is a
clamped solve with no wrap in it, so the curve TRIM used never returned
to its start and the seam cut nothing. Repeating the first fit point
closes it. The seam is C¹ where the rest of the curve is C², which is
the honest limit of a clamped solve — the alternative was a curve with a
gap in it.

OFFSET's preview sampling had drifted the same way TRIM's had: an arc
and a circle read `center.x/.y` as world coordinates when the entity
stores them in its OCS, so an extruded one previewed on the wrong side;
every curved kind was cut at a fixed twenty segments per radian whatever
its size; and an ellipse rebuilt its axis frame from the major axis' X
and Y alone, dropping the Z the entity stores. A hundred lines of that
become one tessellation. A RAY now previews as far as an XLINE does
rather than as a stub the length of its direction vector.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-09 01:08:45 +03:00
1 parent 90abfc21a9
commit ece440bcab
4 files changed
+122 -184

No files matched your search

+25
View File
@@ -685,6 +685,31 @@ mod tests {
assert_eq!(hints(&snap, SnapHint::Quadrant).len(), 4);
}
#[test]
fn a_closed_fit_point_spline_comes_back_closed() {
// The interpolation behind a fit-point spline is a clamped solve and
// does not model a wrap, so without closing the point list the curve
// ended somewhere else entirely — and a TRIM against it cut nothing
// along the seam.
let mut spline = SplineEnt::default();
spline.degree = 3;
spline.flags.closed = true;
spline.fit_points = vec![
v3(0.0, 0.0, 0.0),
v3(10.0, 0.0, 0.0),
v3(10.0, 10.0, 0.0),
v3(0.0, 10.0, 0.0),
];
spline.normal = v3(0.0, 0.0, 1.0);
let curve = entity_curve(&EntityType::Spline(spline)).unwrap();
let (start, end) = (curve.point_at(0.0), curve.point_at(1.0));
assert!(
(start[0] - end[0]).abs() < 1e-9 && (start[1] - end[1]).abs() < 1e-9,
"{start:?} vs {end:?}"
);
assert!(curve.is_closed());
}
#[test]
fn entities_that_are_not_curves_say_so() {
assert!(entity_curve(&EntityType::Point(Default::default())).is_none());
+37 -12
View File
@@ -19,23 +19,48 @@ fn to_truck(spl: &Spline) -> TruckEntity {
// smooth Catmull-Rom curve through them and hand it back as a dense
// polyline so it still draws.
if spl.fit_points.len() >= 2 {
let closed = spl.flags.closed || spl.flags.periodic;
// A fit spline is a C² cubic through its fit points honouring the
// stored end tangents. Catmull-Rom ignores those tangents (its ends
// use local slopes), so an open curve peels away from the true shape
// at the ends; interpolate properly instead. Closed splines wrap,
// which the clamped solve doesn't model, so they keep Catmull-Rom.
let pts = if closed {
catmull_rom_polyline(&spl.fit_points, true)
// Drawn from the same interpolation TRIM and snap use, so that
// what is on screen is the curve those commands cut. The two had
// parted: this drew a C² cubic solved here (or a Catmull-Rom for a
// closed spline), while the kernel interpolated its own — near
// enough to look right and far enough apart that a trim landed
// beside the line it was aimed at.
let planar = crate::entities::curve::spline_curve(spl);
let pts = match &planar {
Some(curve) => crate::entities::curve::curve_points(curve),
// A fit spline through points in space is not a planar curve,
// so the kernel has nothing to say about it and the solve
// here remains the only description of its shape.
None if spl.flags.closed || spl.flags.periodic => {
catmull_rom_polyline(&spl.fit_points, true)
}
None => fit_spline_polyline(spl),
};
let snap = planar
.as_ref()
.map(crate::entities::curve::snap_from)
.unwrap_or_default();
// Fit points are on the curve, so they stay on offer as ends in
// their own right — but never through `key_vertices`, whose
// consecutive entries the snap engine joins and offers the middle
// of. The chord between two fit points of a curvy spline does not
// run along it.
let mut snap_pts = snap.snap_pts;
snap_pts.extend(spl.fit_points.iter().map(|p| {
(
glam::DVec3::new(p.x, p.y, p.z),
crate::scene::model::wire_model::SnapHint::Endpoint,
)
}));
let key_vertices = if planar.is_some() {
Vec::new()
} else {
fit_spline_polyline(spl)
spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect()
};
let key_vertices: Vec<[f64; 3]> =
spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect();
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
snap_pts,
tangent_geoms: vec![],
key_vertices,
fill_tris: vec![],
+46 -171
View File
@@ -10,9 +10,8 @@
// 3. Pick a point on the side to offset toward, or choose Multiple
// to keep offsetting the newly created result at the same distance
use std::f64::consts::TAU;
use crate::modules::draw::modify::spline_ops::{spline_pts_wire, spline_sample_xy};
use crate::entities::curve::entity_curve;
use crate::modules::draw::modify::spline_ops::spline_sample_xy;
use acadrust::entities::LwVertex;
use acadrust::entities::{
Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline,
@@ -23,8 +22,7 @@ use acadrust::{EntityType, Handle};
// from the kernel; only the entity conversion stays here.
use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots;
use acadrust::kernel::geom2d::{
normalize_angle as norm_rad, offset_polyline, Polyline as KernelPolyline,
PolylineVertex as KernelVertex,
offset_polyline, Polyline as KernelPolyline, PolylineVertex as KernelVertex,
};
use glam::{DVec3, Vec3};
use crate::t;
@@ -362,178 +360,55 @@ fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
// ── Wire preview points ─────────────────────────────────────────────────────
/// Preview density, matching the figure the drawn wires use.
const PREVIEW_SEGMENTS_PER_RADIAN: f64 = acadrust::kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN;
/// Preview points for an entity, from its own curve.
///
/// One tessellation rather than a case per type. What that case-per-type
/// version had accumulated: an arc and a circle read their `center.x/.y` as
/// world coordinates when the entity stores them in its OCS, so an extruded
/// one previewed on the wrong side of the drawing; every curved kind was cut
/// at a fixed twenty segments per radian whatever its size; and an ellipse
/// rebuilt its own axis frame from the major axis' X and Y alone, dropping
/// the Z the entity actually stores.
///
/// The narrowing to `f32` stays here rather than in the kernel: a preview
/// wire leaves `points_low` empty and accepts the loss, while the resident
/// render path splits the `f64` into a high/low pair instead. Only a caller
/// knows which of the two it is.
fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
match e {
EntityType::Line(l) => vec![
[l.start.x as f32, l.start.y as f32, l.start.z as f32],
[l.end.x as f32, l.end.y as f32, l.end.z as f32],
],
EntityType::Circle(c) => {
let steps = 64usize;
(0..=steps)
.map(|i| {
let a = TAU * i as f64 / steps as f64;
[
(c.center.x + c.radius * a.cos()) as f32,
(c.center.y + c.radius * a.sin()) as f32,
c.center.z as f32,
]
})
.collect()
}
EntityType::Arc(a) => {
let a0 = norm_rad(a.start_angle);
let a1 = norm_rad(a.end_angle);
let span = {
let s = a1 - a0;
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
(0..=steps)
.map(|i| {
let ang = a0 + span * (i as f64 / steps as f64);
[
(a.center.x + a.radius * ang.cos()) as f32,
(a.center.y + a.radius * ang.sin()) as f32,
a.center.z as f32,
]
})
.collect()
}
EntityType::LwPolyline(p) => lwpolyline_pts(p),
EntityType::Ellipse(e) => {
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return vec![];
}
let b = a * e.minor_axis_ratio;
let nx = e.major_axis.x / a;
let ny = e.major_axis.y / a;
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let span = t1 - t0;
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
(0..=steps)
.map(|i| {
let t = t0 + span * (i as f64 / steps as f64);
let lx = a * t.cos();
let ly = b * t.sin();
[
(e.center.x + lx * nx - ly * ny) as f32,
(e.center.y + lx * ny + ly * nx) as f32,
e.center.z as f32,
]
})
.collect()
}
EntityType::Spline(s) => spline_pts_wire(s),
EntityType::XLine(x) => {
// Infinite in both directions — represent it as a very long segment
// for hit-testing and the offset preview. Long enough to read as
// infinite at any working zoom; the committed XLine renders true.
const HL: f64 = 1.0e6;
let (bx, by, bz) = (x.base_point.x, x.base_point.y, x.base_point.z);
let (dx, dy, dz) = (x.direction.x, x.direction.y, x.direction.z);
vec![
[(bx - dx * HL) as f32, (by - dy * HL) as f32, (bz - dz * HL) as f32],
[(bx + dx * HL) as f32, (by + dy * HL) as f32, (bz + dz * HL) as f32],
// The two kinds with no finite point list of their own, so how far to run
// them is a decision rather than a measurement. Long enough to read as
// infinite at any working zoom; the committed entity renders true. A ray
// runs one way only.
const HL: f64 = 1.0e6;
let unbounded = match e {
EntityType::XLine(x) => Some((x.base_point, x.direction, -HL)),
EntityType::Ray(r) => Some((r.base_point, r.direction, 0.0)),
_ => None,
};
if let Some((base, direction, back)) = unbounded {
let at = |k: f64| {
[
(base.x + direction.x * k) as f32,
(base.y + direction.y * k) as f32,
(base.z + direction.z * k) as f32,
]
}
_ => vec![],
};
return vec![at(back), at(HL)];
}
}
/// Tessellate a LwPolyline into wire points (straight segments + arc bulges).
fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> {
let n = p.vertices.len();
if n < 2 {
let Some(curve) = entity_curve(e) else {
return vec![];
}
let z = p.elevation as f32;
let n_segs = if p.is_closed { n } else { n - 1 };
let mut pts: Vec<[f32; 3]> = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let x0 = v0.location.x;
let y0 = v0.location.y;
let x1 = v1.location.x;
let y1 = v1.location.y;
if pts.is_empty() {
pts.push([x0 as f32, y0 as f32, z]);
}
if v0.bulge.abs() < 1e-10 {
pts.push([x1 as f32, y1 as f32, z]);
} else {
// Arc from bulge
let b = v0.bulge;
let chord_x = x1 - x0;
let chord_y = y1 - y0;
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 {
pts.push([x1 as f32, y1 as f32, z]);
continue;
}
let b2 = b * b;
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
let d = r * (1.0 - b2) / (1.0 + b2);
let mx = (x0 + x1) * 0.5;
let my = (y0 + y1) * 0.5;
let perp_x = -chord_y / chord_len;
let perp_y = chord_x / chord_len;
let sign = b.signum();
let cx = mx + sign * d * perp_x;
let cy = my + sign * d * perp_y;
let a0 = norm_rad((y0 - cy).atan2(x0 - cx));
let a1 = norm_rad((y1 - cy).atan2(x1 - cx));
let span = if b > 0.0 {
let s = a1 - a0;
if s <= 0.0 {
s + TAU
} else {
s
}
} else {
let s = a0 - a1;
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
for j in 1..=steps {
let t = j as f64 / steps as f64;
let ang = if b > 0.0 {
a0 + span * t
} else {
a0 - span * t
};
pts.push([(cx + r * ang.cos()) as f32, (cy + r * ang.sin()) as f32, z]);
}
}
}
if p.is_closed {
if let Some(&first) = pts.first() {
pts.push(first);
}
}
pts
};
curve
.tessellate(PREVIEW_SEGMENTS_PER_RADIAN)
.into_iter()
.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32])
.collect::<Vec<[f32; 3]>>()
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
+14 -1
View File
@@ -78,7 +78,20 @@ fn spline_to_nurbs_with(
}
// No usable control polygon, so this is a fit-point spline.
let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect();
let mut fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect();
if spl.flags.closed || spl.flags.periodic {
// The interpolation is a clamped solve and does not model a wrap, so
// a closed spline came back as an open curve that never returned to
// its start — and a TRIM against it then cut nothing along the seam.
// Repeating the first point closes it. The seam is C¹ rather than the
// C² the rest of the curve has, which is the honest limit of a
// clamped solve; the alternative was a curve with a gap in it.
if let (Some(&first), Some(&last)) = (fit.first(), fit.last()) {
if first != last {
fit.push(first);
}
}
}
let tangent = |v: &Vector3| {
let flat = vector(v);
(flat[0] * flat[0] + flat[1] * flat[1] > 1e-18).then_some(flat)