Extents: a wide polyline's bounds follow its drawn band (fixes #90) (#127)

Polyline extents were the centre line padded by const_width / 2, so
per-segment start/end widths never counted: a band built from vertex
widths, a taper, or segments of different widths reported the
zero-height bounds of the centre line. The bounds now enclose the
filled band of every segment as it is drawn: the exact corners of
straight segments (tapers included), and both edges of arc segments,
sampled along the arc with the width interpolated and widened by the
sampling tolerance so they are never too small. The constant width
still wins over vertex widths, the end vertex of an open polyline
starts no segment, and non-finite or negative widths count as zero.
Thin polylines take the same path as before.

Fixes #90.
This commit is contained in:
Books 2026-10-11 03:26:30 +11:00 committed by GitHub
parent 3c577968f6
commit 348d3b6724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 137 additions and 4 deletions

View File

@ -1,8 +1,8 @@
//! Approximate entity bounds (used for extents, zoom and selection pre-filtering).
use cadcraft_geom::{Bounds2, Polyline, Vec2};
use cadcraft_geom::{Arc, Bounds2, EPS, Polyline, Vec2, bulge_to_arc};
use crate::{Drawing, Entity, EntityKind, Prim};
use crate::{Drawing, Entity, EntityKind, LwPolyline, Prim};
/// Nested block references deeper than this are ignored (cyclic or hostile files).
pub const MAX_BLOCK_DEPTH: usize = 16;
@ -122,7 +122,7 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
let v = i.v.xy() * i.size.y;
Bounds2::from_points([o, o + u, o + v, o + u + v])
}
EntityKind::LwPolyline(p) => Polyline { vertices: p.vertices.clone(), closed: p.closed }.bounds().expand(p.const_width / 2.0),
EntityKind::LwPolyline(p) => lwpolyline_bounds(p),
kind => {
let mut b = Bounds2::EMPTY;
for p in kind.prims() {
@ -149,6 +149,58 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
}
}
/// The polyline's drawn extent: its centre line, widened by the filled band of every segment,
/// with the constant width or each segment's own start/end widths tapering along it, as drawn.
fn lwpolyline_bounds(p: &LwPolyline) -> Bounds2 {
let mut b = Polyline { vertices: p.vertices.clone(), closed: p.closed }.bounds();
let n = p.vertices.len();
let count = if p.closed { n } else { n.saturating_sub(1) };
// The constant width wins, as when drawing; non-finite or negative widths count as zero.
let half = |w: f64| if w.is_finite() && w > 0.0 { w / 2.0 } else { 0.0 };
for i in 0..count {
let (Some(v), Some(w)) = (p.vertices.get(i), p.vertices.get((i + 1) % n)) else { continue };
let (h0, h1) = if p.const_width > 0.0 { (half(p.const_width), half(p.const_width)) } else { (half(v.start_width), half(v.end_width)) };
if h0 == 0.0 && h1 == 0.0 {
// A thin segment: the centre line already counts.
continue;
}
match bulge_to_arc(v.p, w.p, v.bulge) {
// A straight segment's band is a trapezoid: its four corners are exact.
None if !v.p.near(w.p, EPS) => {
let nrm = (w.p - v.p).normalized().perp();
for q in [v.p + nrm * h0, v.p - nrm * h0, w.p + nrm * h1, w.p - nrm * h1] {
b.add(q);
}
}
None => {}
// An arc segment: both band edges, sampled finely along the arc with the width
// interpolated along it, then widened by the sampling tolerance so the curve between
// samples is inside.
Some((arc, ccw)) => {
let outer = arc.radius + h0.max(h1);
let tol = outer * 1e-4;
let mut pts = Vec::new();
Arc { radius: outer, ..arc }.tessellate(tol, &mut pts);
let last = pts.len().saturating_sub(1).max(1) as f64;
let mut edges = Bounds2::EMPTY;
for (k, q) in pts.iter().enumerate() {
// The fraction along the segment, which runs against the arc when not ccw.
let t = k as f64 / last;
let t = if ccw { t } else { 1.0 - t };
let h = h0 + (h1 - h0) * t;
let dir = (*q - arc.center).normalized();
edges.add(arc.center + dir * (arc.radius + h));
edges.add(arc.center + dir * (arc.radius - h));
}
if !edges.is_empty() {
b = b.union(&edges.expand(tol));
}
}
}
}
b
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,5 +1,5 @@
use crate::*;
use cadcraft_geom::{Mat3, Vec2, Vec3};
use cadcraft_geom::{Bounds2, Mat3, PolyVertex, Vec2, Vec3};
fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
@ -106,3 +106,84 @@ fn paper_space_entities() {
assert!(d.remove_entity(h).is_some());
assert_eq!(d.entity_count(), 0);
}
fn wide_pline(verts: &[(f64, f64, f64, f64, f64)], closed: bool, const_width: f64) -> Bounds2 {
// (x, y, start width, end width, bulge) per vertex.
let mut d = Drawing::new_metric();
let vertices = verts.iter().map(|&(x, y, s, e, b)| PolyVertex { p: Vec2::new(x, y), bulge: b, start_width: s, end_width: e }).collect();
let kind = EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width, elevation: 0.0, plinegen: false });
d.add(&Space::Model, Common::default(), kind).unwrap();
d.extents(&Space::Model)
}
fn near_bounds(b: Bounds2, min: (f64, f64), max: (f64, f64)) -> bool {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
close(b.min.x, min.0) && close(b.min.y, min.1) && close(b.max.x, max.0) && close(b.max.y, max.1)
}
/// #90: a polyline's extents enclose its drawn band, whether the width is constant or per segment.
#[test]
fn wide_polyline_extents_follow_the_band() {
// The issue's cases: constant width, the same band from per-segment widths, a taper,
// two segments of different widths, and a width on the open end vertex (which draws nothing).
let b = wide_pline(&[(0.0, 0.0, 0.0, 0.0, 0.0), (10.0, 0.0, 0.0, 0.0, 0.0)], false, 2.0);
assert!(near_bounds(b, (0.0, -1.0), (10.0, 1.0)), "{b:?}");
let b = wide_pline(&[(0.0, 0.0, 2.0, 2.0, 0.0), (10.0, 0.0, 2.0, 2.0, 0.0)], false, 0.0);
assert!(near_bounds(b, (0.0, -1.0), (10.0, 1.0)), "{b:?}");
let b = wide_pline(&[(0.0, 0.0, 0.0, 4.0, 0.0), (10.0, 0.0, 4.0, 0.0, 0.0)], false, 0.0);
assert!(near_bounds(b, (0.0, -2.0), (10.0, 2.0)), "{b:?}");
let b = wide_pline(&[(0.0, 0.0, 2.0, 2.0, 0.0), (10.0, 0.0, 6.0, 6.0, 0.0), (20.0, 0.0, 0.0, 0.0, 0.0)], false, 0.0);
assert!(near_bounds(b, (0.0, -3.0), (20.0, 3.0)), "{b:?}");
let b = wide_pline(&[(0.0, 0.0, 0.0, 0.0, 0.0), (10.0, 0.0, 6.0, 6.0, 0.0)], false, 0.0);
assert!(near_bounds(b, (0.0, 0.0), (10.0, 0.0)), "{b:?}");
// A diagonal band: its exact corners, not the centre line padded by the half-width.
let h = std::f64::consts::FRAC_1_SQRT_2;
let b = wide_pline(&[(0.0, 0.0, 0.0, 0.0, 0.0), (10.0, 10.0, 0.0, 0.0, 0.0)], false, 2.0);
assert!(near_bounds(b, (-h, -h), (10.0 + h, 10.0 + h)), "{b:?}");
// A closed polyline's closing segment counts (here the only wide one).
let sq = [(0.0, 0.0, 0.0, 0.0, 0.0), (10.0, 0.0, 0.0, 0.0, 0.0), (10.0, 10.0, 0.0, 0.0, 0.0), (0.0, 10.0, 2.0, 2.0, 0.0)];
assert!(near_bounds(wide_pline(&sq, true, 0.0), (-1.0, 0.0), (10.0, 10.0)));
assert!(near_bounds(wide_pline(&sq, false, 0.0), (0.0, 0.0), (10.0, 10.0)), "open: the end vertex starts no segment");
// Non-finite or negative widths count as zero.
let b = wide_pline(&[(0.0, 0.0, f64::NAN, f64::INFINITY, 0.0), (10.0, 0.0, 0.0, 0.0, 0.0)], false, 0.0);
assert!(near_bounds(b, (0.0, 0.0), (10.0, 0.0)), "{b:?}");
let b = wide_pline(&[(0.0, 0.0, -4.0, -4.0, 0.0), (10.0, 0.0, 0.0, 0.0, 0.0)], false, 0.0);
assert!(near_bounds(b, (0.0, 0.0), (10.0, 0.0)), "{b:?}");
}
/// A wide arc segment reaches its outer edge (radius plus half the width), never less, and only
/// by the sampling tolerance more.
#[test]
fn wide_arc_segment_extents_reach_the_outer_edge() {
// A half circle below the axis (bulge 1, counter-clockwise from (0,0) to (10,0)), radius 5.
let exact_min = (-1.0, -6.0);
let exact_max = (11.0, 0.0);
for b in [
wide_pline(&[(0.0, 0.0, 0.0, 0.0, 1.0), (10.0, 0.0, 0.0, 0.0, 0.0)], false, 2.0),
wide_pline(&[(0.0, 0.0, 2.0, 2.0, 1.0), (10.0, 0.0, 0.0, 0.0, 0.0)], false, 0.0),
] {
assert!(b.min.x <= exact_min.0 && b.min.y <= exact_min.1 && b.max.x >= exact_max.0 && b.max.y >= exact_max.1, "never smaller: {b:?}");
assert!(
b.min.x > exact_min.0 - 1e-3 && b.min.y > exact_min.1 - 1e-3 && b.max.x < exact_max.0 + 1e-3 && b.max.y < exact_max.1 + 1e-3,
"tight: {b:?}"
);
}
// A taper along the arc (0 to 4 wide): compared with a brute-force sampling of both band
// edges, since the extremes of a tapering curve lie between the obvious points.
let b = wide_pline(&[(0.0, 0.0, 0.0, 4.0, 1.0), (10.0, 0.0, 0.0, 0.0, 0.0)], false, 0.0);
let mut exact = Bounds2::EMPTY;
for k in 0..=100_000 {
let t = f64::from(k) / 100_000.0;
// Counter-clockwise from angle pi (the start, (0,0)) to 2 pi (the end, (10,0)).
let a = std::f64::consts::PI * (1.0 + t);
let dir = Vec2::new(a.cos(), a.sin());
let h = 2.0 * t;
exact.add(Vec2::new(5.0, 0.0) + dir * (5.0 + h));
exact.add(Vec2::new(5.0, 0.0) + dir * (5.0 - h));
}
for (got, want, outward) in [(b.min.x, exact.min.x, -1.0), (b.min.y, exact.min.y, -1.0), (b.max.x, exact.max.x, 1.0), (b.max.y, exact.max.y, 1.0)]
{
let over = (got - want) * outward;
assert!(over > -1e-6 && over < 1e-3, "got {b:?}, exact {exact:?}");
}
}