fix(area): measure the curve, not a polygon through some of its points

A hatch's area was not the area of anything. Its boundary was flattened
into a ring by pushing an arc's *centre* into it, and a spline's control
points — neither of which is on the boundary — and the shoelace formula
was run over the result. Boundary edges now go through the kernel, which
measures each for what it encloses: an arc contributes its sector, a
spline is integrated.

AREA had three measurements between them. The bulged-polyline one
carried its own arc corrections; the planar entities it did not cover
fell through to mass properties. Both are now one call on the entity's
curve, which gets the bulges, the ellipse and the spline right in the
same place. What is left is the genuinely spatial — a 3D polyline, a
spline whose points wander off any plane — measured as rings of points
by Newell's method rather than by projecting them flat.

Regions were doing the same Newell sum by hand. They share it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-08-09 01:44:04 +03:00
commit 77a9167e34
4 changed files with 168 additions and 141 deletions

2
Cargo.lock generated
View file

@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git#77d8ee9d3ee7f8bfafecbafb0ee3097c4b413ccd" source = "git+https://github.com/HakanSeven12/cadkernel.git#e4c9fe626a60fd1500e8f2264e5a712e939c4027"
dependencies = [ dependencies = [
"cavalier_contours", "cavalier_contours",
] ]

View file

@ -1,4 +1,9 @@
use acadrust::entities::{BoundaryEdge, Hatch}; use acadrust::entities::{BoundaryEdge, Hatch};
use acadrust::kernel::geom2d::{
Arc as KernelArc, Curve as KernelCurve, Ellipse as KernelEllipse,
EllipseArc as KernelEllipseArc, Line as KernelLine, NurbsCurve as KernelNurbs,
Parameterization, Polyline as KernelPolyline, PolylineVertex as KernelVertex,
};
use glam::Vec3; use glam::Vec3;
use crate::t; use crate::t;
@ -9,54 +14,128 @@ use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Pr
use crate::scene::convert::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, FallbackGeometry}; use crate::scene::convert::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, FallbackGeometry};
use crate::scene::model::wire_model::SnapHint; use crate::scene::model::wire_model::SnapHint;
/// Signed area of every boundary path, summed via the shoelace formula over /// The area the hatch's boundary paths enclose.
/// the OCS boundary vertices (bulge/curved edges approximated by their end ///
/// points). Returns the absolute total area. /// Summed edge by edge through the kernel, which measures what each edge
/// actually encloses rather than what a polygon through some of its points
/// would. The version this replaced pushed an arc's *centre* into the ring
/// and a spline's control points — neither of which is on the boundary — so
/// the number it produced was not the area of anything.
///
/// Outer paths and their holes both contribute; the sign of a loop says
/// which it is, so the magnitude of the sum is the region's own area.
fn boundary_area(h: &Hatch) -> f64 { fn boundary_area(h: &Hatch) -> f64 {
let mut area = 0.0; let mut area = 0.0;
for path in &h.paths { for path in &h.paths {
let mut ring: Vec<[f64; 2]> = Vec::new(); let mut path_area = 0.0;
let mut ends: Vec<[f64; 2]> = Vec::new();
for edge in &path.edges { for edge in &path.edges {
match edge { let Some(curve) = edge_curve(edge) else {
BoundaryEdge::Polyline(poly) => {
for v in &poly.vertices {
ring.push([v.x, v.y]);
}
}
BoundaryEdge::Line(l) => {
ring.push([l.start.x, l.start.y]);
ring.push([l.end.x, l.end.y]);
}
BoundaryEdge::CircularArc(a) => ring.push([a.center.x, a.center.y]),
BoundaryEdge::EllipticArc(e) => ring.push([e.center.x, e.center.y]),
BoundaryEdge::Spline(s) => {
let src = if !s.fit_points.is_empty() {
s.fit_points.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()
} else {
s.control_points
.iter()
.map(|p| [p.x, p.y])
.collect::<Vec<_>>()
};
ring.extend(src);
}
}
}
let n = ring.len();
if n < 3 {
continue; continue;
};
path_area += curve.enclosed_area();
ends.push(curve.point_at(0.0));
ends.push(curve.point_at(1.0));
} }
let mut acc = 0.0; // Edges are stored as separate pieces, so the chain has to be closed
for i in 0..n { // by the chord from the last end back to the first — the same closing
let a = ring[i]; // an open polyline gets.
let b = ring[(i + 1) % n]; if let (Some(first), Some(last)) = (ends.first(), ends.last()) {
acc += a[0] * b[1] - b[0] * a[1]; path_area += 0.5 * (last[0] * first[1] - first[0] * last[1]);
} }
area += acc.abs() * 0.5; area += path_area.abs();
} }
area area
} }
/// A hatch boundary edge as a kernel curve, in the hatch's own OCS.
fn edge_curve(edge: &BoundaryEdge) -> Option<KernelCurve> {
Some(match edge {
BoundaryEdge::Line(l) => KernelCurve::Line(KernelLine {
start: [l.start.x, l.start.y],
end: [l.end.x, l.end.y],
}),
BoundaryEdge::CircularArc(a) => {
// A clockwise edge is the same arc walked the other way, so the
// stored angles swap rather than the sweep going negative.
let (start, end) = if a.counter_clockwise {
(a.start_angle, a.end_angle)
} else {
(a.end_angle, a.start_angle)
};
KernelCurve::Arc(KernelArc {
centre: [a.center.x, a.center.y],
radius: a.radius,
start_angle: start,
end_angle: end,
})
}
BoundaryEdge::EllipticArc(e) => {
let major = (e.major_axis_endpoint.x, e.major_axis_endpoint.y);
let radius = major.0.hypot(major.1);
if radius < 1e-12 {
return None;
}
let (start, end) = if e.counter_clockwise {
(e.start_angle, e.end_angle)
} else {
(e.end_angle, e.start_angle)
};
KernelCurve::Ellipse(KernelEllipseArc {
ellipse: KernelEllipse {
centre: [e.center.x, e.center.y],
major_radius: radius,
minor_radius: radius * e.minor_axis_ratio,
major_axis: [major.0 / radius, major.1 / radius],
},
start_parameter: start,
end_parameter: end,
})
}
BoundaryEdge::Polyline(poly) => {
let vertices: Vec<KernelVertex> = poly
.vertices
.iter()
.map(|v| KernelVertex {
position: [v.x, v.y],
// A boundary polyline stores its bulge in the vertex's
// third component.
bulge: v.z,
})
.collect();
if vertices.len() < 2 {
return None;
}
KernelCurve::Polyline(KernelPolyline {
vertices,
closed: poly.is_closed,
})
}
BoundaryEdge::Spline(s) => {
let control: Vec<[f64; 2]> = s.control_points.iter().map(|p| [p.x, p.y]).collect();
// A boundary spline's control points carry their weight in the
// third component; for a polynomial one it is unset and the
// kernel's own default of all-ones applies.
let weights = s
.rational
.then(|| s.control_points.iter().map(|p| p.z).collect::<Vec<f64>>());
let curve = KernelNurbs::new(
s.degree.max(1) as usize,
control,
s.knots.clone(),
weights,
)
.or_else(|| {
// A fit-point boundary spline, interpolated the same way a
// SPLINE entity's is.
let fit: Vec<[f64; 2]> = s.fit_points.iter().map(|p| [p.x, p.y]).collect();
KernelNurbs::interpolate(&fit, None, None, Parameterization::Chord)
})?;
KernelCurve::Nurbs(curve)
}
})
}
/// Mean of every boundary edge point (OCS) — the centroid used to place the /// Mean of every boundary edge point (OCS) — the centroid used to place the
/// pattern-origin grip on the hatch body. `None` when there are no boundary /// pattern-origin grip on the hatch body. `None` when there are no boundary
/// points. /// points.

