mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 12:01:10 +00:00
feat(drawings): clip bounded hatches to circles
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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(®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<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)
|
||||
}
|
||||
@@ -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(®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::<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(®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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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, ®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<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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user