Hatch: associative hatches follow their boundary objects; DXF 97/330 source objects (part of #451) (#454)
Squash of PR #454 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61812b25de
commit
e02d1ebd08
|
|
@ -491,6 +491,10 @@ pub struct HatchLoop {
|
|||
pub vertices: Vec<PolyVertex>,
|
||||
#[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<Handle>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -336,10 +336,13 @@ pub fn update(after: &mut Arc<Drawing>, 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<Drawing>>, uid: Option<u64>) {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Bounds2>) -> (Vec<Vec<Vec2>
|
|||
{
|
||||
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<Vec<Vec2>> {
|
||||
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<Handle> {
|
||||
let mut hs: Vec<Handle> = 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<Vec<PolyVertex>> {
|
||||
match &e.kind {
|
||||
|
|
@ -107,7 +124,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
|||
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<Vec<HatchLoop>> {
|
|||
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<Vec<HatchLoop>> {
|
|||
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<Vec<Hatch
|
|||
let mut loops = Vec::new();
|
||||
for h in hs {
|
||||
if let Some(vs) = d.entity(*h).and_then(|e| closed_loop(d, e)) {
|
||||
loops.push(HatchLoop { vertices: vs, outer: loops.is_empty() });
|
||||
loops.push(HatchLoop { vertices: vs, outer: loops.is_empty(), sources: vec![*h] });
|
||||
}
|
||||
}
|
||||
if loops.is_empty() {
|
||||
|
|
@ -183,10 +202,13 @@ impl HatchSettings {
|
|||
scale: f64_or(p, "scale", hdr.1).max(1e-9),
|
||||
angle: p.get("angle").and_then(Value::as_f64).map(f64::to_radians).unwrap_or(hdr.2),
|
||||
gradient: g,
|
||||
associative: bool_or(p, "associative", true),
|
||||
associative: bool_or(p, "associative", s.doc().map_or(true, |d| d.header.i64("HPASSOC", 1) != 0)),
|
||||
}
|
||||
}
|
||||
fn hatch(&self, loops: Vec<HatchLoop>) -> EntityKind {
|
||||
fn hatch(&self, mut loops: Vec<HatchLoop>) -> 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() },
|
||||
|
|
|
|||
|
|
@ -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<PolyVertex>),
|
||||
/// 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<Vec2> {
|
||||
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<Handle> = 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<Vec<Vec2>> = 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<String>)> {
|
||||
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<Drawing>, before: &Drawing) -> Vec<String> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Vec2> {
|
||||
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>, 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<String> = 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));
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ pub struct Boundary {
|
|||
pub outer: Vec<Vec2>,
|
||||
/// Groups of curves inside `outer` that don't touch it and don't surround the point.
|
||||
pub islands: Vec<Island>,
|
||||
/// Indices into the input polylines that make up `outer`, ascending.
|
||||
pub outer_sources: Vec<usize>,
|
||||
}
|
||||
|
||||
/// One island: the outside outline of a connected group of curves.
|
||||
|
|
@ -50,6 +52,8 @@ struct Graph {
|
|||
adj: Vec<Vec<usize>>,
|
||||
/// (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<Vec2>], tol: f64) -> Option<Graph> {
|
||||
|
|
@ -129,7 +133,7 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
|||
}
|
||||
}
|
||||
}
|
||||
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<Vec2>], tol: f64) -> Option<Graph> {
|
|||
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<Vec<Vec2>> {
|
||||
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<Island> {
|
|||
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<Vec2>], p: Vec2, tol: f64) -> Option<Boundary> {
|
|||
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<usize> =
|
||||
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<Vec2>], tol: f64) -> Option<Vec<Vec2>> {
|
||||
let g = build(polys, tol.max(1e-12))?;
|
||||
let core = prune(&g);
|
||||
let comp = components(&core);
|
||||
let mut start: Option<usize> = None;
|
||||
let mut group: Option<usize> = 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<Vec2> {
|
||||
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<f64> = 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<Vec2> = 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -647,7 +647,30 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
|
|||
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<EntityKind> {
|
|||
}))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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<String> = 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);
|
||||
|
|
|
|||
|
|
@ -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<Option<Gradient>>) {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ fn hatch(pattern: &str, gradient: Option<Gradient>) -> 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,
|
||||
|
|
|
|||
|
|
@ -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<PolyVertex> {
|
||||
[(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<HatchLoop>, 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<PolyVertex>) -> 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<Hatch> {
|
||||
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![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());
|
||||
}
|
||||
|
|
@ -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<PatternLine>) -> Hatch {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HatchLoop>) -> Hatch {
|
||||
|
|
|
|||
Loading…
Reference in New Issue