View file

@ -7,6 +7,7 @@
// the MeshModel vertices to match. // the MeshModel vertices to match.
use acadrust::entities::{Body, Region, Solid3D, Surface}; use acadrust::entities::{Body, Region, Solid3D, Surface};
use acadrust::kernel::space::polygon;
use crate::t; use crate::t;
use crate::command::EntityTransform; use crate::command::EntityTransform;
use crate::entities::common::{center_grip, edit_prop as edit, parse_f64, ro_prop as ro}; use crate::entities::common::{center_grip, edit_prop as edit, parse_f64, ro_prop as ro};
@ -203,30 +204,25 @@ fn position_section(prefix: &str, p: &acadrust::types::Vector3) -> PropSection {
/// holes subtract) and halves its magnitude — exact for a single planar /// holes subtract) and halves its magnitude — exact for a single planar
/// loop, approximate for multi-loop or curved regions. Returns zeros when /// loop, approximate for multi-loop or curved regions. Returns zeros when
/// there is nothing to measure. /// there is nothing to measure.
/// The area and perimeter of a region's boundary wires.
///
/// Measured in space rather than in projection: a region need not lie in a
/// coordinate plane, and flattening it to XY first would report its shadow.
fn region_area_perimeter(wires: &[acadrust::entities::Wire]) -> (f64, f64) { fn region_area_perimeter(wires: &[acadrust::entities::Wire]) -> (f64, f64) {
let mut area_vector = [0.0f64; 3];
let mut perimeter = 0.0; let mut perimeter = 0.0;
let (mut nx, mut ny, mut nz) = (0.0, 0.0, 0.0);
for wire in wires { for wire in wires {
let pts = &wire.points; let ring: Vec<[f64; 3]> = wire.points.iter().map(|p| [p.x, p.y, p.z]).collect();
if pts.len() < 2 { // Wires arrive as open chains that the region closes between them, so
continue; // the length is the chain's and the area vectors are summed before
} // being measured — one wire's contribution is not an area on its own.
for seg in pts.windows(2) { perimeter += polygon::chain_length(&ring);
let dx = seg[1].x - seg[0].x; let piece = polygon::area_vector(&ring);
let dy = seg[1].y - seg[0].y; for axis in 0..3 {
let dz = seg[1].z - seg[0].z; area_vector[axis] += piece[axis];
perimeter += (dx * dx + dy * dy + dz * dz).sqrt();
}
let n = pts.len();
for i in 0..n {
let a = &pts[i];
let b = &pts[(i + 1) % n];
nx += (a.y - b.y) * (a.z + b.z);
ny += (a.z - b.z) * (a.x + b.x);
nz += (a.x - b.x) * (a.y + b.y);
} }
} }
let area = 0.5 * (nx * nx + ny * ny + nz * nz).sqrt(); let area = (area_vector[0].powi(2) + area_vector[1].powi(2) + area_vector[2].powi(2)).sqrt();
(area, perimeter) (area, perimeter)
} }

View file

@ -1,9 +1,11 @@
use acadrust::kernel::space::polygon;
use acadrust::{EntityType, Handle}; use acadrust::{EntityType, Handle};
use glam::DVec3; use glam::DVec3;
use crate::command::{ use crate::command::{
AreaPreviewRegion, AreaPreviewSource, CadCommand, CmdOption, CmdResult, SelectionEntity, AreaPreviewRegion, AreaPreviewSource, CadCommand, CmdOption, CmdResult, SelectionEntity,
}; };
use crate::entities::curve::entity_curve;
use crate::entities::traits::EntityTypeOps; use crate::entities::traits::EntityTypeOps;
use crate::scene::model::wire_model::WireModel; use crate::scene::model::wire_model::WireModel;
@ -47,100 +49,50 @@ impl AreaCommand {
CmdOption { label, keyword: keyword.to_string() } CmdOption { label, keyword: keyword.to_string() }
} }
/// Area and perimeter of a ring of picked points.
///
/// Measured in space rather than in projection — a picked sequence need
/// not lie in a coordinate plane, and flattening it to XY first would
/// report its shadow.
fn point_measurement(points: &[DVec3], close_perimeter: bool) -> AreaMeasurement { fn point_measurement(points: &[DVec3], close_perimeter: bool) -> AreaMeasurement {
if points.len() < 2 { if points.len() < 2 {
return AreaMeasurement { area: 0.0, perimeter: Some(0.0) }; return AreaMeasurement { area: 0.0, perimeter: Some(0.0) };
} }
let ring: Vec<[f64; 3]> = points.iter().map(|p| [p.x, p.y, p.z]).collect();
let origin = points[0]; let perimeter = if close_perimeter {
let mut area_vector = DVec3::ZERO; polygon::perimeter(&ring)
for index in 0..points.len() { } else {
let a = points[index] - origin; polygon::chain_length(&ring)
let b = points[(index + 1) % points.len()] - origin; };
area_vector += a.cross(b); AreaMeasurement {
area: polygon::area(&ring),
perimeter: Some(perimeter),
}
} }
let mut perimeter = points /// Area and perimeter of an entity whose geometry is a planar curve.
.windows(2) ///
.map(|pair| (pair[1] - pair[0]).length()) /// Both exact: the kernel measures the curve rather than a polygon
.sum::<f64>(); /// through some of its points, so a bulged polyline counts each arc's own
if close_perimeter { /// bite and length, and a spline is integrated rather than sampled.
perimeter += (points[0] - points[points.len() - 1]).length(); fn curve_measurement(entity: &EntityType) -> Option<AreaMeasurement> {
} let curve = entity_curve(entity)?;
let perimeter = curve.length();
AreaMeasurement { area: area_vector.length() * 0.5, perimeter: Some(perimeter) } Some(AreaMeasurement {
} area: curve.curve.enclosed_area().abs(),
perimeter: perimeter.is_finite().then_some(perimeter),
fn bulged_polyline_measurement( })
points: &[(f64, f64)],
bulges: &[f64],
closed: bool,
) -> AreaMeasurement {
let count = points.len();
if count < 2 {
return AreaMeasurement { area: 0.0, perimeter: Some(0.0) };
}
let segment_count = if closed { count } else { count - 1 };
let origin = points[0];
let mut signed_area = 0.0;
let mut perimeter = 0.0;
for index in 0..segment_count {
let a = points[index];
let b = points[(index + 1) % count];
let local_a = (a.0 - origin.0, a.1 - origin.1);
let local_b = (b.0 - origin.0, b.1 - origin.1);
signed_area += 0.5 * (local_a.0 * local_b.1 - local_b.0 * local_a.1);
let chord = (b.0 - a.0).hypot(b.1 - a.1);
let bulge = bulges.get(index).copied().unwrap_or(0.0);
if bulge.abs() < 1e-12 || chord <= 1e-12 {
perimeter += chord;
continue;
}
let angle = 4.0 * bulge.atan();
let sine = (angle * 0.5).sin().abs();
if sine <= 1e-12 {
perimeter += chord;
continue;
}
let radius = chord / (2.0 * sine);
signed_area += 0.5 * radius * radius * (angle - angle.sin());
perimeter += radius * angle.abs();
}
AreaMeasurement { area: signed_area.abs(), perimeter: Some(perimeter) }
} }
fn entity_measurement(entity: &EntityType) -> Option<AreaMeasurement> { fn entity_measurement(entity: &EntityType) -> Option<AreaMeasurement> {
// Anything the converter recognises is measured from its own curve.
if let Some(measurement) = Self::curve_measurement(entity) {
return Some(measurement);
}
match entity { match entity {
EntityType::LwPolyline(polyline) => { // What is left is the genuinely spatial: a 3D polyline, and a
let points = polyline // spline whose points wander off any plane. Both are measured as
.vertices // rings of points in space.
.iter()
.map(|vertex| (vertex.location.x, vertex.location.y))
.collect::<Vec<_>>();
let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::<Vec<_>>();
Some(Self::bulged_polyline_measurement(
&points,
&bulges,
polyline.is_closed,
))
}
EntityType::Polyline2D(polyline) => {
let points = polyline
.vertices
.iter()
.map(|vertex| (vertex.location.x, vertex.location.y))
.collect::<Vec<_>>();
let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::<Vec<_>>();
Some(Self::bulged_polyline_measurement(
&points,
&bulges,
polyline.is_closed(),
))
}
EntityType::Polyline(polyline) => { EntityType::Polyline(polyline) => {
let points = polyline let points = polyline
.vertices .vertices