From b68fa4f868b8dd5ea312ecb5dfc8a0760dec4c5b Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:57:25 +0100 Subject: [PATCH] feat(drawings): clip bounded hatches to circles --- crates/geom/src/hatch/clip.rs | 33 +- crates/geom/src/hatch/clip/circle.rs | 162 ++++++++++ crates/geom/src/hatch/clip_tests.rs | 398 +++++++++++++++++++++++- crates/geom/src/hatch/generate.rs | 6 +- crates/geom/src/hatch/generate_tests.rs | 326 ++++++++++++++++++- docs/drawing-hatches.md | 175 ++++++++++- 6 files changed, 1073 insertions(+), 27 deletions(-) create mode 100644 crates/geom/src/hatch/clip/circle.rs diff --git a/crates/geom/src/hatch/clip.rs b/crates/geom/src/hatch/clip.rs index f81cc2922e..20ab24c974 100644 --- a/crates/geom/src/hatch/clip.rs +++ b/crates/geom/src/hatch/clip.rs @@ -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 = std::result::Result; const UNCERTAIN: Invalid = Invalid("unresolved clipping arithmetic"); @@ -237,11 +240,20 @@ fn inside(points: &[Vec2], p: Vec2, budget: &mut Budget) -> Result { /// Owned and immutable after complete, bounded topology validation. #[derive(Debug)] pub(super) struct PreparedRegion { - loops: Vec>, + shape: PreparedShape, +} + +#[derive(Debug)] +enum PreparedShape { + Polygon(Vec>), + Circular(circle::CircularRegion), } impl PreparedRegion { pub fn new(region: &Region2, budget: &mut Budget) -> Result { + 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 { + 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)] diff --git a/crates/geom/src/hatch/clip/circle.rs b/crates/geom/src/hatch/clip/circle.rs new file mode 100644 index 0000000000..739551016a --- /dev/null +++ b/crates/geom/src/hatch/clip/circle.rs @@ -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, +} + +impl CircularRegion { + pub(super) fn new(region: &Region2, budget: &mut Budget) -> Result { + 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(®ion.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 { + 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 { + 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 { + // 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, 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 { + 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 { + 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) +} diff --git a/crates/geom/src/hatch/clip_tests.rs b/crates/geom/src/hatch/clip_tests.rs index e9dcfb92d1..e6b0031123 100644 --- a/crates/geom/src/hatch/clip_tests.rs +++ b/crates/geom/src/hatch/clip_tests.rs @@ -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) -> 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 { + 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(®ion, &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(®ion, &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(®ion, &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(®ion, 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(®ion, &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(®ion, &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::(); + 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(®ion, &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(®ion, &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(®ion, &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(®ion, &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(®ion, &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(®ion, &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); +} diff --git a/crates/geom/src/hatch/generate.rs b/crates/geom/src/hatch/generate.rs index 102c3e3a36..175ebdff45 100644 --- a/crates/geom/src/hatch/generate.rs +++ b/crates/geom/src/hatch/generate.rs @@ -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. /// diff --git a/crates/geom/src/hatch/generate_tests.rs b/crates/geom/src/hatch/generate_tests.rs index 6a9f04768a..c8b22687e4 100644 --- a/crates/geom/src/hatch/generate_tests.rs +++ b/crates/geom/src/hatch/generate_tests.rs @@ -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 { + // 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 { + 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) -> Result, 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, ®ion_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 { + // 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()); +} diff --git a/docs/drawing-hatches.md b/docs/drawing-hatches.md index 283dc3d6ca..f36454dada 100644 --- a/docs/drawing-hatches.md +++ b/docs/drawing-hatches.md @@ -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