forked from 1b-agent/opencadstudio-upstream
900 lines
36 KiB
Rust
900 lines
36 KiB
Rust
//! 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::types::Vector3;
|
||
use acadrust::EntityType;
|
||
use cadkernel::geom2d::{
|
||
characteristic_points, Arc, Circle, Curve, Ellipse, EllipseArc, Line, Polyline, PolylineVertex,
|
||
Ray, SnapKind, Transform, XLine,
|
||
};
|
||
use cadkernel::space::{are_coplanar, coplanarity_tolerance, PlanarCurve, Plane, Vec3};
|
||
|
||
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]
|
||
}
|
||
|
||
fn vector3(v: Vec3) -> Vector3 {
|
||
Vector3::new(v.x, v.y, v.z)
|
||
}
|
||
|
||
pub(crate) fn point_along(origin: Vector3, direction: Vector3, parameter: f64) -> Vector3 {
|
||
vector3(Vec3::from(xyz(origin)) + Vec3::from(xyz(direction)) * parameter)
|
||
}
|
||
|
||
pub(crate) fn unit_direction(from: Vector3, through: Vector3) -> Option<Vector3> {
|
||
unit_vector3(Vec3::from(xyz(through)) - Vec3::from(xyz(from)))
|
||
}
|
||
|
||
pub(crate) fn unit_vector(vector: Vector3) -> Option<Vector3> {
|
||
unit_vector3(Vec3::from(xyz(vector)))
|
||
}
|
||
|
||
fn unit_vector3(vector: Vec3) -> Option<Vector3> {
|
||
let unit = vector.normalize()?;
|
||
(unit.x.is_finite() && unit.y.is_finite() && unit.z.is_finite()).then(|| vector3(unit))
|
||
}
|
||
|
||
/// 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<PlanarCurve> {
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Exact spatial source geometry for commands that traverse nonplanar curves.
|
||
/// Curve construction and arc-length calculations remain in the kernel.
|
||
pub fn entity_spatial_measurement(entity: &EntityType) -> Option<cadkernel::space::ArcLengthCurve3> {
|
||
use cadkernel::space::{ArcLengthCurve3, NurbsCurve3};
|
||
let curve = match entity {
|
||
EntityType::Polyline3D(polyline) => {
|
||
if polyline.flags.spline_fit {
|
||
use acadrust::entities::polyline3d::SmoothSurfaceType;
|
||
let degree = match polyline.smooth_type {
|
||
SmoothSurfaceType::QuadraticBSpline => 2,
|
||
SmoothSurfaceType::CubicBSpline | SmoothSurfaceType::Bezier => 3,
|
||
SmoothSurfaceType::None => return None,
|
||
};
|
||
let controls: Vec<_> = crate::entities::polyline::polyline3d_controls(polyline)
|
||
.into_iter().map(|vertex| xyz(vertex.position)).collect();
|
||
let curve = NurbsCurve3::from_control_polygon(degree, &controls, polyline.is_closed())?;
|
||
return ArcLengthCurve3::from_nurbs(curve);
|
||
}
|
||
let points: Vec<_> = polyline.vertices.iter().map(|vertex| {
|
||
[vertex.position.x, vertex.position.y, vertex.position.z]
|
||
}).collect();
|
||
return ArcLengthCurve3::from_polyline(&points, polyline.is_closed());
|
||
}
|
||
EntityType::Spline(spline) => {
|
||
if crate::entities::spline::uses_fit_method(spline) {
|
||
crate::entities::spline::fit_nurbs3(spline)?
|
||
} else {
|
||
let controls: Vec<_> = spline.control_points.iter().copied().map(xyz).collect();
|
||
let weights = if spline.weights.is_empty() { vec![1.0; controls.len()] }
|
||
else { spline.weights.clone() };
|
||
NurbsCurve3::new_strict(spline.degree as usize, controls, spline.knots.clone(), weights)?
|
||
.with_periodicity(spline.flags.periodic)
|
||
}
|
||
}
|
||
_ => return None,
|
||
};
|
||
ArcLengthCurve3::from_nurbs(curve)
|
||
}
|
||
|
||
/// 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<PlanarCurve> {
|
||
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<PlanarCurve> {
|
||
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<PlanarCurve> {
|
||
let vertices: Vec<PolylineVertex> = 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<PlanarCurve> {
|
||
let vertices: Vec<PolylineVertex> = 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<PlanarCurve> {
|
||
let fit_method = crate::entities::spline::uses_fit_method(spline);
|
||
let source = if fit_method {
|
||
&spline.fit_points
|
||
} else {
|
||
&spline.control_points
|
||
};
|
||
let points: Vec<Vector3> = source.to_vec();
|
||
let first = points.first()?;
|
||
if !spline_is_planar(spline) {
|
||
return None;
|
||
}
|
||
let normal = normalized(spline.normal);
|
||
let elevation = Vec3::from(xyz(*first)).dot(Vec3::from(xyz(normal)));
|
||
let plane = ocs_plane(normal, elevation);
|
||
|
||
let point_arrays: Vec<[f64; 3]> = points.iter().copied().map(xyz).collect();
|
||
let tolerance = coplanarity_tolerance(&point_arrays);
|
||
if !points.iter().all(|p| plane.contains(xyz(*p), tolerance)) {
|
||
return None;
|
||
}
|
||
if fit_method && !spline.flags.periodic {
|
||
let plane_normal = Vec3::from(plane.normal()?);
|
||
for tangent in [spline.begin_tangent, spline.end_tangent] {
|
||
let tangent = Vec3::from(xyz(tangent));
|
||
if tangent.length_squared() > 1e-18
|
||
&& tangent.dot(plane_normal).abs()
|
||
> PLANARITY_TOLERANCE * tangent.length().max(1.0)
|
||
{
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
Some(PlanarCurve::new(
|
||
plane,
|
||
Curve::Nurbs(spline_to_nurbs_on(spline, &plane)?),
|
||
))
|
||
}
|
||
|
||
/// Whether the defining points and active tangents share a plane.
|
||
pub fn spline_is_planar(spline: &SplineEnt) -> bool {
|
||
let fit_method = crate::entities::spline::uses_fit_method(spline);
|
||
let points = if fit_method {
|
||
&spline.fit_points
|
||
} else {
|
||
&spline.control_points
|
||
};
|
||
let points: Vec<[f64; 3]> = points.iter().copied().map(xyz).collect();
|
||
let directions: Vec<[f64; 3]> = if fit_method && !spline.flags.periodic {
|
||
vec![xyz(spline.begin_tangent), xyz(spline.end_tangent)]
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
are_coplanar(&points, &directions)
|
||
}
|
||
|
||
/// 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<Curve> {
|
||
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(),
|
||
})
|
||
}
|
||
|
||
/// Re-expresses an axis-aligned LWPOLYLINE in world XY coordinates.
|
||
pub fn lwpolyline_world_xy(polyline: &LwPolylineEnt) -> Option<LwPolylineEnt> {
|
||
let normal = normalized(polyline.normal);
|
||
if normal.x.abs() > 1e-12 || normal.y.abs() > 1e-12 || normal.z.abs() <= 1e-12 {
|
||
return None;
|
||
}
|
||
let Curve::Polyline(curve) = entity_curve_xy(&EntityType::LwPolyline(polyline.clone()))?
|
||
else {
|
||
return None;
|
||
};
|
||
if curve.vertices.len() != polyline.vertices.len() {
|
||
return None;
|
||
}
|
||
|
||
let mut world = polyline.clone();
|
||
for (vertex, kernel) in world.vertices.iter_mut().zip(curve.vertices) {
|
||
vertex.location.x = kernel.position[0];
|
||
vertex.location.y = kernel.position[1];
|
||
vertex.bulge = kernel.bulge;
|
||
}
|
||
world.elevation = ocs_plane(polyline.normal, polyline.elevation).origin[2];
|
||
world.thickness *= normal.z;
|
||
world.normal = Vector3::new(0.0, 0.0, 1.0);
|
||
Some(world)
|
||
}
|
||
|
||
/// Normalizes only LWPOLYLINE entities; other types pass through unchanged.
|
||
pub fn entity_with_lwpolyline_world_xy(entity: &EntityType) -> EntityType {
|
||
match entity {
|
||
EntityType::LwPolyline(polyline) => lwpolyline_world_xy(polyline)
|
||
.map(EntityType::LwPolyline)
|
||
.unwrap_or_else(|| entity.clone()),
|
||
_ => entity.clone(),
|
||
}
|
||
}
|
||
|
||
/// World-space wire points sampled by the kernel's angular policy.
|
||
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> {
|
||
curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
|
||
}
|
||
|
||
/// 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]>,
|
||
}
|
||
|
||
/// Snap candidates for a caller that already has the curve.
|
||
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 {
|
||
unit_vector(normal).unwrap_or(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());
|
||
}
|
||
|
||
#[test]
|
||
fn spatial_polyline_measurement_uses_unflagged_fit_controls() {
|
||
use acadrust::entities::polyline3d::SmoothSurfaceType;
|
||
use acadrust::entities::{Polyline3D, Vertex3DPolyline};
|
||
|
||
let mut polyline = Polyline3D::new();
|
||
polyline.flags.spline_fit = true;
|
||
polyline.smooth_type = SmoothSurfaceType::Bezier;
|
||
polyline.vertices = vec![
|
||
Vertex3DPolyline::from_xyz(0.0, 0.0, 0.0),
|
||
Vertex3DPolyline::from_xyz(1.0, 2.0, 1.0),
|
||
Vertex3DPolyline::from_xyz(3.0, 1.0, 3.0),
|
||
Vertex3DPolyline::from_xyz(5.0, 4.0, 2.0),
|
||
];
|
||
|
||
let curve = entity_spatial_measurement(&EntityType::Polyline3D(polyline)).unwrap();
|
||
assert!(curve.length().is_finite() && curve.length() > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn spatial_fit_spline_accepts_unset_tangents_and_closes() {
|
||
let mut spline = SplineEnt::default();
|
||
spline.degree = 3;
|
||
spline.flags.closed = true;
|
||
spline.fit_points = vec![
|
||
v3(0.0, 0.0, 0.0),
|
||
v3(2.0, 0.0, 1.0),
|
||
v3(2.0, 2.0, 3.0),
|
||
v3(0.0, 2.0, 1.0),
|
||
];
|
||
|
||
let curve = entity_spatial_measurement(&EntityType::Spline(spline)).unwrap();
|
||
assert!(curve.is_closed());
|
||
assert!(curve.length().is_finite() && curve.length() > 0.0);
|
||
}
|
||
|
||
fn hints(snap: &CurveSnap, want: SnapHint) -> Vec<glam::DVec3> {
|
||
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 = snap_from(&entity_curve(&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 = snap_from(&entity_curve(&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 = snap_from(&entity_curve(&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 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());
|
||
}
|
||
|
||
/// The whole stack resolves from here: OCS reaches the B-rep layer and
|
||
/// the ACIS bridge through the same alias every other CAD type comes in
|
||
/// by. A compile-time check, so a chain that stops resolving is caught
|
||
/// where it happens rather than the next time somebody reaches for it.
|
||
#[test]
|
||
fn the_solid_layer_and_the_acis_bridge_are_reachable() {
|
||
let solid = cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3])
|
||
.expect("the kernel builds its own primitives");
|
||
assert!(solid.validate().is_empty());
|
||
assert_eq!(solid.euler_characteristic(), 2);
|
||
let document = acadrust::entities::acis::types::SatDocument::new();
|
||
let (bodies, loss) = cadkernel::acis::lift(&document);
|
||
assert!(bodies.is_empty() && loss.is_empty(), "an empty document lifts to nothing");
|
||
}
|
||
|
||
#[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(), cadkernel::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(), cadkernel::geom2d::Extent::Infinite);
|
||
assert_eq!(curve.point_at(-1.0), [0.0, -3.0, 0.0]);
|
||
}
|
||
}
|