diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 1bb2e37..8ca4ad9 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -491,6 +491,10 @@ pub struct HatchLoop { pub vertices: Vec, #[serde(default)] pub outer: bool, + /// The objects the loop was made from (DXF group 97 count, 330 handles): an associative + /// hatch re-fills the loop from them when they change. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/engine/src/assoc.rs b/crates/engine/src/assoc.rs index 129032a..f17cd1e 100644 --- a/crates/engine/src/assoc.rs +++ b/crates/engine/src/assoc.rs @@ -336,10 +336,13 @@ pub fn update(after: &mut Arc, before: &Drawing) -> usize { /// finishes. `uid` (when known) is the document the command started in. pub fn after_command(s: &mut crate::Session, before: Option<&Arc>, uid: Option) { let Some(before) = before else { return }; + let mut notes = Vec::new(); if let Ok(st) = s.state_mut() && uid.is_none_or(|u| u == st.uid) && !Arc::ptr_eq(before, &st.doc) { update(&mut st.doc, before); + notes = crate::cmd::hatch::refill_associative(&mut st.doc, before); } + notes.into_iter().for_each(|n| s.echo(n)); } diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 8563254..280d411 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -9,6 +9,9 @@ use super::helpers::lwpoly; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod assoc; +pub(crate) use assoc::update as refill_associative; + pub fn specs() -> Vec { vec![ CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative?}").interactive(|s| Ok(Box::new(HatchM::new(s, false)))), @@ -37,24 +40,38 @@ fn outlines(d: &Drawing, space: &Space, near: Option) -> (Vec { continue; } - let mut polys = crate::select::hit_polylines(d, e, tol); - if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { - // Clip infinite lines to a generous box around the extents. - let r = (ext.width() + ext.height()).max(1.0) * 4.0; - for pl in &mut polys { - if let [a, b] = pl.as_slice() { - let m = a.mid(*b); - let dir = (*b - *a).normalized(); - *pl = vec![m - dir * r, m + dir * r]; - } - } - } + let polys = object_polys(d, e, tol, ext); owners.extend(std::iter::repeat_n(e.handle, polys.len())); out.extend(polys); } (out, owners) } +/// An object's outline as polylines (tolerance `tol`), infinite lines clipped to a generous +/// box around the extents `ext`. +fn object_polys(d: &Drawing, e: &Entity, tol: f64, ext: Bounds2) -> Vec> { + let mut polys = crate::select::hit_polylines(d, e, tol); + if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { + let r = (ext.width() + ext.height()).max(1.0) * 4.0; + for pl in &mut polys { + if let [a, b] = pl.as_slice() { + let m = a.mid(*b); + let dir = (*b - *a).normalized(); + *pl = vec![m - dir * r, m + dir * r]; + } + } + } + polys +} + +/// The objects behind input polylines (indices into `owners`), without repeats. +fn handles_of(owners: &[Handle], idx: &[usize]) -> Vec { + let mut hs: Vec = idx.iter().filter_map(|&i| owners.get(i).copied()).collect(); + hs.sort_unstable(); + hs.dedup(); + hs +} + /// Closed loops of entities that are closed curves (for islands and "select objects"). fn closed_loop(d: &Drawing, e: &Entity) -> Option> { match &e.kind { @@ -107,7 +124,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result> { let found = cadcraft_geom::region::boundary_at(&polys, p, tol).ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?; let outer = found.outer; let ob = Bounds2::from_points(outer.iter().copied()); - let mut loops = vec![HatchLoop { vertices: outer.iter().map(|q| PolyVertex::new(*q)).collect(), outer: true }]; + let sources = handles_of(&owners, &found.outer_sources); + let mut loops = vec![HatchLoop { vertices: outer.iter().map(|q| PolyVertex::new(*q)).collect(), outer: true, sources }]; // Islands: closed objects entirely inside the outer loop that don't contain the pick point. if let Some(store) = d.space(&space) { for e in store.iter() { @@ -123,7 +141,7 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result> { let inside = pts.iter().all(|q| point_in_polygon(&outer, *q) || outer.iter().any(|o| o.near(*q, tol * 10.0))); let same_as_outer = (cadcraft_geom::shoelace(&pts).abs() - cadcraft_geom::shoelace(&outer).abs()).abs() < tol.max(1e-6) * 1000.0; if inside && !same_as_outer && !point_in_polygon(&pts, p) { - loops.push(HatchLoop { vertices: vs, outer: false }); + loops.push(HatchLoop { vertices: vs, outer: false, sources: vec![e.handle] }); } } } @@ -134,7 +152,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result> { let open_curves = !island.sources.is_empty() && island.sources.iter().all(|&i| owners.get(i).and_then(|h| d.entity(*h)).is_some_and(|e| island_curve(d, e))); if open_curves { - loops.push(HatchLoop { vertices: island.outline.into_iter().map(PolyVertex::new).collect(), outer: false }); + let sources = handles_of(&owners, &island.sources); + loops.push(HatchLoop { vertices: island.outline.into_iter().map(PolyVertex::new).collect(), outer: false, sources }); } } Ok(loops) @@ -145,7 +164,7 @@ pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result) -> EntityKind { + fn hatch(&self, mut loops: Vec) -> EntityKind { + if !self.associative { + loops.iter_mut().for_each(|l| l.sources.clear()); + } let solid = self.gradient.is_some() || self.pattern == "SOLID"; EntityKind::Hatch(Hatch { pattern: if self.gradient.is_some() { "SOLID".into() } else { self.pattern.clone() }, diff --git a/crates/engine/src/cmd/hatch/assoc.rs b/crates/engine/src/cmd/hatch/assoc.rs new file mode 100644 index 0000000..d222ad8 --- /dev/null +++ b/crates/engine/src/cmd/hatch/assoc.rs @@ -0,0 +1,234 @@ +//! Associative hatches: a hatch keeps the handles of the objects each loop was made from +//! (`HatchLoop::sources`) and is filled again when a command changes them. [`update`] runs after +//! every command that changed the drawing, next to the associative dimension pass. +//! +//! - A changed boundary object: the loop is traced again from its objects. +//! - An erased island: the loop goes, the hatch fills the area. +//! - An erased outer boundary, or one that no longer closes: the hatch keeps its shape and +//! stops being associative (with a message). +//! - The hatch moved or reshaped without its boundary: it stops being associative. +//! - A new hatch (COPY, paste, EXPLODE) whose loops aren't where its objects are: it stops being +//! associative. +//! +//! Only hatches whose own objects changed are traced again, so a command costs a scan of the +//! hatches plus the work on the ones it touched. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, EntityKind, Handle, Hatch, HatchLoop, entity_bounds}; +use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, region, shoelace}; + +use super::{closed_loop, object_polys}; + +/// Upper bound on the source objects of one loop that are traced (hostile files). +const MAX_SOURCES: usize = 4096; +/// Upper bound on new associative hatches checked against their objects in one command (bulk +/// paste); the ones beyond it stop being associative. +const MAX_NEW_CHECKED: usize = 1000; + +/// How a loop came out of its objects. +enum Refill { + Loop(Vec), + /// Every object of the loop was erased. + Erased, + /// The objects no longer enclose an area. + Open, +} + +fn changed(before: &Drawing, after: &Drawing, h: Handle) -> bool { + match (before.entity(h), after.entity(h)) { + (Some(x), Some(y)) => !Arc::ptr_eq(x, y) && x.kind != y.kind, + (Some(_), None) => true, + _ => false, + } +} + +/// A boundary object that is still there (hatches never are boundaries). +fn exists(d: &Drawing, h: Handle) -> bool { + d.entity(h).is_some_and(|e| !matches!(e.kind, EntityKind::Hatch(_))) +} + +fn bounds_of(d: &Drawing, hs: &[Handle]) -> Bounds2 { + let mut b = Bounds2::EMPTY; + for e in hs.iter().filter_map(|h| d.entity(*h)) { + b = b.union(&entity_bounds(d, e, 0)); + } + b +} + +/// `p` moved from where it sat in `from` to the same place in `to` (per axis, so a move, a +/// scale or a stretch of the objects carries a point inside the loop along). +fn map_point(p: Vec2, from: &Bounds2, to: &Bounds2) -> Vec2 { + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| { + let w = a1 - a0; + if w.abs() > 1e-12 && (b1 - b0).is_finite() { b0 + (v - a0) / w * (b1 - b0) } else { v + (b0 - a0) } + }; + Vec2::new(axis(p.x, from.min.x, from.max.x, to.min.x, to.max.x), axis(p.y, from.min.y, from.max.y, to.min.y, to.max.y)) +} + +fn outline_of(vs: &[PolyVertex], tol: f64) -> Vec { + Polyline { vertices: vs.to_vec(), closed: true }.tessellate(tol) +} + +/// The loop traced again from its objects in `after`; `before` places the point the loop is +/// found around (the drawing the loop's vertices belong to). +fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill { + let live: Vec = l.sources.iter().copied().filter(|h| exists(after, *h)).take(MAX_SOURCES).collect(); + if live.is_empty() { + return Refill::Erased; + } + if let [h] = live.as_slice() + && let Some(vs) = after.entity(*h).and_then(|e| closed_loop(after, e)) + { + return Refill::Loop(vs); + } + let nb = bounds_of(after, &live); + if nb.is_empty() { + return Refill::Open; + } + let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0; + let polys: Vec> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect(); + let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9); + let found = if l.outer { + // The face around a point inside the old loop, carried along with the objects. + let ob = bounds_of(before, &live); + let ob = if ob.is_empty() { nb } else { ob }; + let old = outline_of(&l.vertices, (ob.width() + ob.height()).max(1e-9) / 20000.0); + region::interior_point(&old) + .and_then(|q| region::enclosing_loop(&polys, map_point(q, &ob, &nb), snap)) + .or_else(|| region::outline(&polys, snap)) + } else { + region::outline(&polys, snap) + }; + match found { + Some(pts) => Refill::Loop(pts.into_iter().map(PolyVertex::new).collect()), + None => Refill::Open, + } +} + +/// Two loops cover the same area (same extents and area, within a small tolerance). +fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool { + let pa = outline_of(a, 1e-3); + let pb = outline_of(b, 1e-3); + let (ba, bb) = (Bounds2::from_points(pa.iter().copied()), Bounds2::from_points(pb.iter().copied())); + if ba.is_empty() || bb.is_empty() { + return false; + } + let size = ba.width() + ba.height(); + let eps = 1e-4 * (1.0 + size); + ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size) +} + +fn detach(h: &mut Hatch) { + h.associative = false; + h.loops.iter_mut().for_each(|l| l.sources.clear()); +} + +/// Loops of `h` that aren't loops of `prev` (a hatch split by HATCHEDIT, or one that lost an +/// island, keeps the rest unchanged). +fn reshaped(prev: &Hatch, h: &Hatch) -> bool { + if prev.loops.len() == h.loops.len() { + return prev.loops.iter().zip(&h.loops).any(|(a, b)| a.vertices != b.vertices); + } + if prev.loops.len().saturating_mul(h.loops.len()) > 4_000_000 { + return true; + } + h.loops.iter().any(|l| !prev.loops.iter().any(|q| q.vertices == l.vertices)) +} + +/// What happens to one associative hatch after a command; `None` when nothing changes. +fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked: &mut usize) -> Option<(Hatch, Option)> { + let prev = match before.entity(handle).map(|e| &e.kind) { + Some(EntityKind::Hatch(p)) => Some(p), + _ => None, + }; + let touched = h.loops.iter().flat_map(|l| &l.sources).any(|s| changed(before, after, *s)); + let mut nh = h.clone(); + if !touched { + if let (Some(x), Some(y)) = (before.entity(handle), after.entity(handle)) + && Arc::ptr_eq(x, y) + { + return None; + } + match prev { + // Moved or reshaped on its own: no longer tied to the boundary. + Some(p) if reshaped(p, h) => { + detach(&mut nh); + return Some((nh, None)); + } + Some(_) => return None, + None => { + // A new hatch (a copy, a paste, an exploded block) keeps its link only when its + // loops are where its objects are. + *checked += 1; + let fits = *checked <= MAX_NEW_CHECKED + && h.loops + .iter() + .all(|l| l.sources.is_empty() || matches!(refill(after, after, l), Refill::Loop(vs) if same_shape(&vs, &l.vertices))); + if fits { + return None; + } + detach(&mut nh); + return Some((nh, None)); + } + } + } + let mut loops = Vec::with_capacity(h.loops.len()); + for l in &h.loops { + if l.sources.is_empty() { + loops.push(l.clone()); + continue; + } + let from = if prev.is_some() { before } else { after }; + match refill(from, after, l) { + Refill::Loop(vertices) => { + let sources = l.sources.iter().copied().filter(|s| exists(after, *s)).take(MAX_SOURCES).collect(); + loops.push(HatchLoop { vertices, outer: l.outer, sources }); + } + // An erased island: the hatch fills its area. + Refill::Erased if !l.outer => {} + r @ (Refill::Erased | Refill::Open) => { + detach(&mut nh); + let why = if matches!(r, Refill::Erased) { "was erased" } else { "no longer encloses an area" }; + return Some((nh, Some(format!("Hatch {}: its boundary {why}; the hatch is no longer associative.", handle.hex())))); + } + } + } + if !loops.iter().any(|l| l.outer) && h.loops.iter().any(|l| l.outer) { + detach(&mut nh); + return Some((nh, None)); + } + nh.loops = loops; + (nh != *h).then_some((nh, None)) +} + +/// After a change: fill every associative hatch whose boundary objects changed again, and +/// unlink hatches that no longer follow their boundary. Returns the messages to show. +pub fn update(after: &mut Arc, before: &Drawing) -> Vec { + let mut changes: Vec<(Handle, Hatch)> = Vec::new(); + let mut notes = Vec::new(); + { + let d: &Drawing = after; + let mut checked = 0; + for store in std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)) { + for e in store.iter() { + let EntityKind::Hatch(h) = &e.kind else { continue }; + if !h.associative || h.loops.iter().all(|l| l.sources.is_empty()) { + continue; + } + if let Some((nh, note)) = follow(before, d, e.handle, h, &mut checked) { + changes.push((e.handle, nh)); + notes.extend(note); + } + } + } + } + if changes.is_empty() { + return notes; + } + let d = Arc::make_mut(after); + for (h, nh) in changes { + let _ = d.modify_entity(h, |e| e.kind = EntityKind::Hatch(nh)); + } + notes +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ff2c4c9..6e80e7d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,7 +16,7 @@ pub mod file; mod gripcmds; mod griphot; pub(crate) mod groups; -mod hatch; +pub(crate) mod hatch; mod inquiry; mod layer; mod layout; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 2eb7c4d..0b9a4ed 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -137,7 +137,7 @@ pub fn bracket() -> Drawing { EntityKind::Hatch(Hatch { pattern: "ANSI31".into(), solid: false, - loops: vec![HatchLoop { vertices: rect_vertices(a, bb), outer: true }], + loops: vec![HatchLoop { vertices: rect_vertices(a, bb), outer: true, sources: Vec::new() }], scale: 1.0, angle: 0.0, associative: false, diff --git a/crates/engine/tests/hatch_assoc.rs b/crates/engine/tests/hatch_assoc.rs new file mode 100644 index 0000000..ac73c27 --- /dev/null +++ b/crates/engine/tests/hatch_assoc.rs @@ -0,0 +1,196 @@ +//! Associative hatches follow their boundary objects (#451): MOVE, STRETCH and SCALE of a +//! boundary fill the hatch again; erasing an island lets the hatch fill it; erasing or opening +//! the outer boundary, or moving the hatch on its own, ends the link. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle, Hatch, HatchLoop}; +use cadcraft_engine::geom::{Bounds2, PolyVertex, Polyline, Vec2, shoelace}; +use serde_json::{Value, json}; + +fn hatch(s: &Session, h: &str) -> Hatch { + let h = Handle::parse_hex(h).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Hatch(x) => x.clone(), + k => panic!("not a hatch: {k:?}"), + } +} + +fn outline(l: &HatchLoop) -> Vec { + Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(1e-4) +} + +/// Filled area: outer loops minus islands. +fn area(h: &Hatch) -> f64 { + h.loops.iter().map(|l| shoelace(&outline(l)).abs() * if l.outer { 1.0 } else { -1.0 }).sum() +} + +fn bounds(h: &Hatch) -> Bounds2 { + Bounds2::from_points(h.loops.iter().flat_map(outline)) +} + +fn handle(v: &Value) -> String { + v["handle"].as_str().unwrap().to_string() +} + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +/// A 10 x 10 square of four separate lines, hatched by picking inside. +fn square_of_lines(s: &mut Session) -> (Vec, String) { + let ls = vec![ + line(s, [0.0, 0.0], [10.0, 0.0]), + line(s, [10.0, 0.0], [10.0, 10.0]), + line(s, [10.0, 10.0], [0.0, 10.0]), + line(s, [0.0, 10.0], [0.0, 0.0]), + ]; + let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()); + (ls, h) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 * (1.0 + b.abs()) +} + +#[test] +fn pick_point_hatch_follows_stretched_lines() { + let mut s = Session::new(); + let (ls, h) = square_of_lines(&mut s); + let hh = hatch(&s, &h); + assert!(hh.associative); + let mut src: Vec = hh.loops[0].sources.iter().map(|h| h.hex()).collect(); + src.sort(); + let mut want = ls.clone(); + want.sort(); + assert_eq!(src, want, "the outer loop remembers its four lines"); + // Stretch the right edge 5 units to the right: the hatch fills 15 x 10. + s.execute("stretch", &json!({ "window": [[8, -1], [12, 11]], "delta": [5, 0] })).unwrap(); + let hh = hatch(&s, &h); + assert!(hh.associative); + assert!(close(area(&hh), 150.0), "area {}", area(&hh)); + // Undo puts the hatch back with its boundary. + s.execute("u", &json!({})).unwrap(); + assert!(close(area(&hatch(&s, &h)), 100.0)); +} + +#[test] +fn hatch_of_selected_polyline_follows_move_and_scale() { + let mut s = Session::new(); + let r = handle(&s.execute("rectang", &json!({ "p1": [0, 0], "p2": [4, 2] })).unwrap()); + let h = handle(&s.execute("hatch", &json!({ "handles": [r.clone()] })).unwrap()); + s.execute("move", &json!({ "handles": [r.clone()], "delta": [100, 50] })).unwrap(); + let b = bounds(&hatch(&s, &h)); + assert!(b.min.dist(Vec2::new(100.0, 50.0)) < 1e-9 && b.max.dist(Vec2::new(104.0, 52.0)) < 1e-9, "{b:?}"); + s.execute("scale", &json!({ "handles": [r], "base": [100, 50], "factor": 2 })).unwrap(); + let hh = hatch(&s, &h); + assert!(close(area(&hh), 32.0), "area {}", area(&hh)); + assert!(hh.associative); +} + +#[test] +fn moving_hatch_with_its_boundary_keeps_the_link() { + let mut s = Session::new(); + let (mut ls, h) = square_of_lines(&mut s); + ls.push(h.clone()); + s.execute("move", &json!({ "handles": ls, "delta": [20, 0] })).unwrap(); + let hh = hatch(&s, &h); + assert!(hh.associative); + assert!(close(area(&hh), 100.0)); + assert!(bounds(&hh).min.dist(Vec2::new(20.0, 0.0)) < 1e-6); +} + +#[test] +fn erased_island_is_filled_erased_boundary_ends_the_link() { + let mut s = Session::new(); + let r = handle(&s.execute("rectang", &json!({ "p1": [0, 0], "p2": [20, 20] })).unwrap()); + let c = handle(&s.execute("circle", &json!({ "center": [10, 10], "radius": 2 })).unwrap()); + let h = handle(&s.execute("hatch", &json!({ "points": [[1, 1]] })).unwrap()); + assert_eq!(hatch(&s, &h).loops.len(), 2, "outer + circle island"); + s.execute("erase", &json!({ "handles": [c] })).unwrap(); + let hh = hatch(&s, &h); + assert_eq!(hh.loops.len(), 1, "the island's area is hatched"); + assert!(hh.associative); + assert!(close(area(&hh), 400.0)); + // Erasing the outer boundary keeps the hatch where it is, no longer associative. + s.execute("erase", &json!({ "handles": [r] })).unwrap(); + let hh = hatch(&s, &h); + assert!(!hh.associative); + assert!(hh.loops.iter().all(|l| l.sources.is_empty())); + assert!(close(area(&hh), 400.0)); + assert!(s.log.iter().any(|m| m.contains("no longer associative")), "{:?}", s.log); +} + +#[test] +fn opening_the_boundary_ends_the_link() { + let mut s = Session::new(); + let (ls, h) = square_of_lines(&mut s); + // Pull the top edge away: the lines no longer enclose an area. + s.execute("move", &json!({ "handles": [ls[2].clone()], "delta": [0, 5] })).unwrap(); + let hh = hatch(&s, &h); + assert!(!hh.associative); + assert!(close(area(&hh), 100.0), "the hatch keeps its shape"); +} + +#[test] +fn moving_or_copying_the_hatch_alone_ends_the_link() { + let mut s = Session::new(); + let (_, h) = square_of_lines(&mut s); + let n = s.doc().unwrap().model.len(); + s.execute("copy", &json!({ "handles": [h.clone()], "delta": [30, 0] })).unwrap(); + let copy = s.doc().unwrap().model.iter().skip(n).find(|e| matches!(e.kind, EntityKind::Hatch(_))).map(|e| e.handle.hex()).unwrap(); + assert!(!hatch(&s, ©).associative, "a copy away from the boundary isn't associative"); + assert!(hatch(&s, &h).associative, "the original still is"); + s.execute("move", &json!({ "handles": [h.clone()], "delta": [0, 30] })).unwrap(); + assert!(!hatch(&s, &h).associative); +} + +#[test] +fn non_associative_hatch_keeps_no_sources() { + let mut s = Session::new(); + let r = handle(&s.execute("rectang", &json!({ "p1": [0, 0], "p2": [4, 2] })).unwrap()); + let h = handle(&s.execute("hatch", &json!({ "handles": [r.clone()], "associative": false })).unwrap()); + assert!(hatch(&s, &h).loops.iter().all(|l| l.sources.is_empty())); + s.execute("move", &json!({ "handles": [r], "delta": [10, 0] })).unwrap(); + assert!(bounds(&hatch(&s, &h)).min.dist(Vec2::ZERO) < 1e-9, "stays put"); +} + +#[test] +fn hostile_sources_never_panic() { + let mut s = Session::new(); + let r = handle(&s.execute("rectang", &json!({ "p1": [0, 0], "p2": [4, 2] })).unwrap()); + let rh = Handle::parse_hex(&r).unwrap(); + let sq = vec![PolyVertex::new(Vec2::ZERO), PolyVertex::new(Vec2::new(4.0, 0.0)), PolyVertex::new(Vec2::new(4.0, 2.0))]; + let nan = vec![PolyVertex::new(Vec2::new(f64::NAN, 0.0)); 3]; + let mut sources = vec![Handle(u64::MAX), Handle(0), rh]; + sources.extend((1..10_000).map(Handle)); + let k = EntityKind::Hatch(Hatch { + pattern: "ANSI31".into(), + solid: false, + loops: vec![HatchLoop { vertices: sq, outer: true, sources: sources.clone() }, HatchLoop { vertices: nan, outer: false, sources }], + scale: 1.0, + angle: 0.0, + associative: true, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + }); + s.add_entity(k).unwrap(); + s.execute("move", &json!({ "handles": [r], "delta": [1, 1] })).unwrap(); + s.execute("erase", &json!({ "handles": "all" })).ok(); +} + +#[test] +fn interactive_stretch_at_the_command_line_refills() { + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()); + // Crossing-select the right edge and stretch it 5 to the right: the hatch fills 15 x 10. + s.script("STRETCH\nC\n12,-1\n8,11\n\n0,0\n5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let hh = hatch(&s, &h); + assert!(hh.associative); + assert!(close(area(&hh), 150.0), "area {}", area(&hh)); +} diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 429d10c..d50d1c1 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -27,6 +27,8 @@ pub struct Boundary { pub outer: Vec, /// Groups of curves inside `outer` that don't touch it and don't surround the point. pub islands: Vec, + /// Indices into the input polylines that make up `outer`, ascending. + pub outer_sources: Vec, } /// One island: the outside outline of a connected group of curves. @@ -50,6 +52,8 @@ struct Graph { adj: Vec>, /// (graph vertex, input polyline) for every input segment: which curve touches which group. src: Vec<(usize, usize)>, + /// The input polyline of every edge (lower vertex index first). + edge_src: HashMap<(usize, usize), usize>, } fn build(polys: &[Vec], tol: f64) -> Option { @@ -129,7 +133,7 @@ fn build(polys: &[Vec], tol: f64) -> Option { } } } - let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n) }; + let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() }; let mut index: HashMap<(i64, i64), usize> = HashMap::new(); let mut vid = |p: Vec2, g: &mut Graph| -> usize { let k = key(p, bb.min, tol); @@ -153,6 +157,9 @@ fn build(polys: &[Vec], tol: f64) -> Option { let u = vid(line.at(*t0), &mut g); let v = vid(line.at(*t1), &mut g); if u != v { + if let Some(k) = owner.get(i) { + g.edge_src.entry((u.min(v), u.max(v))).or_insert(*k); + } if let Some(l) = g.adj.get_mut(u) && !l.contains(&v) { @@ -284,7 +291,25 @@ fn prune(g: &Graph) -> Graph { } } } - Graph { pts: g.pts.clone(), adj, src: Vec::new() } + Graph { pts: g.pts.clone(), adj, src: Vec::new(), edge_src: HashMap::new() } +} + +/// The outline of the outside of a group of curves on cycles, traced counter-clockwise from its +/// leftmost (then lowest) vertex `u`, giving up after `steps` edges. +fn outside(core: &Graph, u: usize, steps: usize) -> Option> { + let pu = *core.pts.get(u)?; + // Every neighbour lies to the right of the leftmost vertex; the one turned furthest + // counter-clockwise has the outside of the group on its left. + let signed = |w: &usize| { + core.pts.get(*w).map(|q| { + let a = (*q - pu).angle(); + if a > crate::PI { a - crate::TAU } else { a } + }) + }; + let w = core.adj.get(u).into_iter().flatten().filter_map(|w| Some((signed(w)?, *w))).max_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, w)| w)?; + let mut lp = trace(core, u, w, steps).and_then(|ids| points(core, &ids))?; + lp.reverse(); + Some(lp) } /// Outlines of the groups of curves inside the face `outer` (vertex indices into `g`). @@ -329,21 +354,8 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec { if !ob.contains(pu) || !point_in_polygon(&outer_pts, pu) { continue; } - // Every neighbour lies to the right of the leftmost vertex; the one turned furthest - // counter-clockwise has the outside of the group on its left. - let signed = |w: &usize| { - core.pts.get(*w).map(|q| { - let a = (*q - pu).angle(); - if a > crate::PI { a - crate::TAU } else { a } - }) - }; - let Some(w) = core.adj.get(u).into_iter().flatten().filter_map(|w| Some((signed(w)?, *w))).max_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, w)| w) - else { - continue; - }; let steps = edges.get(&c).copied().unwrap_or(0).saturating_add(4); - let Some(mut lp) = trace(&core, u, w, steps).and_then(|ids| points(&core, &ids)) else { continue }; - lp.reverse(); + let Some(lp) = outside(&core, u, steps) else { continue }; let area = shoelace(&lp); let ib = Bounds2::from_points(lp.iter().copied()); if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) || point_in_polygon(&lp, p) { @@ -369,7 +381,66 @@ pub fn boundary_at(polys: &[Vec], p: Vec2, tol: f64) -> Option { let ids = outer_face(&g, p)?; let outer = points(&g, &ids)?; let islands = islands(&g, &ids, p); - Some(Boundary { outer, islands }) + let mut outer_sources: Vec = + ids.iter().zip(ids.iter().cycle().skip(1)).filter_map(|(&u, &v)| g.edge_src.get(&(u.min(v), u.max(v))).copied()).collect(); + outer_sources.sort_unstable(); + outer_sources.dedup(); + Some(Boundary { outer, islands, outer_sources }) +} + +/// The outside outline of curves that together enclose an area (counter-clockwise, dangling +/// ends removed), or `None` when they enclose nothing or form more than one separate group. +pub fn outline(polys: &[Vec], tol: f64) -> Option> { + let g = build(polys, tol.max(1e-12))?; + let core = prune(&g); + let comp = components(&core); + let mut start: Option = None; + let mut group: Option = None; + let mut edges = 0usize; + for (u, ns) in core.adj.iter().enumerate() { + if ns.is_empty() { + continue; + } + let c = comp.get(u).copied()?; + if group.is_some_and(|g| g != c) { + return None; + } + group = Some(c); + edges = edges.saturating_add(ns.len()); + let q = core.pts.get(u)?; + if start.and_then(|s| core.pts.get(s)).is_none_or(|b| q.x < b.x || (q.x == b.x && q.y < b.y)) { + start = Some(u); + } + } + let lp = outside(&core, start?, edges.saturating_add(4))?; + (lp.len() >= 3 && shoelace(&lp) > 0.0).then_some(lp) +} + +/// A point strictly inside a closed polygon (either orientation), for re-finding the face a +/// loop encloses: the middle of the widest span of a horizontal line through it. +pub fn interior_point(poly: &[Vec2]) -> Option { + let b = Bounds2::from_points(poly.iter().copied().filter(|p| p.is_finite())); + if poly.len() < 3 || b.is_empty() || b.height() <= 0.0 { + return None; + } + let mut best: Option<(f64, Vec2)> = None; + for f in [0.5, 0.381_966, 0.618_034, 0.25, 0.75] { + let y = b.min.y + b.height() * f; + let mut xs: Vec = Vec::new(); + for (a, c) in poly.iter().zip(poly.iter().cycle().skip(1)) { + if (a.y > y) != (c.y > y) { + xs.push(a.x + (c.x - a.x) * (y - a.y) / (c.y - a.y)); + } + } + xs.sort_by(f64::total_cmp); + for [x0, x1] in xs.as_chunks::<2>().0 { + let width = x1 - x0; + if width.is_finite() && best.is_none_or(|(bw, _)| width > bw) { + best = Some((width, Vec2::new((x0 + x1) / 2.0, y))); + } + } + } + best.filter(|(w, _)| *w > 0.0).map(|(_, p)| p) } #[cfg(test)] @@ -529,4 +600,27 @@ mod tests { let b = boundary_at(&many, Vec2::new(-0.5, -0.5), 1e-9).unwrap(); assert!(b.islands.len() <= MAX_ISLANDS); } + + #[test] + fn outer_sources_outline_and_interior_point() { + // A square cut by a diagonal: the triangle picked is made of the square and the diagonal; + // a far-away line takes no part. + let polys = vec![square(0.0, 0.0, 10.0), vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0)], vec![Vec2::new(30.0, 0.0), Vec2::new(40.0, 0.0)]]; + let b = boundary_at(&polys, Vec2::new(7.0, 2.0), 1e-9).unwrap(); + assert_eq!(b.outer_sources, vec![0, 1]); + // Four separate lines: one outline; an open U and two separate squares have none. + let l = outline(&lines_of(&square(5.0, 5.0, 5.0)), 1e-9).unwrap(); + assert!((shoelace(&l) - 25.0).abs() < 1e-9); + assert!(outline(&[vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]], 1e-9).is_none()); + assert!(outline(&[square(0.0, 0.0, 1.0), square(5.0, 5.0, 1.0)], 1e-9).is_none()); + // An L shape: the point is inside, also for a clockwise loop. + let l_shape = + vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 1.0), Vec2::new(1.0, 1.0), Vec2::new(1.0, 10.0), Vec2::new(0.0, 10.0)]; + let p = interior_point(&l_shape).unwrap(); + assert!(point_in_polygon(&l_shape, p)); + let rev: Vec = l_shape.iter().rev().copied().collect(); + assert!(point_in_polygon(&rev, interior_point(&rev).unwrap())); + assert!(interior_point(&[Vec2::ZERO, Vec2::new(1.0, 0.0)]).is_none()); + assert!(interior_point(&[Vec2::new(f64::NAN, 0.0); 4]).is_none()); + } } diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1677638..8a6b127 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -647,7 +647,30 @@ fn hatch(tags: &[Tag], t: &T) -> Option { if verts.len() > 1 && verts.first().map(|f| f.p).zip(verts.last().map(|l| l.p)).is_some_and(|(a, b)| a.near(b, 1e-9)) { verts.pop(); } - loops.push(HatchLoop { vertices: verts, outer }); + // 97: number of source boundary objects, then their handles (330). A spline edge's fit + // data count is a 97 too, followed by its fit points instead. + let mut sources = Vec::new(); + for _ in 0..2 { + while get(i).is_some_and(|x| !matches!(x.code, 97 | 92 | 75)) { + i += 1; + } + if get(i).is_none_or(|x| x.code != 97) { + break; + } + let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; + i += 1; + if get(i).is_some_and(|x| !matches!(x.code, 330 | 92 | 75)) { + continue; + } + while sources.len() < n + && let Some(x) = get(i).filter(|x| x.code == 330) + { + sources.extend(Handle::parse_hex(&x.str()).filter(|h| h.0 != 0)); + i += 1; + } + break; + } + loops.push(HatchLoop { vertices: verts, outer, sources }); } let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); @@ -671,6 +694,9 @@ fn hatch(tags: &[Tag], t: &T) -> Option { })) } +/// Upper bound on the source boundary objects of one HATCH loop. +const MAX_HATCH_SOURCES: usize = 4096; + /// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. const MAX_PATTERN_LINES: usize = 1024; const MAX_PATTERN_DASHES: usize = 256; diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index a98e104..8e82682 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -833,7 +833,12 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.f(42, v.bulge); } } - w.i(97, 0); + // Source boundary objects of an associative hatch (the ones still in the drawing). + let sources: Vec = l.sources.iter().filter(|s| h.associative && d.entity(**s).is_some()).map(|s| s.hex()).collect(); + w.i(97, sources.len() as i64); + for s in &sources { + w.s(330, s); + } } w.i(75, i64::from(h.style)); w.i(76, 1); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 8357a2a..2ec428d 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -105,6 +105,7 @@ fn sample() -> Drawing { loops: vec![HatchLoop { vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::new(Vec2::new(2.0, 0.0)), PolyVertex::new(Vec2::new(2.0, 2.0))], outer: true, + sources: Vec::new(), }], scale: 1.0, angle: 0.0, @@ -1495,7 +1496,7 @@ fn gradient_sample() -> (Drawing, Vec>) { let h = Hatch { pattern: "SOLID".into(), solid: true, - loops: vec![HatchLoop { vertices: square.clone(), outer: true }], + loops: vec![HatchLoop { vertices: square.clone(), outer: true, sources: Vec::new() }], scale: 1.0, angle: 0.0, associative: false, diff --git a/crates/io/tests/entity_props_roundtrip.rs b/crates/io/tests/entity_props_roundtrip.rs index dda8894..d86d0b4 100644 --- a/crates/io/tests/entity_props_roundtrip.rs +++ b/crates/io/tests/entity_props_roundtrip.rs @@ -74,7 +74,7 @@ fn hatch(pattern: &str, gradient: Option) -> Hatch { Hatch { pattern: pattern.into(), solid: pattern == "SOLID", - loops: vec![HatchLoop { vertices: square(10.0), outer: true }], + loops: vec![HatchLoop { vertices: square(10.0), outer: true, sources: Vec::new() }], scale: 2.0, angle: 0.25, associative: false, diff --git a/crates/io/tests/hatch_assoc_roundtrip.rs b/crates/io/tests/hatch_assoc_roundtrip.rs new file mode 100644 index 0000000..ec72b8e --- /dev/null +++ b/crates/io/tests/hatch_assoc_roundtrip.rs @@ -0,0 +1,105 @@ +//! The source boundary objects of an associative HATCH (group 97 count, 330 handles per loop, +//! DXF Reference) survive a DXF save and read (#451). + +use cadcraft_doc::*; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_io::{read_dxf, write_dxf}; + +fn square(x0: f64, s: f64) -> Vec { + [(x0, x0), (x0 + s, x0), (x0 + s, x0 + s), (x0, x0 + s)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() +} + +fn hatch(loops: Vec, associative: bool) -> Hatch { + Hatch { + pattern: "ANSI31".into(), + solid: false, + loops, + scale: 1.0, + angle: 0.0, + associative, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + } +} + +fn poly(d: &mut Drawing, vs: Vec) -> Handle { + let k = EntityKind::LwPolyline(LwPolyline { vertices: vs, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false }); + d.add(&Space::Model, Common::default(), k).expect("add") +} + +fn hatches(d: &Drawing) -> Vec { + d.model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).collect() +} + +#[test] +fn source_boundary_objects_roundtrip() { + let mut d = Drawing::new_imperial(); + let outer = poly(&mut d, square(0.0, 20.0)); + let island = poly(&mut d, square(5.0, 5.0)); + let loops = vec![ + HatchLoop { vertices: square(0.0, 20.0), outer: true, sources: vec![outer] }, + HatchLoop { vertices: square(5.0, 5.0), outer: false, sources: vec![island] }, + ]; + d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch(loops.clone(), true))).expect("add"); + // A non-associative hatch writes no sources; a source that is gone isn't written. + d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch(vec![HatchLoop { sources: vec![outer], ..loops[0].clone() }], false))) + .expect("add"); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch(vec![HatchLoop { sources: vec![Handle(0xFFFF)], ..loops[0].clone() }], true))) + .expect("add"); + let text = write_dxf(&d); + let back = read_dxf(text.as_bytes()).expect("read"); + let hs = hatches(&back); + assert_eq!(hs.len(), 3); + assert!(hs[0].associative); + assert_eq!(hs[0].loops.iter().map(|l| l.sources.clone()).collect::>(), vec![vec![outer], vec![island]]); + assert!(hs[1].loops[0].sources.is_empty()); + assert!(hs[2].loops[0].sources.is_empty()); + // Saved again, the links stay. + let again = read_dxf(write_dxf(&back).as_bytes()).expect("read"); + assert_eq!(hatches(&again)[0].loops, hs[0].loops); +} + +/// (group code, value) pairs of a DXF text. +fn pairs(text: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = text.lines().collect(); + lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim().to_string()))).collect() +} + +fn join(p: &[(i32, String)]) -> String { + p.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +/// A HATCH whose edge boundary has a spline edge with fit data (its own 97 count) followed by +/// the source objects, and hostile counts and handles. +#[test] +fn sources_after_spline_fit_data_and_hostile_counts() { + let mut d = Drawing::new_imperial(); + let outer = poly(&mut d, square(0.0, 20.0)); + let loops = vec![HatchLoop { vertices: square(0.0, 20.0), outer: true, sources: vec![outer] }]; + d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch(loops, true))).expect("add"); + let p = pairs(&write_dxf(&d)); + let start = p.iter().position(|(c, v)| *c == 0 && v == "HATCH").expect("HATCH"); + let at = start + p.iter().skip(start).position(|(c, _)| *c == 92).expect("92"); + let end = at + p.iter().skip(at).position(|(c, _)| *c == 97).expect("97"); + assert_eq!(p.get(end + 1), Some(&(330, outer.hex())), "sources written after the boundary"); + let with = |boundary: &str, sources: &str| { + let mut q: Vec<(i32, String)> = p[..at].to_vec(); + q.extend(pairs(boundary)); + q.extend(pairs(sources)); + q.extend(p[end + 2..].iter().cloned()); + read_dxf(join(&q).as_bytes()).expect("read") + }; + let src = format!("97\n1\n330\n{}\n", outer.hex()); + // An edge boundary: one spline edge with two fit points. + let spline = "92\n1\n93\n1\n72\n4\n94\n3\n73\n0\n74\n0\n95\n8\n40\n0\n40\n0\n40\n0\n40\n0\n40\n1\n40\n1\n40\n1\n40\n1\n96\n4\n10\n0\n20\n0\n10\n20\n20\n0\n10\n20\n20\n20\n10\n0\n20\n20\n97\n2\n11\n0\n21\n0\n11\n0\n21\n20\n"; + assert_eq!(hatches(&with(spline, &src))[0].loops[0].sources, vec![outer]); + let poly_loop = join(&p[at..end]); + // Hostile: a huge count, unparsable and zero handles; a negative count. + let back = with(&poly_loop, "97\n999999999999\n330\nZZZ\n330\n0\n330\n1F\n"); + assert_eq!(hatches(&back)[0].loops[0].sources, vec![Handle(0x1F)]); + assert!(hatches(&with(&poly_loop, "97\n-5\n"))[0].loops[0].sources.is_empty()); +} diff --git a/crates/io/tests/hatch_pattern_def.rs b/crates/io/tests/hatch_pattern_def.rs index c4108c6..036d89a 100644 --- a/crates/io/tests/hatch_pattern_def.rs +++ b/crates/io/tests/hatch_pattern_def.rs @@ -8,7 +8,7 @@ use cadcraft_io::{read_dxf, write_dxf}; fn square() -> HatchLoop { let v = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); - HatchLoop { vertices: v, outer: true } + HatchLoop { vertices: v, outer: true, sources: Vec::new() } } fn hatch(pattern: &str, scale: f64, angle: f64, origin: Vec2, lines: Vec) -> Hatch { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 3551c57..972ff42 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -77,7 +77,7 @@ fn hatch_pattern_and_solid() { let h = Hatch { pattern: "ANSI31".into(), solid: false, - loops: vec![HatchLoop { vertices: sq.clone(), outer: true }], + loops: vec![HatchLoop { vertices: sq.clone(), outer: true, sources: Vec::new() }], scale: 1.0, angle: 0.0, associative: false, diff --git a/crates/render/tests/hatch_fill.rs b/crates/render/tests/hatch_fill.rs index 538f1c1..e7a5819 100644 --- a/crates/render/tests/hatch_fill.rs +++ b/crates/render/tests/hatch_fill.rs @@ -9,7 +9,7 @@ use cadcraft_render::{DisplayList, Kind, Options, build}; fn square(lo: f64, hi: f64) -> HatchLoop { let v = [(lo, lo), (hi, lo), (hi, hi), (lo, hi)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); - HatchLoop { vertices: v, outer: lo == 0.0 } + HatchLoop { vertices: v, outer: lo == 0.0, sources: Vec::new() } } fn hatch(pattern: &str, loops: Vec) -> Hatch {