From 57661c5754e5b7a350659db759b2bfa1b8521a71 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 00:43:35 +0300 Subject: [PATCH] feat(entities): give an entity its geometry as a curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same shape was being read out of an entity's fields in three places and the three had drifted. An arc's tessellation cut it at 20 segments per radian, TRIM's sampling at 16, and its snap points came from a fourth expression again; a spline was sampled at a fixed 96 points however long it was; TRIM read an arc's centre as world coordinates when the entity stores it in its OCS, so an arc carrying the (0, 0, −1) normal a MIRROR leaves behind acted as a boundary on the wrong side of the drawing. entity_curve returns a PlanarCurve: the shape, plus the plane its coordinates are read in. The plane comes from the entity's OCS — its extrusion normal through the DXF arbitrary-axis algorithm — not from the UCS. A UCS is the working plane a user drew in and an entity does not remember it: draw a circle in a UCS turned 30° about Z and the stored normal is still +Z, which is why the header keeps the UCS separately. The algorithm itself stays in transform.rs, where the format is understood and where #142's degenerate case was fixed once. Entities that store world coordinates and may not be planar at all — LINE, SPLINE — are checked rather than assumed. A spline whose points wander in space returns None instead of being flattened onto XY, which would move it. entity_curve_xy folds the plane away for the editing commands, which work in plan view and need every boundary in one frame before two of them can be intersected. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/entities/curve.rs | 710 ++++++++++++++++++++++++++ src/entities/mod.rs | 1 + src/modules/draw/modify/spline_ops.rs | 41 +- 4 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 src/entities/curve.rs diff --git a/Cargo.lock b/Cargo.lock index ce4147119..aaaa6aff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#dee251f190a82f66d548e10d36f112815d9a901f" +source = "git+https://github.com/HakanSeven12/cadkernel.git#39083307452ba14b3968bdb0738e2665d3d18e62" dependencies = [ "cavalier_contours", ] diff --git a/src/entities/curve.rs b/src/entities/curve.rs new file mode 100644 index 000000000..12182c71c --- /dev/null +++ b/src/entities/curve.rs @@ -0,0 +1,710 @@ +//! An entity's geometry as a kernel curve. +//! +//! One converter, so that what is drawn, what is snapped to and what is +//! trimmed all read the same definition. Before this each of those grew its +//! own per-type match, and the three drifted: an arc's tessellation, its snap +//! points and its trim parameters were computed by different code from the +//! same fields. +//! +//! # The plane +//! +//! A [`Curve`] is two-dimensional; a drawing's curve lives on a plane in +//! space. The two together are a [`PlanarCurve`], and the plane comes from +//! the entity's **OCS** — its extrusion normal, run through the DXF +//! arbitrary-axis algorithm in [`ocs_axes`]. Not from the UCS: the UCS is the +//! working plane a user drew in, and an entity does not remember it. Draw a +//! circle in a UCS turned 30° about Z and the stored normal is still +Z, so +//! the frame this builds is the world one — which is exactly right, because +//! that is where the circle's stored coordinates are read. +//! +//! Entities that store WCS coordinates rather than OCS ones (LINE, ELLIPSE, +//! SPLINE, RAY, XLINE) still get a plane, derived from the geometry itself. +//! Where they turn out not to be planar at all, the answer is `None` rather +//! than a silent projection onto XY, which would move the geometry. +//! +//! # What is deliberately absent +//! +//! Text, hatch, dimensions, meshes, solids, images and blocks are not +//! curves. They keep their own paths and this returns `None` for them, which +//! is what lets a caller ask about any entity without checking first. + +use acadrust::entities::{ + Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, LwPolyline as LwPolylineEnt, + Polyline2D, Spline as SplineEnt, +}; +use acadrust::kernel::geom2d::{ + characteristic_points, Arc, Circle, Curve, Ellipse, EllipseArc, Line, Polyline, PolylineVertex, + Ray, SnapKind, Transform, XLine, +}; +use acadrust::kernel::space::{PlanarCurve, Plane, Vec3}; +use acadrust::types::Vector3; +use acadrust::EntityType; + +use crate::modules::draw::modify::spline_ops::spline_to_nurbs_on; +use crate::scene::model::wire_model::SnapHint; +use crate::scene::view::transform::ocs_axes; + +/// The codec's vector as the array the kernel speaks in. The two have no +/// conversion between them by design — neither crate knows the other — so the +/// bridge is here, once. +fn xyz(v: Vector3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +/// How far a point may sit off a candidate plane and still be taken to lie on +/// it. +/// +/// Relative to the geometry's own size rather than absolute: a spline drawn +/// at survey coordinates carries several orders more rounding in its stored +/// values than one drawn near the origin, and a fixed floor would reject the +/// first or accept anything for the second. +const PLANARITY_TOLERANCE: f64 = 1e-9; + +/// The entity's geometry as a curve on the plane it lives on. +/// +/// `None` for entities that are not curves, and for the ones that are but +/// whose points do not lie on a plane — a 3D polyline, a spline through +/// points in space. +pub fn entity_curve(entity: &EntityType) -> Option { + match entity { + EntityType::Line(line) => straight_curve(line.start, line.end, Straight::Segment), + EntityType::Ray(ray) => { + straight_curve(ray.base_point, ray.base_point + ray.direction, Straight::Ray) + } + EntityType::XLine(line) => straight_curve( + line.base_point, + line.base_point + line.direction, + Straight::Infinite, + ), + EntityType::Circle(circle) => Some(circle_curve(circle)), + EntityType::Arc(arc) => Some(arc_curve(arc)), + EntityType::Ellipse(ellipse) => ellipse_curve(ellipse), + EntityType::LwPolyline(polyline) => lwpolyline_curve(polyline), + EntityType::Polyline2D(polyline) => polyline2d_curve(polyline), + EntityType::Spline(spline) => spline_curve(spline), + _ => None, + } +} + +/// The plane an OCS-stored entity's coordinates are read in. +/// +/// `elevation` is the entity's third stored coordinate — the distance along +/// the normal its plane sits at — so the plane's origin is where a stored +/// `(0, 0)` actually lands. +/// +/// The axes come from [`ocs_axes`] rather than being derived here. That +/// algorithm is a DXF storage convention with its own history of getting the +/// degenerate cases wrong, and having it in one place is what keeps the +/// second copy from drifting. +pub fn ocs_plane(normal: Vector3, elevation: f64) -> Plane { + let normal = normalized(normal); + if is_default_normal(normal) { + // The overwhelming majority. Named rather than left to the general + // path so the axes come out bit-exact, which is what lets consumers + // take the world-XY shortcut. + return Plane::from_axes([0.0, 0.0, elevation], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + } + let (x_axis, y_axis) = ocs_axes((normal.x, normal.y, normal.z)); + Plane::from_axes( + (Vec3::from(xyz(normal)) * elevation).to_array(), + [x_axis.0, x_axis.1, x_axis.2], + [y_axis.0, y_axis.1, y_axis.2], + ) +} + +/// Which of the three straight kinds a pair of points describes. +enum Straight { + Segment, + Ray, + Infinite, +} + +/// A LINE, RAY or XLINE, which store world coordinates and may run anywhere. +/// +/// A straight curve lies in infinitely many planes, so any of them gives the +/// same points back. The one picked is the world XY plane when the geometry +/// is level, because that is the frame everything else in a drawing shares +/// and a caller that wants to intersect two curves needs them in one frame. +/// Otherwise it is the upright plane through the line, which is the only +/// choice that does not depend on an arbitrary rotation. +fn straight_curve(from: Vector3, to: Vector3, kind: Straight) -> Option { + let along = Vec3::from(xyz(to)) - Vec3::from(xyz(from)); + if along.length_squared() <= 0.0 { + return None; + } + let plane = if (to.z - from.z).abs() <= PLANARITY_TOLERANCE * scale_of(&[from, to]) { + Plane::from_axes([0.0, 0.0, from.z], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + } else { + // The upright plane through the line: its normal is horizontal, and + // perpendicular to the line. A line that is itself vertical has no + // such normal, and any plane containing it will do — XZ is taken. + let normal = along.cross(Vec3::Z).normalize().unwrap_or(Vec3::Y); + Plane::orthonormal(xyz(from), along.to_array(), normal.to_array())? + }; + let start = plane.project(xyz(from))?; + let end = plane.project(xyz(to))?; + let direction = [end[0] - start[0], end[1] - start[1]]; + Some(PlanarCurve::new( + plane, + match kind { + Straight::Segment => Curve::Line(Line { start, end }), + Straight::Ray => Curve::Ray(Ray { + origin: start, + direction, + }), + Straight::Infinite => Curve::XLine(XLine { + base: start, + direction, + }), + }, + )) +} + +pub fn circle_curve(circle: &CircleEnt) -> PlanarCurve { + PlanarCurve::new( + ocs_plane(circle.normal, circle.center.z), + Curve::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }), + ) +} + +pub fn arc_curve(arc: &ArcEnt) -> PlanarCurve { + PlanarCurve::new( + ocs_plane(arc.normal, arc.center.z), + Curve::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }), + ) +} + +/// An ELLIPSE, whose centre and major axis are stored in world coordinates +/// even though the shape is planar. +/// +/// The plane is still the entity's OCS one, so an ellipse and a circle with +/// the same normal end up in the same frame and can be intersected without +/// re-expressing either. Getting there means projecting the two stored +/// vectors rather than reading them off, which is where the major axis picks +/// up its direction *and* its length. +pub fn ellipse_curve(ellipse: &EllipseEnt) -> Option { + let normal = normalized(ellipse.normal); + let elevation = Vec3::from(xyz(ellipse.center)).dot(Vec3::from(xyz(normal))); + let plane = ocs_plane(normal, elevation); + let centre = plane.project(xyz(ellipse.center))?; + let major = plane.project_vector(xyz(ellipse.major_axis))?; + let major_radius = (major[0] * major[0] + major[1] * major[1]).sqrt(); + if major_radius <= 0.0 { + return None; + } + Some(PlanarCurve::new( + plane, + Curve::Ellipse(EllipseArc { + ellipse: Ellipse { + centre, + major_radius, + minor_radius: major_radius * ellipse.minor_axis_ratio, + major_axis: [major[0] / major_radius, major[1] / major_radius], + }, + start_parameter: ellipse.start_parameter, + end_parameter: ellipse.end_parameter, + }), + )) +} + +pub fn lwpolyline_curve(polyline: &LwPolylineEnt) -> Option { + let vertices: Vec = polyline + .vertices + .iter() + .map(|v| PolylineVertex { + position: [v.location.x, v.location.y], + bulge: v.bulge, + }) + .collect(); + (vertices.len() >= 2).then(|| { + PlanarCurve::new( + ocs_plane(polyline.normal, polyline.elevation), + Curve::Polyline(Polyline { + vertices, + closed: polyline.is_closed, + }), + ) + }) +} + +/// A heavy 2D POLYLINE. Its vertices carry a Z, but the entity's own +/// `elevation` is what defines the plane; the two agree in a well-formed +/// drawing and the entity field is the one the writer round-trips. +pub fn polyline2d_curve(polyline: &Polyline2D) -> Option { + let vertices: Vec = polyline + .vertices + .iter() + .map(|v| PolylineVertex { + position: [v.location.x, v.location.y], + bulge: v.bulge, + }) + .collect(); + (vertices.len() >= 2).then(|| { + PlanarCurve::new( + ocs_plane(polyline.normal, polyline.elevation), + Curve::Polyline(Polyline { + vertices, + closed: polyline.flags.is_closed(), + }), + ) + }) +} + +/// A SPLINE, whose control and fit points are world coordinates that need not +/// lie on any plane. +/// +/// The `planar` flag is not trusted on its own — it is routinely wrong in +/// files written by other software — so the points are checked against the +/// plane the normal describes. A spline that genuinely wanders in space gets +/// `None`, which is honest: flattening it to XY would move it. +pub fn spline_curve(spline: &SplineEnt) -> Option { + let points: Vec = spline + .control_points + .iter() + .chain(spline.fit_points.iter()) + .copied() + .collect(); + let first = points.first()?; + let normal = normalized(spline.normal); + let elevation = Vec3::from(xyz(*first)).dot(Vec3::from(xyz(normal))); + let plane = ocs_plane(normal, elevation); + + let tolerance = PLANARITY_TOLERANCE * scale_of(&points); + if !points.iter().all(|p| plane.contains(xyz(*p), tolerance)) { + return None; + } + Some(PlanarCurve::new( + plane, + Curve::Nurbs(spline_to_nurbs_on(spline, &plane)?), + )) +} + +/// The entity's curve in world XY coordinates. +/// +/// The editing commands — TRIM, EXTEND, FILLET, OFFSET — work in plan view, +/// where every boundary has to be expressed in one shared frame before two of +/// them can be intersected. [`entity_curve`] gives each entity its own plane, +/// which is right for describing the geometry and wrong for comparing two +/// pieces of it; this folds the plane away. +/// +/// `None` when the entity is not a planar curve, and when its plane stands +/// edge-on to the view: a circle seen exactly from the side is a line +/// segment, and there is no honest curve to trim against. +/// +/// This is also where an extruded entity stops being read wrong. Reading an +/// arc's `center.x/.y` straight out of the entity treats OCS coordinates as +/// world ones, so an arc carrying the `(0, 0, −1)` normal a MIRROR leaves +/// behind acted as a boundary on the wrong side of the drawing. +pub fn entity_curve_xy(entity: &EntityType) -> Option { + let planar = entity_curve(entity)?; + let plane = planar.plane; + if plane.is_xy_aligned() { + // Already world-aligned, so only the elevation has to be dropped and + // any origin offset carried over. + let [x, y, _] = plane.origin; + return if x == 0.0 && y == 0.0 { + Some(planar.curve) + } else { + planar.curve.transformed(&Transform::translation([x, y])) + }; + } + // A plane whose normal has no Z component is edge-on: it projects to a + // line, and a curve on it has no plan-view shape. + let normal = plane.normal()?; + if normal[2].abs() <= 1e-12 { + return None; + } + // Otherwise the drop to XY is affine — each plane axis contributes its own + // X and Y — so the curve can follow it exactly rather than being sampled. + planar.curve.transformed(&Transform { + x_axis: [plane.x_axis[0], plane.x_axis[1]].into(), + y_axis: [plane.y_axis[0], plane.y_axis[1]].into(), + origin: [plane.origin[0], plane.origin[1]].into(), + }) +} + +/// The snap candidates an entity's curve offers, in the two channels the +/// wire model carries them in. +#[derive(Debug, Default, Clone)] +pub struct CurveSnap { + /// Centres, quadrants and midpoints, in world coordinates. + pub snap_pts: Vec<(glam::DVec3, SnapHint)>, + /// The ends of the curve and of each of a chain's segments. + pub key_vertices: Vec<[f64; 3]>, +} + +/// Every point the entity's own geometry offers to snap to. +/// +/// The point of routing this through [`entity_curve`] rather than reading the +/// fields per type: an arc's quadrants, an ellipse's axis ends and a spline's +/// midpoint are all the same question asked of different shapes, and asking +/// it once means the answer is exact everywhere. Previously an arc offered no +/// quadrants at all and a spline's "endpoints" were its control points, which +/// for a control-point spline are not on the curve. +/// +/// `None` for anything that is not a planar curve, which keeps the callers +/// that have their own snap sources — text, blocks, dimensions — untouched. +pub fn curve_snap(entity: &EntityType) -> Option { + Some(snap_from(&entity_curve(entity)?)) +} + +/// [`curve_snap`] for a caller that already has the curve. +/// +/// The per-type wire builders take a concrete entity rather than an +/// [`EntityType`], and wrapping one back up would mean cloning it on the +/// render path. +pub fn snap_from(curve: &PlanarCurve) -> CurveSnap { + // A chain of straight segments is what `key_vertices` means: the snap + // engine joins consecutive entries and offers the midpoint of each. Only + // a line or a polyline can promise that, so those two put their ends + // there and let their midpoints be derived; everything else names its own + // ends and middle explicitly. + // + // The same distinction is why a polyline's arc-segment centres are not + // emitted: a wire carrying a centre is treated as round elsewhere, which + // a polyline with one bulge in it is not. + let chain = matches!(curve.curve, Curve::Line(_) | Curve::Polyline(_)); + let mut out = CurveSnap::default(); + let mut push = |world: [f64; 3], hint: SnapHint| { + out.snap_pts.push((glam::DVec3::from_array(world), hint)); + }; + for point in characteristic_points(&curve.curve) { + let world = curve.plane.point_at(point.point); + match point.kind { + SnapKind::Endpoint if chain => out.key_vertices.push(world), + SnapKind::Endpoint => push(world, SnapHint::Endpoint), + SnapKind::Midpoint if !chain => push(world, SnapHint::Midpoint), + SnapKind::Centre if !chain => push(world, SnapHint::Center), + SnapKind::Quadrant => push(world, SnapHint::Quadrant), + _ => {} + } + } + out +} + +/// The magnitude the geometry's own coordinates sit at, for scaling a +/// relative tolerance. Floored at one so geometry near the origin does not +/// end up with a tolerance of nothing. +fn scale_of(points: &[Vector3]) -> f64 { + points + .iter() + .map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs())) + .fold(1.0, f64::max) +} + +fn is_default_normal(normal: Vector3) -> bool { + normal.x == 0.0 && normal.y == 0.0 && normal.z == 1.0 +} + +/// A unit normal, falling back to +Z for the degenerate vector some files +/// store. Zero would make every axis collapse and put the whole entity at +/// one point. +fn normalized(normal: Vector3) -> Vector3 { + match Vec3::from(xyz(normal)).normalize() { + Some(unit) => Vector3::new(unit.x, unit.y, unit.z), + None => Vector3::new(0.0, 0.0, 1.0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::lwpolyline::LwVertex; + use acadrust::entities::{Line as LineEnt, Ray as RayEnt, XLine as XLineEnt}; + use acadrust::types::Vector2; + use std::f64::consts::{FRAC_PI_2, PI, TAU}; + + fn v3(x: f64, y: f64, z: f64) -> Vector3 { + Vector3::new(x, y, z) + } + + /// Every point the curve reports, against a direct evaluation of the + /// entity's own fields. The property that matters: the converter must not + /// move anything. + fn assert_on(curve: &PlanarCurve, expected: impl Fn(f64) -> [f64; 3]) { + for i in 0..=8 { + let t = i as f64 / 8.0; + let got = curve.point_at(t); + let want = expected(t); + for axis in 0..3 { + assert!( + (got[axis] - want[axis]).abs() < 1e-9, + "t={t}: {got:?} vs {want:?}" + ); + } + } + } + + #[test] + fn a_flat_circle_lands_on_the_world_xy_plane() { + let mut circle = CircleEnt::default(); + circle.center = v3(10.0, 20.0, 4.0); + circle.radius = 3.0; + circle.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Circle(circle)).unwrap(); + assert!(curve.plane.is_xy_aligned()); + assert_on(&curve, |t| { + let a = t * TAU; + [10.0 + 3.0 * a.cos(), 20.0 + 3.0 * a.sin(), 4.0] + }); + } + + #[test] + fn an_extruded_arc_uses_the_arbitrary_axis_frame() { + // Normal along −Z: the frame flips, and a converter that ignored it + // would put the arc on the wrong side of the drawing. + let mut arc = ArcEnt::default(); + arc.center = v3(5.0, 0.0, 2.0); + arc.radius = 1.0; + arc.start_angle = 0.0; + arc.end_angle = FRAC_PI_2; + arc.normal = v3(0.0, 0.0, -1.0); + let curve = entity_curve(&EntityType::Arc(arc)).unwrap(); + assert!(!curve.plane.is_xy_aligned()); + assert_on(&curve, |t| { + let a = t * FRAC_PI_2; + let wcs = crate::scene::view::transform::ocs_point_to_wcs( + (5.0 + a.cos(), a.sin(), 2.0), + (0.0, 0.0, -1.0), + ); + [wcs.0, wcs.1, wcs.2] + }); + } + + #[test] + fn a_level_line_shares_the_world_frame() { + let mut line = LineEnt::default(); + line.start = v3(1.0, 2.0, 7.0); + line.end = v3(4.0, 6.0, 7.0); + let curve = entity_curve(&EntityType::Line(line)).unwrap(); + assert!(curve.plane.is_xy_aligned(), "{:?}", curve.plane); + assert_on(&curve, |t| [1.0 + 3.0 * t, 2.0 + 4.0 * t, 7.0]); + } + + #[test] + fn a_sloping_line_still_reports_its_own_points() { + // No XY frame contains it, so the plane is the upright one through + // the line. The points are what must not change. + let mut line = LineEnt::default(); + line.start = v3(0.0, 0.0, 0.0); + line.end = v3(3.0, 4.0, 12.0); + let curve = entity_curve(&EntityType::Line(line)).unwrap(); + assert!(!curve.plane.is_xy_aligned()); + assert_on(&curve, |t| [3.0 * t, 4.0 * t, 12.0 * t]); + } + + #[test] + fn a_vertical_line_is_not_a_degenerate_case() { + // `along × Z` is zero here, which is where an unguarded normal would + // collapse the frame and send every point to the origin. + let mut line = LineEnt::default(); + line.start = v3(2.0, 3.0, 0.0); + line.end = v3(2.0, 3.0, 10.0); + let curve = entity_curve(&EntityType::Line(line)).unwrap(); + assert_on(&curve, |t| [2.0, 3.0, 10.0 * t]); + } + + #[test] + fn a_zero_length_line_has_no_curve() { + let mut line = LineEnt::default(); + line.start = v3(1.0, 1.0, 1.0); + line.end = v3(1.0, 1.0, 1.0); + assert!(entity_curve(&EntityType::Line(line)).is_none()); + } + + #[test] + fn an_ellipse_keeps_its_axis_length_and_direction() { + let mut ellipse = EllipseEnt::default(); + ellipse.center = v3(10.0, 5.0, 0.0); + ellipse.major_axis = v3(0.0, 4.0, 0.0); // up, length 4 + ellipse.minor_axis_ratio = 0.5; + ellipse.start_parameter = 0.0; + ellipse.end_parameter = TAU; + ellipse.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Ellipse(ellipse)).unwrap(); + let Curve::Ellipse(arc) = &curve.curve else { + panic!("expected an ellipse"); + }; + assert!((arc.ellipse.major_radius - 4.0).abs() < 1e-12); + assert!((arc.ellipse.minor_radius - 2.0).abs() < 1e-12); + assert_on(&curve, |t| { + let a = t * TAU; + // Major axis points along +Y, so the roles of the two axes swap. + [10.0 - 2.0 * a.sin(), 5.0 + 4.0 * a.cos(), 0.0] + }); + } + + #[test] + fn an_ellipse_on_an_extruded_plane_stays_on_it() { + let mut ellipse = EllipseEnt::default(); + ellipse.center = v3(0.0, 0.0, 3.0); + ellipse.major_axis = v3(2.0, 0.0, 0.0); + ellipse.minor_axis_ratio = 0.5; + ellipse.start_parameter = 0.0; + ellipse.end_parameter = PI; + ellipse.normal = v3(0.0, 1.0, 0.0); + // The centre is off that plane, so the entity is inconsistent and the + // projection is what decides. What must hold is that the result is a + // planar curve whose points all sit on the plane it reports. + let curve = entity_curve(&EntityType::Ellipse(ellipse)).unwrap(); + for point in curve.tessellate(20.0) { + assert!(curve.plane.contains(point, 1e-9), "{point:?}"); + } + } + + #[test] + fn a_polyline_carries_its_bulges_and_closure() { + let mut polyline = LwPolylineEnt::default(); + polyline.elevation = 2.0; + polyline.is_closed = true; + polyline.normal = v3(0.0, 0.0, 1.0); + polyline.vertices = vec![ + LwVertex::with_bulge(Vector2::new(0.0, 0.0), 1.0), + LwVertex::from_coords(10.0, 0.0), + ]; + let curve = entity_curve(&EntityType::LwPolyline(polyline)).unwrap(); + assert!(curve.is_closed()); + // A bulge of 1 is a half circle. Left to right it dips below. + let points = curve.tessellate(20.0); + assert!(points.iter().all(|p| (p[2] - 2.0).abs() < 1e-12)); + assert!( + points.iter().any(|p| p[1] < -4.9), + "the semicircle should reach y = −5" + ); + } + + #[test] + fn a_two_point_minimum_is_enforced() { + let mut polyline = LwPolylineEnt::default(); + polyline.vertices = vec![LwVertex::from_coords(0.0, 0.0)]; + assert!(entity_curve(&EntityType::LwPolyline(polyline)).is_none()); + } + + #[test] + fn a_flat_spline_converts_and_a_spatial_one_does_not() { + let mut spline = SplineEnt::default(); + spline.degree = 3; + spline.fit_points = vec![ + v3(0.0, 0.0, 5.0), + v3(1.0, 2.0, 5.0), + v3(3.0, 1.0, 5.0), + v3(5.0, 4.0, 5.0), + ]; + spline.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Spline(spline.clone())).unwrap(); + assert!(curve.plane.is_xy_aligned()); + for point in curve.tessellate(20.0) { + assert!((point[2] - 5.0).abs() < 1e-9, "{point:?}"); + } + + // One point lifted out of the plane. The `planar` flag still says + // nothing; the geometry is what is checked. + spline.fit_points[2].z = 9.0; + assert!(entity_curve(&EntityType::Spline(spline)).is_none()); + } + + fn hints(snap: &CurveSnap, want: SnapHint) -> Vec { + snap.snap_pts + .iter() + .filter(|(_, hint)| std::mem::discriminant(hint) == std::mem::discriminant(&want)) + .map(|&(p, _)| p) + .collect() + } + + #[test] + fn an_arc_offers_the_quadrants_its_sweep_covers() { + let mut arc = ArcEnt::default(); + arc.center = v3(0.0, 0.0, 0.0); + arc.radius = 2.0; + arc.start_angle = 0.0; + arc.end_angle = PI; // the upper half + arc.normal = v3(0.0, 0.0, 1.0); + let snap = curve_snap(&EntityType::Arc(arc)).unwrap(); + let quadrants = hints(&snap, SnapHint::Quadrant); + // 0° and 90° and 180° are on it; 270° is not. + assert_eq!(quadrants.len(), 3, "{quadrants:?}"); + assert!(quadrants.iter().all(|q| q.y >= -1e-9)); + + assert_eq!(hints(&snap, SnapHint::Center), vec![glam::DVec3::ZERO]); + assert_eq!(hints(&snap, SnapHint::Midpoint).len(), 1); + // Ends go through the hint channel, not `key_vertices`: a pair there + // would also offer the chord's midpoint, which is not on the arc. + assert_eq!(hints(&snap, SnapHint::Endpoint).len(), 2); + assert!(snap.key_vertices.is_empty()); + } + + #[test] + fn a_polyline_puts_its_vertices_in_the_chain_channel() { + let mut polyline = LwPolylineEnt::default(); + polyline.normal = v3(0.0, 0.0, 1.0); + polyline.vertices = vec![ + LwVertex::from_coords(0.0, 0.0), + LwVertex::from_coords(10.0, 0.0), + LwVertex::from_coords(10.0, 5.0), + ]; + let snap = curve_snap(&EntityType::LwPolyline(polyline)).unwrap(); + assert_eq!(snap.key_vertices.len(), 3); + // Midpoints are derived from those by the snap engine, so emitting + // them here as well would offer every one of them twice. + assert!(snap.snap_pts.is_empty(), "{:?}", snap.snap_pts); + } + + #[test] + fn a_closed_curve_has_no_ends_to_offer() { + let mut circle = CircleEnt::default(); + circle.radius = 1.0; + circle.normal = v3(0.0, 0.0, 1.0); + let snap = curve_snap(&EntityType::Circle(circle)).unwrap(); + assert!(snap.key_vertices.is_empty()); + assert!(hints(&snap, SnapHint::Endpoint).is_empty()); + assert!(hints(&snap, SnapHint::Midpoint).is_empty()); + assert_eq!(hints(&snap, SnapHint::Quadrant).len(), 4); + } + + #[test] + fn entities_that_are_not_curves_say_so() { + assert!(entity_curve(&EntityType::Point(Default::default())).is_none()); + assert!(entity_curve(&EntityType::Text(Default::default())).is_none()); + assert!(entity_curve(&EntityType::Hatch(Default::default())).is_none()); + assert!(entity_curve(&EntityType::MText(Default::default())).is_none()); + // A 3D polyline is a curve but not a planar one, so it goes the same + // way rather than being flattened. + assert!(entity_curve(&EntityType::Polyline3D(Default::default())).is_none()); + } + + #[test] + fn a_zero_normal_falls_back_rather_than_collapsing() { + let mut circle = CircleEnt::default(); + circle.center = v3(1.0, 2.0, 0.0); + circle.radius = 1.0; + circle.normal = v3(0.0, 0.0, 0.0); + let curve = entity_curve(&EntityType::Circle(circle)).unwrap(); + assert!(curve.plane.is_xy_aligned()); + assert_eq!(curve.point_at(0.0), [2.0, 2.0, 0.0]); + } + + #[test] + fn a_ray_keeps_its_direction_and_a_construction_line_its_extent() { + let mut ray = RayEnt::default(); + ray.base_point = v3(1.0, 1.0, 0.0); + ray.direction = v3(2.0, 0.0, 0.0); + let curve = entity_curve(&EntityType::Ray(ray)).unwrap(); + assert_eq!(curve.extent(), acadrust::kernel::geom2d::Extent::Forward); + assert_eq!(curve.point_at(1.0), [3.0, 1.0, 0.0]); + + let mut line = XLineEnt::default(); + line.base_point = v3(0.0, 0.0, 0.0); + line.direction = v3(0.0, 3.0, 0.0); + let curve = entity_curve(&EntityType::XLine(line)).unwrap(); + assert_eq!(curve.extent(), acadrust::kernel::geom2d::Extent::Infinite); + assert_eq!(curve.point_at(-1.0), [0.0, -3.0, 0.0]); + } +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 69e81b14e..8d1d02182 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -2,6 +2,7 @@ pub mod arc; pub mod attribute; pub mod circle; pub mod common; +pub mod curve; pub mod dim_override; pub mod dimension; pub mod ellipse; diff --git a/src/modules/draw/modify/spline_ops.rs b/src/modules/draw/modify/spline_ops.rs index e13ef55e9..662f5a24a 100644 --- a/src/modules/draw/modify/spline_ops.rs +++ b/src/modules/draw/modify/spline_ops.rs @@ -11,6 +11,7 @@ use acadrust::entities::Spline; use acadrust::kernel::geom2d::{NurbsCurve, Parameterization}; +use acadrust::kernel::space::Plane; use acadrust::types::Vector3; use acadrust::Handle; @@ -38,13 +39,36 @@ fn elevation(spl: &Spline) -> f64 { /// any other instead of failing every command that touches it. /// /// `None` only when neither form has enough points to describe a curve. +/// +/// Reads the stored points as XY directly, which is right for the splines the +/// editing commands work on — they run in the world frame. A spline on an +/// extruded plane needs [`spline_to_nurbs_on`] instead. pub fn spline_to_nurbs(spl: &Spline) -> Option { + spline_to_nurbs_with(spl, |p| [p.x, p.y], |v| [v.x, v.y]) +} + +/// [`spline_to_nurbs`] with the points expressed in `plane`'s coordinates. +/// +/// The two differ only for a spline whose extrusion normal is not +Z. Where +/// it is, the projection reduces to reading X and Y, so this is the same +/// conversion with the frame made explicit. +/// +/// `None` additionally when the plane is degenerate, since there is then no +/// coordinate to express the points in. +pub fn spline_to_nurbs_on(spl: &Spline, plane: &Plane) -> Option { + let point = |p: &Vector3| plane.project([p.x, p.y, p.z]).unwrap_or([p.x, p.y]); + let vector = |v: &Vector3| plane.project_vector([v.x, v.y, v.z]).unwrap_or([v.x, v.y]); + plane.normal()?; + spline_to_nurbs_with(spl, point, vector) +} + +fn spline_to_nurbs_with( + spl: &Spline, + point: impl Fn(&Vector3) -> [f64; 2], + vector: impl Fn(&Vector3) -> [f64; 2], +) -> Option { let degree = (spl.degree.max(1)) as usize; - let control_points: Vec<[f64; 2]> = spl - .control_points - .iter() - .map(|p| [p.x, p.y]) - .collect(); + let control_points: Vec<[f64; 2]> = spl.control_points.iter().map(&point).collect(); let weights = (!spl.weights.is_empty()).then(|| spl.weights.clone()); if let Some(curve) = @@ -54,9 +78,10 @@ pub fn spline_to_nurbs(spl: &Spline) -> Option { } // No usable control polygon, so this is a fit-point spline. - let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(|p| [p.x, p.y]).collect(); - let tangent = |v: &acadrust::types::Vector3| { - (v.x * v.x + v.y * v.y > 1e-18).then_some([v.x, v.y]) + let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect(); + let tangent = |v: &Vector3| { + let flat = vector(v); + (flat[0] * flat[0] + flat[1] * flat[1] > 1e-18).then_some(flat) }; NurbsCurve::interpolate( &fit,