feat(drawings): clip bounded hatches to circles

This commit is contained in:
lillith
2026-10-26 17:57:25 +01:00
parent 0c4d882ba9
commit b68fa4f868
6 changed files with 1073 additions and 27 deletions
+27 -6
View File
@@ -1,4 +1,5 @@
//! Analytic Line-only clipping. Uncertain predicates refuse the whole operation.
//! Analytic polygon and canonical-circle clipping. Uncertain predicates refuse
//! the whole operation.
//!
//! Preparation proves simple, exactly closed loops and disjoint interior holes.
//! Consequently even-odd crossings equal outer minus the union of holes. Boundary
@@ -10,6 +11,8 @@ use super::budget::{Budget, Limits};
use super::phase::Param;
use super::{Invalid, MAX_LENGTH_MM};
mod circle;
type Result<T> = std::result::Result<T, Invalid>;
const UNCERTAIN: Invalid = Invalid("unresolved clipping arithmetic");
@@ -237,11 +240,20 @@ fn inside(points: &[Vec2], p: Vec2, budget: &mut Budget) -> Result<bool> {
/// Owned and immutable after complete, bounded topology validation.
#[derive(Debug)]
pub(super) struct PreparedRegion {
loops: Vec<Vec<Vec2>>,
shape: PreparedShape,
}
#[derive(Debug)]
enum PreparedShape {
Polygon(Vec<Vec<Vec2>>),
Circular(circle::CircularRegion),
}
impl PreparedRegion {
pub fn new(region: &Region2, budget: &mut Budget) -> Result<Self> {
if matches!(region.outer.segs.first(), Some(Seg2::Arc { .. })) {
return Ok(Self { shape: PreparedShape::Circular(circle::CircularRegion::new(region, budget)?) });
}
if region.holes.len() > Limits::default().input_pieces / 3 {
return Err(Invalid("too many region holes"));
}
@@ -282,14 +294,18 @@ impl PreparedRegion {
}
}
}
Ok(Self { loops })
Ok(Self { shape: PreparedShape::Polygon(loops) })
}
/// Encloses all vertex projections, including possible extremal candidates.
/// Encloses the complete boundary's normal range, including extremal candidates.
pub fn normal_bounds(&self, frame: &Frame, budget: &mut Budget) -> Result<(f64, f64)> {
let loops = match &self.shape {
PreparedShape::Polygon(loops) => loops,
PreparedShape::Circular(region) => return region.normal_bounds(frame, budget),
};
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for points in &self.loops {
for points in loops {
for &p in points {
budget.work(1)?;
let (_, h) = frame.project(p)?;
@@ -302,10 +318,14 @@ impl PreparedRegion {
}
pub fn clip(&self, frame: &Frame, offset: f64, budget: &mut Budget) -> Result<ClippedLine> {
let loops = match &self.shape {
PreparedShape::Polygon(loops) => loops,
PreparedShape::Circular(region) => return region.clip(frame, offset, budget),
};
let offset = Bound::point(offset)?;
let mut events = Vec::new();
let mut base = 0usize;
for points in &self.loops {
for points in loops {
let mut projected = Vec::new();
for (i, &p) in points.iter().enumerate() {
budget.work(1)?;
@@ -558,6 +578,7 @@ struct Projected {
enum Key {
Vertex(usize),
Edge(usize),
Circle(usize, u8),
}
#[derive(Clone, Copy)]
+162
View File
@@ -0,0 +1,162 @@
//! Complete circles recognized from the documented two-semicircle encoding.
//! This is structural semantics, not a tolerance for closing arbitrary arc joins.
use std::f64::consts::{PI, TAU};
use crate::Loop2;
use super::{Bound, Budget, ClippedLine, Event, Frame, Invalid, Key, MAX_LENGTH_MM, Region2, Result, Seg2, UNCERTAIN, Vec2, event, product, sweep};
#[derive(Clone, Copy, Debug)]
struct Circle {
center: Vec2,
radius: f64,
}
#[derive(Debug)]
pub(super) struct CircularRegion {
outer: Circle,
inner: Option<Circle>,
}
impl CircularRegion {
pub(super) fn new(region: &Region2, budget: &mut Budget) -> Result<Self> {
budget.work(1)?;
if region.holes.len() > 1 {
return Err(Invalid("not supported yet: multiple circular hatch holes"));
}
// Charge the original Arc records, before reducing a boundary to one
// circle. No unchecked traversal or allocation precedes these charges.
for boundary in region.loops() {
budget.input_pieces(boundary.segs.len())?;
budget.work(boundary.segs.len())?;
}
let outer = Circle::new(&region.outer, budget)?;
let inner = region.holes.first().map(|boundary| Circle::new(boundary, budget)).transpose()?;
if let Some(inner) = inner {
budget.work(1)?;
if inner.center != outer.center {
return Err(Invalid("not supported yet: eccentric circular hatch hole"));
}
if inner.radius >= outer.radius {
return Err(Invalid("circular hatch hole must be strictly inside outer circle"));
}
}
Ok(Self { outer, inner })
}
pub(super) fn normal_bounds(&self, frame: &Frame, budget: &mut Budget) -> Result<(f64, f64)> {
budget.work(1)?;
let (_, height) = frame.project(self.outer.center)?;
budget.work(1)?;
let radius = square_root(self.outer.scaled_radius_squared(frame)?, budget)?;
budget.work(1)?;
Ok((height.sub(radius)?.lo, height.add(radius)?.hi))
}
pub(super) fn clip(&self, frame: &Frame, offset: f64, budget: &mut Budget) -> Result<ClippedLine> {
budget.work(1)?;
let offset = Bound::point(offset)?;
let mut events = Vec::new();
self.outer.events(frame, offset, 0, &mut events, budget)?;
if let Some(inner) = self.inner {
inner.events(frame, offset, 1, &mut events, budget)?;
}
sweep(events, budget)
}
}
impl Circle {
fn new(boundary: &Loop2, budget: &mut Budget) -> Result<Self> {
budget.work(1)?;
let [first, second] = boundary.segs.as_slice() else {
return Err(Invalid("not supported yet: noncanonical circular hatch boundary"));
};
// These two checks are covered by the original-piece work charge.
first.check(MAX_LENGTH_MM).map_err(Invalid)?;
second.check(MAX_LENGTH_MM).map_err(Invalid)?;
let (Seg2::Arc { center, radius, start: a, sweep: sa }, Seg2::Arc { center: other_center, radius: other_radius, start: b, sweep: sb }) =
(*first, *second)
else {
return Err(Invalid("not supported yet: mixed circular hatch boundary"));
};
let forward = sa == PI && sb == PI && ((a == 0.0 && b == PI) || (b == 0.0 && a == PI));
let reversed = sa == -PI && sb == -PI && ((a == TAU && b == PI) || (b == TAU && a == PI));
if center != other_center || radius != other_radius || !(forward || reversed) {
return Err(Invalid("not supported yet: noncanonical circular hatch boundary"));
}
// Evaluated sin/cos endpoints do not certify a closed circle. Only the
// exact signatures above mean a full circle here, including signed zero
// equivalence, reversal and cyclic order. No other seam is repaired.
let radius_bound = Bound::point(radius)?;
for component in [center.x, center.y] {
budget.work(1)?;
let center_bound = Bound::point(component)?;
if center_bound.sub(radius_bound)?.lo < -MAX_LENGTH_MM || center_bound.add(radius_bound)?.hi > MAX_LENGTH_MM {
return Err(Invalid("circular hatch boundary exceeds coordinate limit"));
}
}
Ok(Self { center, radius })
}
fn scaled_radius_squared(self, frame: &Frame) -> Result<Bound> {
// The represented u and u.perp are orthogonal but need not have unit
// length. In this frame (t-tc)^2 + (h-hc)^2 = radius^2 / (u dot u).
product(self.radius, self.radius)?.div(frame.norm_squared)
}
fn events(self, frame: &Frame, offset: Bound, boundary: usize, events: &mut Vec<Event>, budget: &mut Budget) -> Result<()> {
budget.work(1)?;
let (center, height) = frame.project(self.center)?;
budget.work(1)?;
let distance = offset.sub(height)?;
let discriminant = self.scaled_radius_squared(frame)?.sub(distance.mul(distance)?)?;
// Charge even a miss or a tangent that paints nothing. A discriminant
// straddling zero is unresolved, not a reason to clamp it to zero.
budget.work(1)?;
match discriminant.sign()? {
-1 => Ok(()),
0 => event(events, Event { at: center, key: Key::Circle(boundary, 0), toggle: false, boundary: 0 }, budget),
1 => {
let reach = square_root(discriminant, budget)?;
budget.work(2)?;
let left = center.sub(reach)?;
let right = center.add(reach)?;
// Keep distinct root identities and enclosures. The shared
// sweep certifies their order and splits at tangent contacts.
event(events, Event { at: left, key: Key::Circle(boundary, 0), toggle: true, boundary: 0 }, budget)?;
event(events, Event { at: right, key: Key::Circle(boundary, 1), toggle: true, boundary: 0 }, budget)
}
_ => Err(UNCERTAIN),
}
}
}
fn square_root(value: Bound, budget: &mut Budget) -> Result<Bound> {
budget.work(1)?;
if value.lo < 0.0 {
return Err(UNCERTAIN);
}
Bound::new(root_endpoint(value.lo, false, budget)?, root_endpoint(value.hi, true, budget)?)
}
fn root_endpoint(value: f64, upper: bool, budget: &mut Budget) -> Result<f64> {
budget.work(1)?;
let mut root = value.sqrt();
// sqrt supplies only a starting candidate. Each returned endpoint proves
// its square inequality using conservative products; four attempts bound
// the cost, not an assumed accuracy guarantee for the platform's sqrt.
for _ in 0..4 {
budget.work(1)?;
if !root.is_finite() || root < 0.0 {
return Err(UNCERTAIN);
}
let squared = product(root, root)?;
let certified = if upper { squared.lo >= value } else { squared.hi <= value };
if certified {
return Ok(root);
}
root = if upper { root.next_up() } else { root.next_down() };
}
Err(UNCERTAIN)
}
+397 -1
View File
@@ -1,4 +1,4 @@
use std::f64::consts::{FRAC_PI_4, PI, SQRT_2};
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, SQRT_2, TAU};
use crate::hatch::budget::{Budget, Limits};
use crate::hatch::clip::{ClippedLine, Frame, PreparedRegion};
@@ -669,3 +669,399 @@ fn interval_adapter_queries_charge_entry_before_invalid_or_empty_span_access() {
}
}
}
fn circular_region(center: Vec2, radius: f64, inner: Option<f64>) -> Region2 {
Region2 { outer: Loop2::circle(center, radius), holes: inner.into_iter().map(|r| Loop2::circle(center, r)).collect() }
}
fn exact_circle_spans(line: &ClippedLine, expected: &[(f64, f64)]) {
let actual: Vec<_> = line.spans().iter().map(|s| (s.start, s.end)).collect();
assert_eq!(actual, expected);
assert!(line.spans().iter().all(|s| s.start < s.end));
}
fn circle_integer_dots(line: &ClippedLine, budget: &mut Budget) -> Vec<f64> {
let mut dots = Vec::new();
for coordinate in -6..=6 {
let t = phase_parameter(coordinate, 0, budget);
for i in 0..line.spans().len() {
if let Some(at) = line.dot_in_span(i, t, budget).unwrap() {
dots.push(at);
}
}
}
dots
}
#[test]
fn canonical_disk_has_exact_secants_outer_tangencies_and_misses() {
let center = p(2.0, -1.0);
let region = circular_region(center, 5.0, None);
let before = region.clone();
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(center, Vec2::X).unwrap();
let (lo, hi) = prepared.normal_bounds(&frame, &mut budget).unwrap();
assert!(lo.is_finite() && hi.is_finite() && lo <= -5.0 && hi >= 5.0);
// x^2+q^2=25 in the center's frame: the secants use 3-4-5 triangles.
for (q, expected, dots) in [
(0.0, vec![(-5.0, 5.0)], vec![-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0]),
(3.0, vec![(-4.0, 4.0)], vec![-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]),
(-3.0, vec![(-4.0, 4.0)], vec![-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]),
(5.0, vec![], vec![]),
(-5.0, vec![], vec![]),
(6.0, vec![], vec![]),
(-6.0, vec![], vec![]),
] {
let line = prepared.clip(&frame, q, &mut budget).unwrap();
exact_circle_spans(&line, &expected);
assert_eq!(circle_integer_dots(&line, &mut budget), dots);
for (span, &(a, b)) in line.spans().iter().zip(&expected) {
assert_eq!(frame.point(q, span.start).unwrap(), center + p(a, q));
assert_eq!(frame.point(q, span.end).unwrap(), center + p(b, q));
}
if q.abs() >= 5.0 {
assert!(!line.contains_strict(0.0, &mut budget).unwrap());
}
}
assert_eq!(region, before);
}
#[test]
fn canonical_annulus_inner_tangencies_split_strokes_without_a_material_gap() {
let center = p(2.0, -1.0);
let region = circular_region(center, 5.0, Some(3.0));
let before = region.clone();
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(center, Vec2::X).unwrap();
for (q, expected, dots) in [
(0.0, vec![(-5.0, -3.0), (3.0, 5.0)], vec![-4.0, 4.0]),
(3.0, vec![(-4.0, 0.0), (0.0, 4.0)], vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]),
(-3.0, vec![(-4.0, 0.0), (0.0, 4.0)], vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]),
(4.0, vec![(-3.0, 3.0)], vec![-2.0, -1.0, 0.0, 1.0, 2.0]),
(-4.0, vec![(-3.0, 3.0)], vec![-2.0, -1.0, 0.0, 1.0, 2.0]),
(5.0, vec![], vec![]),
(-5.0, vec![], vec![]),
] {
let line = prepared.clip(&frame, q, &mut budget).unwrap();
exact_circle_spans(&line, &expected);
assert_eq!(circle_integer_dots(&line, &mut budget), dots);
if q.abs() == 3.0 {
let a = phase_parameter(-1, 0, &mut budget);
let b = phase_parameter(1, 0, &mut budget);
let zero = phase_parameter(0, 0, &mut budget);
for (i, endpoints) in [(0, (-1.0, 0.0)), (1, (0.0, 1.0))] {
let stroke = line.dash_in_span(i, a, b, &mut budget).unwrap().unwrap();
assert_eq!((stroke.start, stroke.end), endpoints);
assert_eq!(line.dot_in_span(i, zero, &mut budget).unwrap(), None);
}
}
}
assert_eq!(region, before);
}
#[test]
fn canonical_circle_reversal_cyclic_order_and_translation_preserve_all_contacts() {
for translation in [Vec2::ZERO, p(11.0, -7.0)] {
let center = p(2.0, -1.0) + translation;
for outer_reversed in [false, true] {
for hole_reversed in [false, true] {
for outer_shift in 0..2 {
for hole_shift in 0..2 {
let mut outer = Loop2::circle(center, 5.0);
let mut hole = Loop2::circle(center, 3.0);
if outer_reversed {
outer = outer.reversed();
}
if hole_reversed {
hole = hole.reversed();
}
outer.segs.rotate_left(outer_shift);
hole.segs.rotate_left(hole_shift);
let region = Region2 { outer, holes: vec![hole] };
let before = region.clone();
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(center, Vec2::X).unwrap();
for (q, expected) in
[(0.0, vec![(-5.0, -3.0), (3.0, 5.0)]), (3.0, vec![(-4.0, 0.0), (0.0, 4.0)]), (4.0, vec![(-3.0, 3.0)]), (5.0, vec![])]
{
let line = prepared.clip(&frame, q, &mut budget).unwrap();
exact_circle_spans(&line, &expected);
for (span, &(a, b)) in line.spans().iter().zip(&expected) {
assert_eq!(frame.point(q, span.start).unwrap(), p(2.0 + a, -1.0 + q) + translation);
assert_eq!(frame.point(q, span.end).unwrap(), p(2.0 + b, -1.0 + q) + translation);
}
}
assert_eq!(region, before);
}
}
}
}
}
}
#[test]
fn canonical_circle_admission_depends_on_fields_and_equates_signed_zero() {
// circle_from has the admitted fields at this seam, regardless of which
// constructor made them. Signed zero is numeric equality, not a new seam.
let region = Region2 { outer: Loop2::circle_from(p(0.0, -0.0), 5.0, -0.0), holes: vec![Loop2::circle_from(p(-0.0, 0.0), 3.0, 0.0)] };
let (line, _budget) = horizontal(&region, 0.0);
exact_circle_spans(&line, &[(-5.0, -3.0), (3.0, 5.0)]);
}
#[test]
fn canonical_annulus_near_tangencies_keep_the_gap_contact_and_integer_dots_distinct() {
let center = p(2.0, -1.0);
let region = circular_region(center, 5.0, Some(3.0));
let before = region.clone();
let delta = 1.0 / 1_073_741_824.0; // Exact 2^-30, not a geometry tolerance.
let below_outer = (16.0_f64 + 6.0 * delta - delta * delta).sqrt();
let below_inner = (6.0_f64 * delta - delta * delta).sqrt();
let above_outer = (16.0_f64 - 6.0 * delta - delta * delta).sqrt();
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(center, Vec2::X).unwrap();
for sign in [-1.0, 1.0] {
let below = prepared.clip(&frame, sign * (3.0 - delta), &mut budget).unwrap();
assert_spans(&below, &[(-below_outer, -below_inner), (below_inner, below_outer)]);
assert!(below.spans()[0].end < 0.0 && below.spans()[1].start > 0.0);
assert!(below.spans()[0].start < -4.0 && below.spans()[1].end > 4.0);
assert_eq!(circle_integer_dots(&below, &mut budget), vec![-4.0, -3.0, -2.0, -1.0, 1.0, 2.0, 3.0, 4.0]);
let tangent = prepared.clip(&frame, sign * 3.0, &mut budget).unwrap();
exact_circle_spans(&tangent, &[(-4.0, 0.0), (0.0, 4.0)]);
assert_eq!(circle_integer_dots(&tangent, &mut budget), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]);
let above = prepared.clip(&frame, sign * (3.0 + delta), &mut budget).unwrap();
assert_spans(&above, &[(-above_outer, above_outer)]);
assert!(above.spans()[0].start > -4.0 && above.spans()[0].end < 4.0);
assert_eq!(circle_integer_dots(&above, &mut budget), vec![-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]);
}
assert_eq!(region, before);
}
#[test]
fn canonical_annulus_adapter_preserves_signed_global_dash_phase_and_zero_tokens() {
let center = p(2.0, -1.0);
let region = circular_region(center, 5.0, Some(3.0));
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(center, Vec2::X).unwrap();
let mut total_strokes = 0;
let mut total_length = 0.0;
let mut total_dots = 0;
for (k, q, expected, dot_sites) in [
(-1, -3.0, vec![(-3.5, -1.5), (-0.5, 0.0), (0.0, 1.5), (2.5, 4.0)], vec![-2.5, -0.5, 1.5, 3.5]),
(0, 0.0, vec![(-5.0, -4.0), (3.0, 5.0)], vec![-4.0, 4.0]),
(1, 3.0, vec![(-4.0, -3.5), (-2.5, -0.5), (0.5, 2.5), (3.5, 4.0)], vec![-3.5, -1.5, 0.5, 2.5]),
] {
let line = prepared.clip(&frame, q, &mut budget).unwrap();
let mut actual = Vec::new();
let mut dots = Vec::new();
for i in 0..line.spans().len() {
for j in -4..=4 {
// [2,-1]: H=k/2, P=3, so a=(k+6j)/2 and b=a+2.
let a = phase_parameter(k + 6 * j, -1, &mut budget);
let b = phase_parameter(k + 6 * j + 4, -1, &mut budget);
if let Some(span) = line.dash_in_span(i, a, b, &mut budget).unwrap() {
actual.push((span.start, span.end));
}
// [0,0,-2,0]: keep each zero token, including the preceding
// cycle's trailing zero. P=2, t=H+2j+prefix.
for prefix in [0, 0, 2] {
let t = phase_parameter(k + 4 * j + 2 * prefix, -1, &mut budget);
if let Some(at) = line.dot_in_span(i, t, &mut budget).unwrap() {
dots.push(at);
}
}
}
}
assert_eq!(actual, expected);
dots.sort_by(f64::total_cmp);
let expected_dots: Vec<_> = dot_sites.into_iter().flat_map(|t| [t, t, t]).collect();
assert_eq!(dots, expected_dots);
total_strokes += actual.len();
total_length += actual.iter().map(|(a, b)| b - a).sum::<f64>();
total_dots += dots.len();
}
assert_eq!(total_strokes, 10);
assert_eq!(total_length, 13.5);
assert_eq!(total_dots, 30);
}
#[test]
fn canonical_annulus_uses_the_actual_oblique_frame_and_its_represented_norm() {
let center = p(2.0, -1.0);
let origin = p(-3.0, 4.0);
let region = circular_region(center, 5.0, Some(3.0));
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(origin, p(3.0, 4.0)).unwrap();
assert_eq!(frame.tangent(), p(0.6, 0.8));
// Exact binary64 significands over 2^53. Their squared norm is GREATER
// than one, even though an ordinary f64 sum may round it to one.
let ux_n = 5_404_319_552_844_595_i128;
let uy_n = 7_205_759_403_792_794_i128;
let norm_n = ux_n * ux_n + uy_n * uy_n;
assert!(norm_n > (1_i128 << 106));
// These independent circle equations are evaluated approximately only for
// the existing endpoint-comparison tolerance; they are not certificates.
let norm = norm_n as f64 / (1_i128 << 106) as f64;
let tc = (5.0 * 0.6 - 5.0 * 0.8) / norm;
let hc = (-5.0 * 0.8 - 5.0 * 0.6) / norm;
let offset = -5.0;
let distance = offset - hc;
let outer = (25.0 / norm - distance * distance).sqrt();
let inner = (9.0 / norm - distance * distance).sqrt();
let expected = [(tc - outer, tc - inner), (tc + inner, tc + outer)];
let line = prepared.clip(&frame, offset, &mut budget).unwrap();
assert_spans(&line, &expected);
for (span, &(a, b)) in line.spans().iter().zip(&expected) {
let world = |t: f64| origin + p(-0.8, 0.6) * offset + p(0.6, 0.8) * t;
assert_point(frame.point(offset, span.start).unwrap(), world(a));
assert_point(frame.point(offset, span.end).unwrap(), world(b));
}
let (lo, hi) = prepared.normal_bounds(&frame, &mut budget).unwrap();
let radius_in_frame = 5.0 / norm.sqrt();
assert!(lo <= hc - radius_in_frame + TOL && hi >= hc + radius_in_frame - TOL);
}
#[test]
fn noncanonical_circle_encodings_and_one_ulp_signature_changes_are_refused() {
let center = p(2.0, -1.0);
let canonical = Loop2::circle(center, 5.0);
let mut rejected = vec![
Loop2 { segs: vec![Seg2::Arc { center, radius: 5.0, start: 0.0, sweep: TAU }] },
Loop2 { segs: vec![Seg2::Arc { center, radius: 5.0, start: 0.0, sweep: TAU.next_up() }] },
Loop2 { segs: vec![canonical.segs[0]] },
Loop2::circle_from(center, 5.0, 0.5),
Loop2::circle_from(center, 5.0, PI),
Loop2 {
segs: [0.0, FRAC_PI_2, PI, 3.0 * FRAC_PI_2].into_iter().map(|start| Seg2::Arc { center, radius: 5.0, start, sweep: FRAC_PI_2 }).collect(),
},
Loop2 { segs: vec![canonical.segs[0], canonical.segs[0]] },
Loop2 { segs: vec![canonical.segs[0], Seg2::Line { a: p(-3.0, -1.0), b: p(7.0, -1.0) }] },
];
// Every change below is finite and tiny; none authorizes tolerant seam or
// support repair. Recognition requires exact fields of the chosen encoding.
for (index, replacement) in [
(0, Seg2::Arc { center: p(2.0_f64.next_up(), -1.0), radius: 5.0, start: 0.0, sweep: PI }),
(1, Seg2::Arc { center: p(2.0, (-1.0_f64).next_down()), radius: 5.0, start: PI, sweep: PI }),
(1, Seg2::Arc { center, radius: 5.0_f64.next_up(), start: PI, sweep: PI }),
(0, Seg2::Arc { center, radius: 5.0, start: 0.0_f64.next_up(), sweep: PI }),
(1, Seg2::Arc { center, radius: 5.0, start: PI.next_down(), sweep: PI }),
(0, Seg2::Arc { center, radius: 5.0, start: 0.0, sweep: PI.next_up() }),
(1, Seg2::Arc { center, radius: 5.0, start: PI, sweep: PI.next_down() }),
(1, Seg2::Arc { center, radius: 5.0, start: PI, sweep: FRAC_PI_2 }),
] {
let mut changed = canonical.clone();
changed.segs[index] = replacement;
rejected.push(changed);
}
let mut reversed = canonical.reversed();
reversed.segs[0] = Seg2::Arc { center, radius: 5.0, start: TAU.next_down(), sweep: -PI };
rejected.push(reversed);
for outer in rejected {
let region = Region2 { outer, holes: vec![] };
let before = region.clone();
assert!(PreparedRegion::new(&region, &mut Budget::default()).is_err());
assert_eq!(region, before);
}
}
#[test]
fn circular_regions_reject_eccentric_multiple_and_mixed_holes_without_radius_snapping() {
let center = p(2.0, -1.0);
let outer = Loop2::circle(center, 5.0);
for holes in [
vec![Loop2::circle(center, 5.0)],
vec![Loop2::circle(center, 5.0_f64.next_up())],
vec![Loop2::circle(p(2.0_f64.next_up(), -1.0), 3.0)],
vec![Loop2::circle(p(3.0, -1.0), 1.0)], // Strictly inside, but eccentric is deferred.
vec![Loop2::circle(p(6.0, -1.0), 1.0)], // Region boundaries touch.
vec![Loop2::circle(p(6.5, -1.0), 1.0)], // Region boundaries cross.
vec![Loop2::circle(p(9.0, -1.0), 1.0)], // Hole outside outer circle.
vec![Loop2::circle(center, 3.0), Loop2::circle(center, 1.0)],
vec![Loop2::circle(center, 3.0), Loop2::circle(center, 3.0)],
vec![rectangle(1.0, -2.0, 3.0, 0.0)],
] {
let region = Region2 { outer: outer.clone(), holes };
let before = region.clone();
assert!(PreparedRegion::new(&region, &mut Budget::default()).is_err());
assert_eq!(region, before);
}
// A distinct concentric radius is not malformed merely because its gap is
// below a geometry epsilon. Later unresolved root ordering may still refuse.
let thin = circular_region(center, 5.0, Some(5.0_f64.next_down()));
assert!(PreparedRegion::new(&thin, &mut Budget::default()).is_ok());
}
#[test]
fn canonical_circles_reject_hostile_fields_and_enforce_the_complete_coordinate_box() {
let center = p(2.0, -1.0);
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, -f64::MAX] {
for region in [
circular_region(p(value, -1.0), 5.0, None),
circular_region(p(2.0, value), 5.0, None),
circular_region(center, value, None),
circular_region(center, 5.0, Some(value)),
] {
assert!(PreparedRegion::new(&region, &mut Budget::default()).is_err());
}
for replacement in [Seg2::Arc { center, radius: 5.0, start: value, sweep: PI }, Seg2::Arc { center, radius: 5.0, start: 0.0, sweep: value }] {
let mut region = circular_region(center, 5.0, None);
region.outer.segs[0] = replacement;
assert!(PreparedRegion::new(&region, &mut Budget::default()).is_err());
}
}
for radius in [0.0, -0.0, -1.0, f64::MIN_POSITIVE, 1e-12_f64.next_down()] {
assert!(PreparedRegion::new(&circular_region(center, radius, None), &mut Budget::default()).is_err());
}
let limit = hatch::MAX_LENGTH_MM;
let edge_center = p(limit - 5.0, -limit + 5.0);
let region = circular_region(edge_center, 5.0, None);
let mut budget = Budget::default();
let prepared = PreparedRegion::new(&region, &mut budget).unwrap();
let frame = Frame::new(edge_center, Vec2::X).unwrap();
exact_circle_spans(&prepared.clip(&frame, 0.0, &mut budget).unwrap(), &[(-5.0, 5.0)]);
for center in [p(edge_center.x.next_up(), edge_center.y), p(edge_center.x, edge_center.y.next_down())] {
assert!(PreparedRegion::new(&circular_region(center, 5.0, None), &mut Budget::default()).is_err());
}
}
#[test]
fn circular_clipping_charges_original_pieces_contacts_and_empty_queries_without_prefix_success() {
let center = p(2.0, -1.0);
let disk = circular_region(center, 5.0, None);
let annulus = circular_region(center, 5.0, Some(3.0));
for (region, required) in [(&disk, 2), (&annulus, 4)] {
let mut too_few = Budget::new(Limits { input_pieces: required - 1, ..Limits::default() });
assert!(PreparedRegion::new(region, &mut too_few).is_err());
let mut enough = Budget::new(Limits { input_pieces: required, ..Limits::default() });
assert!(PreparedRegion::new(region, &mut enough).is_ok());
let mut no_work = Budget::new(Limits { work: 0, ..Limits::default() });
assert!(PreparedRegion::new(region, &mut no_work).is_err());
}
let mut preparation = Budget::default();
let prepared = PreparedRegion::new(&disk, &mut preparation).unwrap();
let frame = Frame::new(center, Vec2::X).unwrap();
// Isolate query-entry costs; even an exterior query with no root is work.
for offset in [0.0, 5.0, 6.0] {
let mut exhausted = Budget::new(Limits { work: 0, ..Limits::default() });
assert!(prepared.clip(&frame, offset, &mut exhausted).is_err());
}
// A tangent emits no stroke but still creates a charged contact event.
for offset in [0.0, 5.0] {
let mut no_events = Budget::new(Limits { events: 0, ..Limits::default() });
assert!(prepared.clip(&frame, offset, &mut no_events).is_err());
}
let before = annulus.clone();
let mut events = Budget::new(Limits { events: 3, ..Limits::default() });
let prepared = PreparedRegion::new(&annulus, &mut events).unwrap();
// Four distinct crossings are necessary. A budget failure must not return
// the first material interval as a successful prefix.
assert!(prepared.clip(&frame, 0.0, &mut events).is_err());
let complete = prepared.clip(&frame, 0.0, &mut Budget::default()).unwrap();
exact_circle_spans(&complete, &[(-5.0, -3.0), (3.0, 5.0)]);
assert_eq!(annulus, before);
}
+4 -2
View File
@@ -1,4 +1,4 @@
//! Bounded expansion of stored paper-millimetre patterns over prepared line regions.
//! Bounded hatch expansion over polygons and canonical circular regions.
use crate::{Region2, Vec2};
@@ -20,7 +20,9 @@ pub enum Mark {
Dot { at: Vec2 },
}
/// Expand a validated pattern over a supported simple region with straight boundaries.
/// Expand a validated pattern over a supported polygon, disk or concentric annulus.
/// Circular regions use the canonical two-semicircle encoding, with at most one
/// concentric circular hole; general arc boundaries remain unsupported.
/// The result is complete or an error; inputs are never changed. Stored lengths already
/// use paper millimetres, so this function applies no unit conversion or view scale.
///
+325 -1
View File
@@ -237,7 +237,9 @@ fn all_negative_cycles_paint_nothing_instead_of_continuous_lines() {
fn painting_nothing_does_not_bypass_pattern_or_region_validation() {
let gap = family([0.0, 0.0], 0.0, 10.0, &[-1.0]);
let p = pattern(vec![gap.clone()]);
let curved = Region2 { outer: Loop2::circle(Vec2::ZERO, 1.0), holes: vec![] };
// Canonical full circles are supported; a different seam remains outside
// the admitted two-semicircle encoding, even for a gap-only pattern.
let curved = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 1.0, FRAC_PI_2), holes: vec![] };
assert!(generate(&p, &curved).is_err());
assert!(generate(&p, &Region2::default()).is_err());
let mut open = region(-1.0, -0.5, 1.0, 0.5);
@@ -440,3 +442,325 @@ fn a_dot_enclosure_overlapping_a_boundary_refuses_the_entire_result() {
assert!(generate(&pattern(vec![ambiguous.clone()]), &r).is_err());
assert!(generate(&pattern(vec![first, ambiguous]), &r).is_err());
}
fn circular_region(with_hole: bool) -> Region2 {
let center = Vec2::new(2.0, -1.0);
Region2 { outer: Loop2::circle(center, 5.0), holes: if with_hole { vec![Loop2::circle(center, 3.0)] } else { vec![] } }
}
fn circle_lines(intervals: &[[f64; 3]]) -> Vec<Mark> {
// Independent world map of the analytic (left t, right t, relative y)
// tables for center (2,-1), horizontal tangent and vertical normal.
intervals.iter().map(|&[left, right, q]| Mark::Line { a: Vec2::new(2.0 + left, -1.0 + q), b: Vec2::new(2.0 + right, -1.0 + q) }).collect()
}
fn circle_dots(q: f64, positions: &[f64]) -> Vec<Mark> {
positions.iter().map(|&t| Mark::Dot { at: Vec2::new(2.0 + t, -1.0 + q) }).collect()
}
fn circle_output(p: &Pattern, r: &Region2, limits: Option<Limits>) -> Result<Vec<Mark>, super::Invalid> {
let pattern_before = p.clone();
let region_before = r.clone();
let result = match limits {
Some(limits) => generate_with_limits(p, r, limits),
None => generate(p, r),
};
assert_eq!(p, &pattern_before);
assert_eq!(r, &region_before);
result
}
fn assert_exact_circle_output(p: &Pattern, r: &Region2, expected: &[Mark], total_length: f64) {
let marks = circle_output(p, r, None).unwrap();
// Full equality checks member/span/cycle order and each repeated zero token.
assert_eq!(marks, expected);
let length: f64 = marks
.iter()
.map(|mark| match *mark {
Mark::Line { a, b } => {
assert!(a.is_finite() && b.is_finite() && a.x < b.x);
assert_eq!(a.y, b.y);
b.x - a.x
}
Mark::Dot { at } => {
assert!(at.is_finite());
0.0
}
})
.sum();
assert_eq!(length, total_length);
let exact = Limits { marks: expected.len(), ..Limits::default() };
assert_eq!(circle_output(p, r, Some(exact)).unwrap(), expected);
if !expected.is_empty() {
let short = Limits { marks: expected.len() - 1, ..Limits::default() };
assert!(circle_output(p, r, Some(short)).is_err());
}
}
#[test]
fn a_disk_generates_three_ordered_secants_with_total_length_twenty_six() {
let r = circular_region(false);
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[])]);
// x^2 + q^2 = 25 in centered coordinates: q=-3,0,3 gives A=4,5,4.
let expected = circle_lines(&[[-4.0, 4.0, -3.0], [-5.0, 5.0, 0.0], [-4.0, 4.0, 3.0]]);
assert_exact_circle_output(&p, &r, &expected, 26.0);
}
#[test]
fn a_disk_keeps_twenty_three_strict_integer_dots_and_excludes_its_boundary() {
let r = circular_region(false);
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[0.0, -1.0])]);
let mut expected = circle_dots(-3.0, &[-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]);
expected.extend(circle_dots(0.0, &[-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0]));
expected.extend(circle_dots(3.0, &[-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]));
assert_eq!(expected.len(), 23);
assert_exact_circle_output(&p, &r, &expected, 0.0);
}
#[test]
fn outer_circle_tangencies_and_exterior_members_emit_neither_lines_nor_dots() {
for with_hole in [false, true] {
let r = circular_region(with_hole);
for q in [-6.0, -5.0, 5.0, 6.0] {
for dashes in [&[][..], &[0.0, -1.0][..]] {
// Only k=0 can meet the circle; q=+/-5 is an isolated contact,
// whereas q=+/-6 has no roots. Both must return empty success.
let p = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, dashes)]);
assert_exact_circle_output(&p, &r, &[], 0.0);
}
}
}
}
#[test]
fn an_annulus_keeps_six_ordered_spans_and_splits_both_inner_tangencies() {
let r = circular_region(true);
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[])]);
let expected = circle_lines(&[[-4.0, 0.0, -3.0], [0.0, 4.0, -3.0], [-5.0, -3.0, 0.0], [3.0, 5.0, 0.0], [-4.0, 0.0, 3.0], [0.0, 4.0, 3.0]]);
assert_exact_circle_output(&p, &r, &expected, 20.0);
}
#[test]
fn an_annulus_keeps_fourteen_strict_dots_without_a_dot_at_inner_tangency() {
let r = circular_region(true);
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[0.0, -1.0])]);
let mut expected = circle_dots(-3.0, &[-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]);
expected.extend(circle_dots(0.0, &[-4.0, 4.0]));
expected.extend(circle_dots(3.0, &[-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]));
assert_eq!(expected.len(), 14);
assert_exact_circle_output(&p, &r, &expected, 0.0);
}
#[test]
fn annular_secants_beyond_the_hole_keep_the_middle_line_and_dot() {
let r = circular_region(true);
for q in [-4.0, 4.0] {
let continuous = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[])]);
assert_exact_circle_output(&continuous, &r, &circle_lines(&[[-3.0, 3.0, q]]), 6.0);
let dotted = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[0.0, -1.0])]);
assert_exact_circle_output(&dotted, &r, &circle_dots(q, &[-2.0, -1.0, 0.0, 1.0, 2.0]), 0.0);
}
}
fn phased_annulus_lines() -> Vec<Mark> {
// Exact period 3, phase k/2: clipping neither restarts a cycle nor joins
// the two pieces of the k=-1, j=0 dash across its tangent contact.
circle_lines(&[
[-3.5, -1.5, -3.0],
[-0.5, 0.0, -3.0],
[0.0, 1.5, -3.0],
[2.5, 4.0, -3.0],
[-5.0, -4.0, 0.0],
[3.0, 5.0, 0.0],
[-4.0, -3.5, 3.0],
[-2.5, -0.5, 3.0],
[0.5, 2.5, 3.0],
[3.5, 4.0, 3.0],
])
}
#[test]
fn signed_annular_dash_phase_preserves_ten_pieces_and_thirteen_and_a_half_length() {
let p = pattern(vec![family([2.0, -1.0], 0.5, 3.0, &[2.0, -1.0])]);
assert_exact_circle_output(&p, &circular_region(true), &phased_annulus_lines(), 13.5);
}
#[test]
fn annular_zero_tokens_keep_thirty_ordered_instances_across_span_windows() {
let p = pattern(vec![family([2.0, -1.0], 0.5, 3.0, &[0.0, 0.0, -2.0, 0.0])]);
let mut positions = circle_dots(-3.0, &[-2.5, -0.5, 1.5, 3.5]);
positions.extend(circle_dots(0.0, &[-4.0, 4.0]));
positions.extend(circle_dots(3.0, &[-3.5, -1.5, 0.5, 2.5]));
// At H+2*j: previous cycle's trailing zero, then this cycle's two zeros.
let expected: Vec<_> = positions.into_iter().flat_map(|mark| [mark; 3]).collect();
assert_eq!(expected.len(), 30);
assert_exact_circle_output(&p, &circular_region(true), &expected, 0.0);
}
#[test]
fn accepted_circle_reversals_cyclic_starts_and_translation_preserve_ordered_phase() {
for reversed in [false, true] {
for start in 0..2 {
for shift in [Vec2::ZERO, Vec2::new(11.0, -7.0)] {
let center = Vec2::new(2.0, -1.0) + shift;
let mut outer = Loop2::circle(center, 5.0);
let mut hole = Loop2::circle(center, 3.0);
if reversed {
outer = outer.reversed();
hole = hole.reversed();
}
outer.segs.rotate_left(start);
hole.segs.rotate_left(1 - start);
let r = Region2 { outer, holes: vec![hole] };
let p = pattern(vec![family([center.x, center.y], 0.5, 3.0, &[2.0, -1.0])]);
let expected: Vec<_> = phased_annulus_lines()
.into_iter()
.map(|mark| match mark {
Mark::Line { a, b } => Mark::Line { a: a + shift, b: b + shift },
Mark::Dot { .. } => panic!("the analytic phased fixture has only lines"),
})
.collect();
assert_exact_circle_output(&p, &r, &expected, 13.5);
}
}
}
}
fn near_tangent_root_bracket(numerator: u128) -> [f64; 2] {
// The analytic squared root is numerator/2^60. Integer square root of
// numerator*2^40 brackets the root on a 2^-50 lattice, independently of
// the clipper's floating products, sqrt candidate and contact midpoint.
assert!(numerator < (17_u128 << 60));
let square = numerator << 40;
let lower = square.isqrt();
let upper = if lower * lower == square { lower } else { lower + 1 };
assert!(lower * lower <= square && upper * upper >= square);
assert!(upper <= (1_u128 << 53));
let unit = 2.0_f64.powi(-50);
[lower as f64 * unit, upper as f64 * unit]
}
// Reuse the clipping acceptance's 1e-9 mm output comparison for this fixture.
// A geometric contact is the midpoint of a certified Bound, not a correctly
// rounded analytic root. Near q=3-2^-30, the square enclosure near 9 can have
// width 2^-49; propagating it through the small inner root gives about 1e-11
// mm of contact uncertainty. This comparison includes that effect and final
// coordinate rounding; it never chooses topology or relaxes the older tests.
const CIRCLE_CONTACT_COMPARISON_MM: f64 = 1e-9;
fn assert_circle_line_enclosures(marks: &[Mark], y: f64, intervals: &[([f64; 2], [f64; 2])], length: [f64; 2]) {
assert_eq!(marks.len(), intervals.len());
let mut total = 0.0;
for (mark, &(left, right)) in marks.iter().zip(intervals) {
let Mark::Line { a, b } = *mark else { panic!("continuous circle pattern produced a dot") };
assert!(a.is_finite() && b.is_finite() && a.x < b.x);
assert_eq!(a.y, y);
assert_eq!(b.y, y);
for (actual, bounds) in [(a.x, left), (b.x, right)] {
// These reference lattice endpoints and their translation by 2
// are exactly representable. Only the public contact output gets
// the documented comparison allowance around the exact-root bracket.
let low = 2.0 + bounds[0];
let high = 2.0 + bounds[1];
assert!(actual >= low - CIRCLE_CONTACT_COMPARISON_MM && actual <= high + CIRCLE_CONTACT_COMPARISON_MM);
}
total += b.x - a.x;
}
// Each positive horizontal span contributes two endpoint uncertainties.
let allowance = 2.0 * intervals.len() as f64 * CIRCLE_CONTACT_COMPARISON_MM;
assert!(total >= length[0] - allowance && total <= length[1] + allowance);
}
#[test]
fn near_inner_tangency_keeps_distinct_gaps_contacts_and_strict_dot_counts() {
let r = circular_region(true);
let delta = 2.0_f64.powi(-30);
let denominator = 1_u128 << 60;
let six_delta = 6_u128 << 30;
// Analytic radicands over 2^60 avoid the cancellation of delta^2 in a
// floating computation of 25-q*q or 9-q*q. Counts are exact: 2/2/1
// spans and 8/6/7 dots; no success-or-error alternative is accepted.
for (side, outer_square, inner_square, dot_positions) in [
(-1, 16 * denominator + six_delta - 1, Some(six_delta - 1), &[-4.0, -3.0, -2.0, -1.0, 1.0, 2.0, 3.0, 4.0][..]),
(0, 16 * denominator, Some(0), &[-3.0, -2.0, -1.0, 1.0, 2.0, 3.0][..]),
(1, 16 * denominator - six_delta - 1, None, &[-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0][..]),
] {
let a = near_tangent_root_bracket(outer_square);
let (intervals, length) = match inner_square {
Some(square) => {
let b = near_tangent_root_bracket(square);
(vec![([-a[1], -a[0]], [-b[1], -b[0]]), (b, a)], [2.0 * (a[0] - b[1]), 2.0 * (a[1] - b[0])])
}
None => (vec![([-a[1], -a[0]], a)], [2.0 * a[0], 2.0 * a[1]]),
};
for sign in [-1.0, 1.0] {
let q = sign * (3.0 + f64::from(side) * delta);
let continuous = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[])]);
let marks = circle_output(&continuous, &r, None).unwrap();
assert_circle_line_enclosures(&marks, -1.0 + q, &intervals, length);
let Mark::Line { a: first, .. } = marks[0] else { panic!("missing first line") };
let Mark::Line { b: last, .. } = marks[marks.len() - 1] else { panic!("missing last line") };
if side < 0 {
// A>4 and B>0: the comparison allowance cannot conceal an
// outer endpoint snapped to 4 or a collapsed positive hole gap.
assert!(first.x < -2.0 && last.x > 6.0);
let Mark::Line { b: left, .. } = marks[0] else { panic!("missing left span") };
let Mark::Line { a: right, .. } = marks[1] else { panic!("missing right span") };
assert!(left.x < 2.0 && right.x > 2.0);
} else if side == 0 {
assert_eq!(marks, circle_lines(&[[-4.0, 0.0, q], [0.0, 4.0, q]]));
} else {
// A<4 and no hole contact: one span crosses the center.
assert!(first.x > -2.0 && last.x < 6.0 && first.x < 2.0 && last.x > 2.0);
}
assert_eq!(circle_output(&continuous, &r, Some(Limits { marks: intervals.len(), ..Limits::default() })).unwrap(), marks);
assert!(circle_output(&continuous, &r, Some(Limits { marks: intervals.len() - 1, ..Limits::default() })).is_err());
let dotted = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[0.0, -1.0])]);
assert_exact_circle_output(&dotted, &r, &circle_dots(q, dot_positions), 0.0);
}
}
}
#[test]
fn circular_generation_charges_original_pieces_candidates_roots_and_tokens() {
let continuous = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[])]);
for (r, pieces) in [(circular_region(false), 1), (circular_region(true), 3)] {
let expected = circle_output(&continuous, &r, None).unwrap();
assert!(!expected.is_empty());
assert_eq!(circle_output(&continuous, &r, Some(Limits { input_pieces: pieces + 1, ..Limits::default() })).unwrap(), expected);
for limits in [
Limits { input_pieces: pieces, ..Limits::default() },
Limits { candidate_lines: 0, ..Limits::default() },
// At least k=-3..3 are charged, including exterior candidates.
// Wider certified bounds may legitimately visit more than seven.
Limits { candidate_lines: 6, ..Limits::default() },
Limits { work: 0, ..Limits::default() },
Limits { events: 0, ..Limits::default() },
] {
assert!(circle_output(&continuous, &r, Some(limits)).is_err());
}
let dotted = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[0.0, -1.0])]);
assert!(circle_output(&dotted, &r, Some(Limits { dash_steps: 0, ..Limits::default() })).is_err());
}
}
#[test]
fn empty_circular_output_still_charges_geometric_and_gap_work() {
let r = circular_region(true);
let gap = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[-1.0])]);
assert_exact_circle_output(&gap, &r, &[], 0.0);
for limits in [
Limits { input_pieces: 3, ..Limits::default() },
Limits { candidate_lines: 0, ..Limits::default() },
Limits { candidate_lines: 6, ..Limits::default() },
Limits { work: 0, ..Limits::default() },
Limits { events: 0, ..Limits::default() },
Limits { dash_steps: 0, ..Limits::default() },
] {
assert!(circle_output(&gap, &r, Some(limits)).is_err());
}
let exterior = pattern(vec![family([2.0, 5.0], 0.0, 32.0, &[0.0, -1.0])]);
assert_exact_circle_output(&exterior, &r, &[], 0.0);
assert!(circle_output(&exterior, &r, Some(Limits { candidate_lines: 0, ..Limits::default() })).is_err());
assert!(circle_output(&exterior, &r, Some(Limits { work: 0, ..Limits::default() })).is_err());
}
+158 -17
View File
@@ -1,10 +1,13 @@
# Hatch patterns, PAT import and polygonal generation (W38d D1–D3)
# Hatch patterns, PAT import and bounded generation (W38d D1–D5)
D1 supplies a neutral hatch-pattern model and a bounded PAT reader for
[W38d (#7062)][issue], following [the drawing-sketch handoff][handoff].
D2 adds bounded line-and-dot generation inside polygonal regions with holes.
D3 extends its phase arithmetic and clipping queries while retaining the D1
model, public generation API, polygon-only scope and existing resource caps.
D4 adds end-to-end PAT-to-polygon acceptance cases. D5 extends clipping to a
canonical disk or an annulus with one concentric circular hole, retaining the
polygon path, phase rules and caps.
These are library foundations: they do not attach a fill to a sketch, save
drawing records, change the paper scene or register a command. They do not
complete W38d or implement section-derived hatching (W34).
@@ -55,8 +58,8 @@ These are transient geometric marks in the supplied region's paper XY frame,
not paper-scene objects or saved fill records. A dot remains a point; it is not
a zero-length line. Each explicit zero in a dash sequence produces its own
eligible dot, even if another zero coincides with it. The call validates the
pattern and supported region, leaves both inputs unchanged, and returns the complete result or an error. No partial
output is returned after a failure.
pattern and supported region, leaves both inputs unchanged, and returns the
complete result or an error. No partial output is returned after a failure.
## Coordinates, units and dash phase
@@ -95,7 +98,7 @@ across clipped fragments and holes; clipping does not restart the dash sequence.
The eventual paper adapter must keep, for example, a 3 mm hatch spacing at 3 mm
on paper when a view scale changes. It must transform the region to paper
coordinates without applying that view scale to the pattern a second time.
D1–D3 have no view or placement API. A multi-family pattern also has no single
D1–D5 have no view or placement API. A multi-family pattern also has no single
intrinsic spacing: a future spacing control must define its relationship to
pattern scale explicitly.
@@ -170,7 +173,9 @@ reports expected line 3. UTF-8 errors identify the physical line where possible.
Option and total byte-limit errors start with `PAT import:`, without an
invented line number.
## Polygonal generation: D2 geometry retained by D3
## Supported regions
### Polygonal regions retained from D2
The [clipper][clipper] accepts one `Region2`: an outer loop and zero or more
holes, all made of `Seg2::Line`. Every loop must have at least three edges,
@@ -180,7 +185,42 @@ orientation is accepted; the outer/hole roles come from `Region2`, not winding.
Holes must lie strictly inside the outer loop, without touching, crossing or
nesting within one another. Unsupported or
uncertain geometry returns `hatch::Invalid`; the input is not snapped, repaired
or approximated. Arcs, conics and Bézier segments remain unsupported in D3.
or approximated. D5 leaves this polygon admission unchanged; its separate
circular path below does not admit mixed line-and-arc regions.
### Canonical circles and one concentric hole (D5)
The [circular clipper][circle-clipper] supports a disk, or an annulus whose only hole has
exactly the same finite center and a radius strictly between zero and the outer
radius. Each loop must contain exactly two `Seg2::Arc` records with identical
center and radius and one of these ordered `(start, sweep)` signatures:
| Encoding | First arc | Second arc |
| --- | --- | --- |
| Forward | `(0, PI)` | `(PI, PI)` |
| Reversed | `(TAU, -PI)` | `(PI, -PI)` |
Cyclic rotation of either pair is accepted. `PI` and `TAU` denote the stored
Rust `f64` constants; matching uses exact numeric equality, including equivalence
of signed zeros. The recognizer checks fields, not which constructor created
them: `Loop2::circle_from` is accepted when it produces the same fields, but an
arbitrary seam angle is not. Every segment must also pass `Seg2::check`, and the
complete circle bounding box must satisfy the existing `+/-100000 mm` coordinate
cap. Validating only its two seam endpoints would not establish that bound.
This is an explicit interpretation of the complete-circle encoding documented
by [`Loop2::circle`][profile]. It does not snap evaluated arc endpoints or infer
closure from a tolerance. Evaluating the trigonometric endpoints may round;
recognition does not change those records or claim bitwise endpoint closure.
Other seams, a single full-turn arc, four quarter arcs, partial or mixed loops,
eccentric holes, multiple circular holes, conics and Bézier curves remain
unsupported. A one-ULP change to a required signature field is not repaired.
The same applies to mismatched centers/radii and equal inner/outer radii.
Passing `Seg2::check` alone does not establish this narrower circle contract.
The numerical checks below can still refuse a structurally admitted circle.
### Shared boundary and contact policy
Clipping preserves positive-length interior spans and their boundary contacts.
A span's endpoints may lie on the boundary; its interior must lie in material.
@@ -195,7 +235,7 @@ Unresolved signs, overlapping contact bounds or a dot whose membership cannot
be distinguished from a contact return an error. Endpoints and frame
reconstruction are rounded floating-point values, not an exact coordinate or
universal geometric-tolerance certificate. In particular, some mathematically
valid polygons or near-contact patterns can be refused without a fallback.
valid supported regions or near-contact patterns can be refused without a fallback.
The generator uses a normalized tangent and its left normal. In that frame,
member `k` has normal offset `k * delta_y` and tangential dash phase
@@ -263,14 +303,15 @@ The private clipping adapter consumes complete parameter enclosures:
adapter may refuse an exact positive dash whose endpoint enclosures overlap
or touch. It does not weaken the pattern validator.
- A selected exact dash endpoint uses `nearest()` once. A selected geometric
contact keeps the existing rounded D2 span endpoint. The resulting scalar
contact keeps the clipper's existing rounded span endpoint. The resulting scalar
endpoints are terminal output for the existing frame reconstruction; they
are never reused as containment certificates.
Thus exact phase arithmetic does **not** make the polygon intersections,
Thus exact phase arithmetic does **not** make geometric intersections, the
normalized frame or final `Vec2` coordinates exact. The existing finite,
distinct-endpoint and directional-progress checks still apply. The polygon,
hole and boundary-contact rules described above are unchanged.
distinct-endpoint and directional-progress checks still apply. D5 sends its
circle-root enclosures through the same clipping queries; it does not change
the phase arithmetic or the boundary-contact rules.
### Bounded arithmetic envelope
@@ -299,7 +340,59 @@ not raise that limit or bypass existing progress checks: cycle endpoints and
nonzero token endpoints must still advance after nearest rounding, including
gap-only visits.
## Work and output limits (D2–D3)
## D5 circle roots in the represented frame
The stored normalized tangent `u` and its left normal `n` are floating-point
vectors. Their squared length `S = u dot u` has a certified positive enclosure;
normalizing a vector does not establish that its represented squared length is
exactly one. Circle clipping uses this same `Frame`, including `S`.
For a circle with center `C` and radius `r`, `Frame::project(C)` supplies
bounded tangent/normal coordinates `(tc, hc)`. For a member at normal offset
`offset = k * delta_y`, the equations on the represented frame are:
```text
radius_squared_in_frame = r * r / S
Q = radius_squared_in_frame - (offset - hc)^2
normal range = hc +/- sqrt(radius_squared_in_frame)
tangent roots = tc +/- sqrt(Q)
```
All operations carry conservative enclosures. The normal range bounds candidate
member enumeration. The root classification is:
| Enclosure for Q | Result |
| --- | --- |
| `Q.hi < 0` | No intersection |
| `Q.lo == Q.hi == 0` | One contact, without a parity toggle |
| `Q.lo > 0` | Two crossing events |
| Otherwise | Whole-result numeric refusal |
A floating-point square root supplies a candidate, not a proof of its own error
bound. At most four charged candidate checks per endpoint, moving to a neighbour
when needed, must establish the lower/upper square inequalities with the existing
conservative product bounds. Failure to establish those bounds is an error. There is no assumed ULP guarantee
for `sqrt`, epsilon band, negative-radicand clamp, tessellation or open-ended
root iteration.
The resulting root enclosures enter the existing event sweep and remain attached
to its spans. Unresolved event order refuses the whole result. Candidate windows
and D3 dot/dash queries use those retained bounds, not their rounded output
endpoints. This is particularly important for contacts:
- An outer tangency produces no stroke or dot.
- An inner tangency splits the material into two retained strokes at the
contact, without changing material parity. A dot at the contact is excluded;
the two strokes must not be joined across it.
- A certified miss of the inner circle leaves one continuous material span;
a certified secant removes a positive-width hole interval. These
three cases are not merged by a geometric tolerance.
The exact phase, candidate-index limits and numeric-progress rules from D3 are
unchanged. Root certification is not a universal accuracy guarantee for final
rounded XY marks or a promise to handle every finite circle/frame combination.
## Work and output limits (D2–D5)
One internal [budget][budget] spans preparation, every family, clipping, dash
enumeration, dot membership queries and output. Its default cumulative caps are:
@@ -325,6 +418,12 @@ Production uses the same generator with the defaults. D3 charges its bounded
dyadic operations and each clipping classification to the existing work
counter, including failing queries and queries that produce no marks. It adds
no counter or budget increase; the extra checks can exhaust the work cap sooner.
D5 charges the original arc pieces before recognizing a complete circle: two
pieces for a disk, four for an annulus. Support validation and candidate/root
classification, including misses and tangencies, consume the existing work
budget. Created events count before deduplication, and all root-certification
attempts are bounded and charged. Compression into circle supports does not
reset any counter.
Budget or allocation failure, nonfinite arithmetic and unresolved numeric
uncertainty fail the whole call. Member and cycle indices must fit the generator's safe integer range
@@ -339,15 +438,16 @@ later paper-scene record limit remain separate responsibilities.
## Later phases and acceptance
The future paper adapter must choose a visible representation for `Mark::Dot`;
the current scene's butt-cap strokes reject degenerate geometry. D1–D3 supply no
the current scene's butt-cap strokes reject degenerate geometry. D1–D5 supply no
paper adapter, stroke width, colour, export, command or interactive preview.
Curved-region clipping remains outside this slice.
General arc and curved-region clipping beyond the explicit D5 circle subset
remains outside this slice.
Connecting fills to sketches waits for W38a's drawing-owned sketch record.
The later W38d integration owns persistent boundary identity, retained geometry
and lost-reference reporting, commands and undo, embedded imported definitions,
paper object kinds/layers and export acceptance. Section-derived hatching
remains a separate feature. D1–D3 add no drawing-format field or new dependency.
remains a separate feature. D1–D5 add no drawing-format field or new dependency.
D1 acceptance covers model validation and serde, converted units and phase data,
Unicode/BOM/line-ending handling, strict final blanks, malformed numeric/header
@@ -362,9 +462,48 @@ checks with the independent [phase tests][phase-tests]: signed floor division,
exact represented-input expressions, nearest-even ties, subnormal values and
arithmetic refusal, plus enclosure-aware dash/dot integration cases.
D4 links import to generation in the existing [PAT tests][tests]. Its three
cases compare every segment with an independent 45-degree rectangle oracle:
eight segments in a 40 by 20 mm rectangle, eleven after adding the rectangular
hole `[10, 20] x [5, 15]`, and the same construction after millimetre/inch scaling.
Imported and directly constructed patterns must agree and leave inputs unchanged.
This exercises conversion once in paper millimetres; it does not exercise a view
scale or a paper-scene adapter.
D5 acceptance extends the existing [clipping tests][clip-tests] and
[generation tests][generate-tests] with independent circle equations. The main
analytic fixtures use center `(2, -1)`, outer radius `5`, optional concentric
hole radius `3`, and horizontal lines with origin at that center, spacing
`delta_y = 3` and `delta_x = 0` unless specified. All coordinates and lengths below
are in millimetres:
| Fixture | Expected complete output |
| --- | --- |
| Disk, continuous | 3 strokes, total length 26 |
| Disk, `[0, -1]` | 23 strict-interior dots |
| Annulus, continuous | 6 strokes, total length 20; inner tangencies split strokes |
| Annulus, `[0, -1]` | 14 dots, excluding boundary contacts |
| Annulus, `delta_x = 1/2`, `[2, -1]` | 10 strokes, total length 13.5, with signed global phase |
| Annulus, `delta_x = 1/2`, `[0, 0, -2, 0]` | 30 dots; distinct zero tokens keep their multiplicity |
At a single line's signed normal distance `q = 3 - 2^-30`, `3`, or
`3 + 2^-30` from the same center, the annulus has respectively 2, 2, or 1
continuous spans, and 8, 6, or 7 dots for `[0, -1]`. Their expectations follow
from the independent roots `sqrt(25 - q*q)` and, when present,
`sqrt(9 - q*q)`; an implementation-produced golden is not the oracle.
Counts alone are insufficient: endpoints, hole gaps, split contacts, total
lengths, signed phase and zero-token multiplicities must also agree.
Additional D5 cases cover exact translation, admitted reverse/cyclic encodings,
the represented oblique frame, hostile signatures and unsupported shapes,
unchanged inputs, and whole-result refusal under lowered existing caps.
Required success cases must succeed; allowing either an error or a result would
not establish acceptance. An unresolved numeric refusal is reported as an
unmet success case, never as an approximate substitute pass.
The actual test commands and results belong to the PR's validation record,
bound to the tested source revision. D1/D2 test results do not validate a later
D3 source change, and source review or written tests do not establish execution.
bound to the tested source revision. Earlier-phase results do not validate a
later source change, and source review or written tests do not establish execution.
Library checks do not establish paper output, persistence or interactive
acceptance.
@@ -388,6 +527,8 @@ protected tables or illustrations. See the repository's
[reader]: ../crates/io/src/pat.rs
[generator]: ../crates/geom/src/hatch/generate.rs
[clipper]: ../crates/geom/src/hatch/clip.rs
[circle-clipper]: ../crates/geom/src/hatch/clip/circle.rs
[profile]: ../crates/geom/src/profile.rs
[budget]: ../crates/geom/src/hatch/budget.rs
[phase]: ../crates/geom/src/hatch/phase.rs
[phase-tests]: ../crates/geom/src/hatch/phase_tests.rs