Compare commits

..
9 changed files with 107 additions and 144 deletions
-6
View File
@@ -123,12 +123,6 @@ pub struct LispState {
acad_loaded: bool,
/// Memory caps (lowered by tests).
pub(crate) limits: Limits,
/// Interpreter steps all synchronous callbacks have run, nested ones included. A callback's
/// allowance counts what ran since it started, so the callbacks it calls spend its steps.
pub(crate) callback_used: u64,
/// The `callback_used` value the callback running now (and so every one it calls) must not
/// pass: the lowest of its own and its callers' allowances.
pub(crate) callback_ceiling: Option<u64>,
}
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
+2 -8
View File
@@ -1,5 +1,5 @@
use super::args::{R, arity, int, list_arg, string};
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
// ---------------- strings ----------------
@@ -114,13 +114,7 @@ pub(super) fn read_fn(a: &[Value]) -> R {
arity(a, 0, 1)?;
match a.first() {
None | Some(Value::Nil) => Ok(Value::Nil),
Some(v) => {
// The strings in the list it makes are new, each as long as its text at most: the
// text is charged, though a list result isn't inspected by `charge_result`.
let text = string(v)?;
charge_bytes(text.len())?;
read::read_one(&text)
}
Some(v) => read::read_one(&string(v)?),
}
}
+2 -39
View File
@@ -179,12 +179,7 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
Value::Nil => serde_json::Value::Null,
Value::Int(i) => serde_json::json!(i),
Value::Real(r) => serde_json::json!(r),
Value::Str(s) => {
// A string is copied whole each time it occurs: it spends the allowance by its length
// (one unit per 16 bytes, so about 32 MB in all), shared or not.
*budget = budget.checked_sub(s.len() as u64 / 16)?;
serde_json::json!(&**s)
}
Value::Str(s) => serde_json::json!(&**s),
Value::Cons(_) => {
let mut arr = Vec::new();
for x in v.iter() {
@@ -301,16 +296,12 @@ fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
out
}
/// The most string bytes an entity list may hold (counted per occurrence, shared or not).
const MAX_GROUP_TEXT: usize = 32 << 20;
/// An association list back to DXF groups (points split into x, y, z groups). The type
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
/// left out: they aren't the entity's to set.
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
let mut kind = None;
let mut tags = Vec::new();
let mut text_left = MAX_GROUP_TEXT;
for item in list.iter() {
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
@@ -332,11 +323,7 @@ fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
_ => tags.push(match &v {
Value::Int(i) => Tag::i(code, i64::from(*i)),
Value::Real(r) => Tag::f(code, *r),
Value::Str(s) => {
// Each occurrence of a shared string becomes its own copy in the tag.
text_left = text_left.checked_sub(s.len()).ok_or_else(|| LispError::new("out of memory: the entity list holds too much text"))?;
Tag::s(code, &**s)
}
Value::Str(s) => Tag::s(code, &**s),
Value::Ename(h) => Tag::s(code, h.hex()),
other => return Err(LispError::bad_arg("DXF group value", other)),
}),
@@ -1138,27 +1125,3 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
}
#[cfg(test)]
mod budget_tests {
use super::*;
/// One 64 KB string occurring many times is charged by its length each time.
#[test]
fn shared_strings_spend_the_conversion_budgets() {
let big = Value::str(&"x".repeat(64 << 10));
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
let list = |n: usize| {
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
Value::list(items)
};
assert!(list_to_groups(&list(600)).is_err());
assert!(list_to_groups(&list(100)).is_ok());
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
let mut budget = TO_JSON_BUDGET;
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
let mut budget = TO_JSON_BUDGET;
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
}
}
+15 -35
View File
@@ -175,19 +175,17 @@ pub struct Machine {
stack: Vec<Frame>,
state: State,
need: Option<Need>,
/// Steps the last [`Machine::run`] took.
last_steps: u64,
}
impl Machine {
/// A machine evaluating `forms` in turn.
pub fn forms(forms: Vec<Value>) -> Machine {
Machine { stack: vec![Frame::Forms { rest: forms.into_iter().rev().collect() }], state: State::Ret(Value::Nil), need: None, last_steps: 0 }
Machine { stack: vec![Frame::Forms { rest: forms.into_iter().rev().collect() }], state: State::Ret(Value::Nil), need: None }
}
/// A machine calling `f` with `args`.
pub fn calling(f: Value, args: Vec<Value>) -> Machine {
Machine { stack: Vec::new(), state: State::Call(f, args), need: None, last_steps: 0 }
Machine { stack: Vec::new(), state: State::Call(f, args), need: None }
}
/// Give the value a [`Need`] waited for, and carry on.
@@ -198,15 +196,9 @@ impl Machine {
/// Run until done, waiting, or `budget` steps.
pub fn run(&mut self, ctx: &mut Ctx<'_>, budget: u64) -> Outcome {
let mut n = 0u64;
let outcome = self.run_counted(ctx, budget, &mut n);
self.last_steps = n.min(budget);
outcome
}
fn run_counted(&mut self, ctx: &mut Ctx<'_>, budget: u64, n: &mut u64) -> Outcome {
loop {
*n += 1;
if *n > budget {
n += 1;
if n > budget {
return Outcome::Wait(Need::Yield);
}
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
@@ -654,41 +646,29 @@ impl Ctx<'_> {
super::value::charge_result(&v)?;
return Ok(v);
}
let cap = self.s.lisp.limits.callback_steps;
let start = self.s.lisp.callback_used;
// This callback may run until `callback_used` reaches `ceiling`: `cap` steps from now, or
// sooner where a caller's allowance ends, so callbacks nested in callbacks share one
// allowance instead of each taking a fresh one (an exponential tree otherwise).
let outer = self.s.lisp.callback_ceiling;
let ceiling = start.saturating_add(cap).min(outer.unwrap_or(u64::MAX));
self.s.lisp.callback_ceiling = Some(ceiling);
let r = self.drive_callback(f, args, ceiling);
self.s.lisp.callback_ceiling = outer;
r
}
/// Run callback `f` in slices until it finishes or `callback_used` reaches `ceiling`.
fn drive_callback(&mut self, f: &Value, args: Vec<Value>, ceiling: u64) -> Result<Value, LispError> {
let cap = self.s.lisp.limits.callback_steps;
let mut m = Machine::calling(f.clone(), args);
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
// (the step budget ran out) is retried until the ceiling, so a constant-memory infinite
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
// loop inside a comparator or predicate terminates with an error rather than hanging the
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
// one calls are counted in `callback_used` too.
// thread and bypassing the blocking execution cap (CWE-400).
let mut spent: u64 = 0;
loop {
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
let remaining = cap.saturating_sub(spent);
if remaining == 0 {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
let slice = remaining.min(SYNC_SLICE);
let outcome = m.run(&mut sub, slice);
sub.s.lisp.callback_used = sub.s.lisp.callback_used.saturating_add(m.last_steps);
match outcome {
match m.run(&mut sub, slice) {
Outcome::Done(v) => return Ok(v),
Outcome::Error(e) => return Err(e),
Outcome::Wait(Need::Yield) => {}
Outcome::Wait(Need::Yield) => {
spent = spent.saturating_add(slice);
if spent >= cap {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
}
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
}
}
-17
View File
@@ -286,20 +286,6 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
}
/// Callbacks that call callbacks share one allowance: a branching routine can't multiply it.
#[test]
fn nested_callbacks_share_one_step_allowance() {
let mut s = session();
s.lisp.limits.callback_steps = 1_000_000;
// 4^14 leaf calls if every callback took a fresh allowance; the whole tree gets one.
let started = std::time::Instant::now();
let e = eval(&mut s, "(defun f (n) (if (> n 0) (vl-every '(lambda (x) (f (1- n)) t) '(1 2 3 4)) t)) (f 14)").unwrap_err();
assert!(e.contains("callback stopped after 1000000 steps"), "{e}");
assert!(started.elapsed() < std::time::Duration::from_secs(60));
// The next run starts afresh.
assert_eq!(ok(&mut s, "(f 1)"), "T");
}
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
#[test]
fn setvar_rejects_an_oversized_shared_structure() {
@@ -991,9 +977,6 @@ fn strings_are_capped() {
assert!(e.contains("too much text"), "{e}");
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
assert!(e.contains("too much text"), "{e}");
// READ makes new strings inside the list it returns: they count, though the result is a list.
let e = eval(&mut s, "(setq a \"(\\\"0123456789\\\")\") (repeat 100000 (setq b (read a)))").unwrap_err();
assert!(e.contains("too much text"), "{e}");
// The budget is per run: the next one starts afresh.
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
}
+2 -32
View File
@@ -346,11 +346,7 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
equal_within(a, b, fuzz, EQUAL_BUDGET)
}
/// [`equal`] with its work allowance (node visits plus string bytes / 64) given.
fn equal_within(a: &Value, b: &Value, fuzz: f64, mut budget: u64) -> bool {
let mut budget: u64 = EQUAL_BUDGET;
equal_depth(a, b, fuzz, 0, &mut budget)
}
@@ -377,19 +373,7 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
a = na;
b = nb;
}
_ => {
// Comparing two strings costs their length, not one visit: a shared list of long
// equal strings must spend the allowance by the bytes it compares (one unit per 64).
if let (Value::Str(x), Value::Str(y)) = (&a, &b) {
let cost = (x.len().min(y.len()) / 64) as u64;
if cost >= *budget {
*budget = 0;
return false;
}
*budget -= cost;
}
return atom_equal(&a, &b, fuzz);
}
_ => return atom_equal(&a, &b, fuzz),
}
}
}
@@ -556,20 +540,6 @@ fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
mod tests {
use super::*;
/// `equal` charges the bytes of the strings it compares: a list of long equal strings fits a
/// big allowance but not a small one, though it has few nodes.
#[test]
fn equal_spends_its_allowance_on_string_bytes() {
let long = Value::str(&"x".repeat(6_400));
let other = Value::str(&"x".repeat(6_400));
let list = |v: &Value| Value::list(vec![v.clone(); 100]);
let (a, b) = (list(&long), list(&other));
// 100 strings x 100 units each, plus about 100 node visits.
assert!(equal_within(&a, &b, 0.0, 20_000));
assert!(!equal_within(&a, &b, 0.0, 5_000));
assert!(equal(&a, &b, 0.0));
}
#[test]
fn reals_print_with_six_significant_digits() {
assert_eq!(real_text(1.0), "1.0");
+44 -7
View File
@@ -367,6 +367,7 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
/// the part of the hatch inside it is generated; `None` = all of it.
#[cfg(test)]
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
pattern_lines_until(d, h, loops, px, view, None)
}
@@ -380,6 +381,21 @@ pub(crate) fn pattern_lines_until(
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
) -> Vec<Vec<Vec2>> {
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
}
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
#[allow(clippy::too_many_arguments)]
fn pattern_lines_within(
d: &Drawing,
h: &Hatch,
loops: &[Vec<Vec2>],
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
max_runs: usize,
work_limit: u64,
) -> Vec<Vec<Vec2>> {
let lines = lines_of(d, h);
let scale = scale_of(h);
@@ -388,7 +404,7 @@ pub(crate) fn pattern_lines_until(
// Shared work budget across all families and spans, counting dash-processing steps even when
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
// their cost (CWE-400).
let mut work: u64 = MAX_HATCH_WORK;
let mut work: u64 = work_limit;
for fam in &lines {
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
// are not swept at all.
@@ -403,8 +419,8 @@ pub(crate) fn pattern_lines_until(
continue;
}
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= max_runs || work == 0 || is_set(cancel) {
break;
}
}
@@ -415,6 +431,8 @@ pub(crate) fn pattern_lines_until(
const SWATCH_REPEATS: f64 = 6.0;
/// Most runs a swatch holds.
const MAX_SWATCH_RUNS: usize = 4000;
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
const MAX_SWATCH_WORK: u64 = 2_000_000;
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
@@ -441,14 +459,19 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
pattern_lines: lines.to_vec(),
};
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
// (about a million runs) to keep a few thousand.
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
runs.truncate(MAX_SWATCH_RUNS);
runs.shrink_to_fit();
runs
}
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
struct Family<'a> {
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
max_runs: usize,
origin: Vec2,
ang: f64,
delta: Vec2,
@@ -487,6 +510,13 @@ impl Family<'_> {
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
let transform_charge = verts / 8 + 1;
if transform_charge > *work {
return;
}
*work -= transform_charge;
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
@@ -505,10 +535,17 @@ impl Family<'_> {
// family draws anything at all).
let draws = self.dashes.iter().any(|d| *d >= 0.0);
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
// Each scanline visits every boundary edge and sorts the intersections. Charge both the
// traversal and an n*ceil(log2(n)) upper bound for sorting, scaled to one unit per eight
// operations, whether or not any dash is made.
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
let sort_levels = if edges > 1 { (u64::BITS - (edges - 1).leading_zeros()) as u64 } else { 0 };
let crossings_charge = edges.saturating_add(edges.saturating_mul(sort_levels)) / 8 + 1;
for k in k0..=k1 {
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
if out.len() >= self.max_runs || crossings_charge > *work || is_set(cancel) {
return;
}
*work -= crossings_charge;
let y = k as f64 * self.spacing;
let xs = crossings(&local, y);
let offset = shift_per_line * k as f64;
@@ -538,10 +575,10 @@ impl Family<'_> {
'outer: while x < x1 {
for d in self.dashes {
guard += 1;
*work = work.saturating_sub(1);
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
break 'outer;
}
*work -= 1;
let len = d.abs();
if *d > 0.0 {
let a = x.max(x0);
+31
View File
@@ -21,11 +21,17 @@ use cadcraft_geom::Vec2;
/// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes).
const MAX_TEXTS: usize = 10_000;
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
const MAX_TEXT_POINTS: usize = 4_000_000;
/// One text element of the pattern, shaped once at the origin.
struct Element {
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
at: f64,
local: Shaped,
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
points: usize,
rotation: f64,
absolute: bool,
offset: Vec2,
@@ -50,6 +56,7 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
return out;
}
let mut walk = Walk { pts, i: 0, start: 0.0 };
let mut points_left = MAX_TEXT_POINTS;
let mut base = 0.0;
'repeats: while base <= len {
for el in &elements {
@@ -57,6 +64,8 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
if s > len + 1e-9 || out.len() >= MAX_TEXTS {
break 'repeats;
}
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
points_left = left;
let Some((p, dir)) = walk.at(s) else { break 'repeats };
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
@@ -93,8 +102,16 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
};
let (local, _) = cadcraft_fonts::place(&font, text, &params);
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
// glyphs still has a cost proportional to its size.
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
+ local.strokes.len()
+ local.glyphs.len()
+ 1;
out.push(Element {
at,
points,
local,
rotation: finite(el.rotation),
absolute: el.absolute,
@@ -175,6 +192,20 @@ mod tests {
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
}
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
#[test]
fn placed_labels_stop_at_the_point_allowance() {
let d = Drawing::new_imperial();
let mut lt = gas();
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], &lt, 1.0, 0.0);
let points: usize =
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
assert!(points <= MAX_TEXT_POINTS, "{points}");
}
#[test]
fn hostile_values_never_panic() {
let d = Drawing::new_imperial();
+11
View File
@@ -211,3 +211,14 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
}
}
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
#[test]
fn compact_patterns_stop_at_the_swatch_limits() {
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
let started = std::time::Instant::now();
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
assert!(started.elapsed() < std::time::Duration::from_secs(20));
}