mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-27 12:27:04 -06:00
* refactor: replace wildcard imports with explicit ones (S2208, batch_04) * refactor: replace wildcard imports with explicit ones (S2208, batch_04) * Format imports so the CI fmt gate passes * fix: import f64_req in inquiry.rs
941 lines
41 KiB
Rust
941 lines
41 KiB
Rust
//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects.
|
||
|
||
mod patfile;
|
||
pub use patfile::{PatternChoice, pattern_choices};
|
||
pub(super) use patfile::{lines_for, run_patterns};
|
||
|
||
use cadcraft_color::Color;
|
||
use cadcraft_doc::{Drawing, Entity, EntityKind, Gradient, Handle, Hatch, HatchLoop, Space, entity_bounds};
|
||
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::helpers::lwpoly;
|
||
use super::*;
|
||
|
||
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||
|
||
mod assoc;
|
||
mod edit;
|
||
mod pick;
|
||
use super::has_doc;
|
||
pub(crate) use assoc::update as refill_associative;
|
||
pub use pick::{HatchPicks, PickMode};
|
||
|
||
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) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\" (built in, or defined by patternFile | patternText), patternFile?: path to a .pat file | patternText?: its contents (loads the patterns it defines), scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
|
||
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
|
||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], addPoints?: [[x,y]], addBoundary?: [hex], removeLoops?: [zero-based index] (geometry edits require one hatch), style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||
CommandSpec::new("hatchpick", "Hatch Pick", pick::run_hatchpick)
|
||
.params("{points?: [[x,y]] (internal points), handles?: [hex] (closed objects)} (typed: Points, Objects, oRigin or Hatch, then the picks): the Hatch and Gradient dialog's picks, kept in the session; changes nothing")
|
||
.enabled(has_doc)
|
||
.noundo()
|
||
.interactive(|_| Ok(Box::new(pick::HatchPickM::default()))),
|
||
]
|
||
}
|
||
|
||
/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`.
|
||
pub(super) fn outline_tol(ext: Bounds2) -> f64 {
|
||
(ext.width() + ext.height()).max(1e-9) / 20000.0
|
||
}
|
||
|
||
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each
|
||
/// with the handle of the object it came from.
|
||
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> (Vec<Vec<Vec2>>, Vec<Handle>) {
|
||
let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) };
|
||
let ext = d.extents(space);
|
||
let tol = outline_tol(ext);
|
||
let mut out = Vec::new();
|
||
let mut owners = Vec::new();
|
||
for e in store.iter() {
|
||
if !d.is_visible(e) || matches!(e.kind, EntityKind::Hatch(_) | EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Dimension(_)) {
|
||
continue;
|
||
}
|
||
if let Some(n) = near
|
||
&& !matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_))
|
||
&& !entity_bounds(d, e, 0).intersects(&n)
|
||
{
|
||
continue;
|
||
}
|
||
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 {
|
||
EntityKind::Circle(c) => {
|
||
let ctr = c.center.xy();
|
||
Some(vec![PolyVertex::with_bulge(ctr - Vec2::X * c.radius, 1.0), PolyVertex::with_bulge(ctr + Vec2::X * c.radius, 1.0)])
|
||
}
|
||
EntityKind::LwPolyline(p) if p.closed || p.vertices.first().zip(p.vertices.last()).is_some_and(|(a, b)| a.p.near(b.p, 1e-9)) => {
|
||
Some(p.vertices.clone())
|
||
}
|
||
EntityKind::Ellipse(el) => {
|
||
let ge = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||
if !ge.is_full() {
|
||
return None;
|
||
}
|
||
let mut pts = Vec::new();
|
||
ge.tessellate(el.major.xy().len() * 1e-3, &mut pts);
|
||
pts.pop();
|
||
Some(pts.into_iter().map(PolyVertex::new).collect())
|
||
}
|
||
EntityKind::Spline(sp) if sp.closed => Some(sp.tessellate(1e-3).into_iter().map(PolyVertex::new).collect()),
|
||
_ => {
|
||
let _ = d;
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Open curves that can make up an island together (a square drawn as four LINEs, two ARCs
|
||
/// forming a circle, the inner lines of multiline walls around a room). Closed objects are
|
||
/// islands on their own (see `closed_loop`).
|
||
fn island_curve(d: &Drawing, e: &Entity) -> bool {
|
||
matches!(
|
||
e.kind,
|
||
EntityKind::Line(_)
|
||
| EntityKind::Arc(_)
|
||
| EntityKind::Ellipse(_)
|
||
| EntityKind::LwPolyline(_)
|
||
| EntityKind::Polyline3d(_)
|
||
| EntityKind::Spline(_)
|
||
| EntityKind::MLine(_)
|
||
) && closed_loop(d, e).is_none()
|
||
}
|
||
|
||
/// HPGAPTOL: the widest gap between curve ends that still closes a boundary (0–5000).
|
||
fn gap_tolerance(d: &Drawing) -> f64 {
|
||
let g = d.header.f64("HPGAPTOL", 0.0);
|
||
if g.is_finite() { g.clamp(0.0, 5000.0) } else { 0.0 }
|
||
}
|
||
|
||
/// Where a pattern starts (HPORIGINMODE): the stored origin, or a corner or the centre of the
|
||
/// hatch's extents.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub(crate) enum OriginMode {
|
||
#[default]
|
||
Current,
|
||
BottomLeft,
|
||
BottomRight,
|
||
TopRight,
|
||
TopLeft,
|
||
Center,
|
||
}
|
||
|
||
impl OriginMode {
|
||
pub(crate) fn from_index(i: i64) -> Self {
|
||
match i {
|
||
1 => OriginMode::BottomLeft,
|
||
2 => OriginMode::BottomRight,
|
||
3 => OriginMode::TopRight,
|
||
4 => OriginMode::TopLeft,
|
||
5 => OriginMode::Center,
|
||
_ => OriginMode::Current,
|
||
}
|
||
}
|
||
pub(crate) fn index(self) -> i64 {
|
||
self as i64
|
||
}
|
||
pub(crate) fn parse(t: &str) -> Option<Self> {
|
||
Some(match t.trim().to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||
"current" | "usecurrent" => OriginMode::Current,
|
||
"bottomleft" => OriginMode::BottomLeft,
|
||
"bottomright" => OriginMode::BottomRight,
|
||
"topright" => OriginMode::TopRight,
|
||
"topleft" => OriginMode::TopLeft,
|
||
"center" | "centre" => OriginMode::Center,
|
||
_ => return None,
|
||
})
|
||
}
|
||
/// The origin for loops: `current` when the mode keeps it.
|
||
pub(crate) fn origin(self, loops: &[HatchLoop], current: Vec2) -> Vec2 {
|
||
let b = Bounds2::from_points(loops.iter().flat_map(|l| l.vertices.iter().map(|v| v.p)).filter(|p| p.is_finite()));
|
||
if b.is_empty() {
|
||
return current;
|
||
}
|
||
match self {
|
||
OriginMode::Current => current,
|
||
OriginMode::BottomLeft => b.min,
|
||
OriginMode::BottomRight => Vec2::new(b.max.x, b.min.y),
|
||
OriginMode::TopRight => b.max,
|
||
OriginMode::TopLeft => Vec2::new(b.min.x, b.max.y),
|
||
OriginMode::Center => b.center(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Island detection style by name (HPISLANDDETECTION 0 normal, 1 outer, 2 ignore).
|
||
pub(crate) fn island_style(t: &str) -> Option<u8> {
|
||
match t.trim().to_ascii_lowercase().as_str() {
|
||
"normal" | "n" | "0" => Some(0),
|
||
"outer" | "o" | "1" => Some(1),
|
||
"ignore" | "i" | "2" => Some(2),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Boundary loops (outer + islands) around an internal point.
|
||
pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
||
let d = s.doc()?;
|
||
let space = s.space();
|
||
let ext = d.extents(&space);
|
||
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
|
||
let (polys, owners) = outlines(d, &space, None);
|
||
// A line that ends on a flattened curve (an ellipse) can stop short of its chords.
|
||
let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol);
|
||
let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d))
|
||
.ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
|
||
let outer = found.outer;
|
||
let ob = Bounds2::from_points(outer.iter().copied());
|
||
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() {
|
||
if !d.is_visible(e) {
|
||
continue;
|
||
}
|
||
let eb = entity_bounds(d, e, 0);
|
||
if !ob.contains_box(&eb) {
|
||
continue;
|
||
}
|
||
if let Some(vs) = closed_loop(d, e) {
|
||
let pts = Polyline { vertices: vs.clone(), closed: true }.tessellate(tol.max(eb.width() * 1e-3));
|
||
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, sources: vec![e.handle] });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Islands made of separate open curves (four LINEs around a square). Groups that include a
|
||
// closed object were handled above, object by object.
|
||
for island in found.islands {
|
||
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 {
|
||
let sources = handles_of(&owners, &island.sources);
|
||
loops.push(HatchLoop { vertices: island.outline.into_iter().map(PolyVertex::new).collect(), outer: false, sources });
|
||
}
|
||
}
|
||
Ok(loops)
|
||
}
|
||
|
||
pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result<Vec<HatchLoop>> {
|
||
let d = s.doc()?;
|
||
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(), sources: vec![*h] });
|
||
}
|
||
}
|
||
if loops.is_empty() {
|
||
return Err(EngineError::Other("No closed boundary objects selected.".into()));
|
||
}
|
||
Ok(loops)
|
||
}
|
||
|
||
/// Gradient fills by name: the shapes the renderer draws (`render/src/hatch.rs`); the INV forms
|
||
/// swap the two colours.
|
||
pub(crate) const GRADIENT_NAMES: [&str; 10] =
|
||
["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL", "INVLINEAR", "INVCYLINDER", "INVCURVED", "INVSPHERICAL", "INVHEMISPHERICAL"];
|
||
|
||
/// A gradient name as stored (upper case), or None when it isn't one of [`GRADIENT_NAMES`].
|
||
pub(crate) fn gradient_name(t: &str) -> Option<&'static str> {
|
||
let up = t.trim().to_ascii_uppercase();
|
||
GRADIENT_NAMES.iter().copied().find(|n| *n == up)
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct HatchSettings {
|
||
pattern: String,
|
||
scale: f64,
|
||
angle: f64,
|
||
gradient: Option<Gradient>,
|
||
associative: bool,
|
||
/// HPSEPARATE: one hatch per picked point or selected object.
|
||
separate: bool,
|
||
origin: Vec2,
|
||
origin_mode: OriginMode,
|
||
style: u8,
|
||
}
|
||
|
||
impl HatchSettings {
|
||
fn from(s: &Session, p: &Value, gradient: bool) -> Self {
|
||
let hdr = s.doc().map(|d| (d.header.str("HPNAME", "ANSI31"), d.header.f64("HPSCALE", 1.0), d.header.f64("HPANG", 0.0))).unwrap_or((
|
||
"ANSI31".into(),
|
||
1.0,
|
||
0.0,
|
||
));
|
||
let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0);
|
||
let g = gradient.then(|| Gradient {
|
||
name: str_param(p, "name").and_then(gradient_name).unwrap_or("LINEAR").to_string(),
|
||
color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)),
|
||
color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)),
|
||
angle: f64_or(p, "angle", 0.0).to_radians(),
|
||
centered: bool_or(p, "centered", true),
|
||
});
|
||
HatchSettings {
|
||
pattern,
|
||
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", s.doc().map_or(true, |d| d.header.i64("HPASSOC", 1) != 0)),
|
||
separate: bool_or(p, "separate", s.doc().is_ok_and(|d| d.header.i64("HPSEPARATE", 0) != 0)),
|
||
origin: point_param(p, "origin").filter(|o| o.is_finite()).unwrap_or_else(|| {
|
||
s.doc().ok().and_then(|d| d.header.point("HPORIGIN")).map(|o| o.xy()).filter(|o| o.is_finite()).unwrap_or(Vec2::ZERO)
|
||
}),
|
||
origin_mode: if point_param(p, "origin").is_some() {
|
||
OriginMode::Current
|
||
} else {
|
||
str_param(p, "originMode")
|
||
.and_then(OriginMode::parse)
|
||
.unwrap_or_else(|| OriginMode::from_index(s.doc().map_or(0, |d| d.header.i64("HPORIGINMODE", 0))))
|
||
},
|
||
style: str_param(p, "style")
|
||
.and_then(island_style)
|
||
.unwrap_or_else(|| s.doc().map_or(0, |d| d.header.i64("HPISLANDDETECTION", 0).clamp(0, 2) as u8)),
|
||
}
|
||
}
|
||
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() },
|
||
solid,
|
||
origin: self.origin_mode.origin(&loops, self.origin),
|
||
loops,
|
||
scale: self.scale,
|
||
angle: self.angle,
|
||
associative: self.associative,
|
||
style: self.style,
|
||
elevation: 0.0,
|
||
gradient: self.gradient.clone(),
|
||
background: None,
|
||
pattern_lines: patfile::lines_for(&self.pattern),
|
||
})
|
||
}
|
||
}
|
||
|
||
fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||
// A zero or negative pattern scale would be clamped to a near-zero spacing.
|
||
size_param(if gradient { "gradient" } else { "hatch" }, p, "scale", false)?;
|
||
let cmd = if gradient { "gradient" } else { "hatch" };
|
||
if str_param(p, "style").is_some_and(|t| island_style(t).is_none()) {
|
||
return Err(bad(cmd, "`style` is \"normal\", \"outer\" or \"ignore\""));
|
||
}
|
||
if str_param(p, "originMode").is_some_and(|t| OriginMode::parse(t).is_none()) {
|
||
return Err(bad(cmd, "unknown `originMode`"));
|
||
}
|
||
if gradient && str_param(p, "name").is_some_and(|t| gradient_name(t).is_none()) {
|
||
return Err(bad(cmd, format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))));
|
||
}
|
||
let st = HatchSettings::from(s, p, gradient);
|
||
// Loops by picked point or selected object: one hatch each when separate.
|
||
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||
// Internal points and boundary objects can come together (the dialog's Pick points and
|
||
// Select objects); points alone don't fall back to the selection.
|
||
let pts = points_param(p, "points");
|
||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||
let objects = pts.is_none() || given("handles") || given("handle");
|
||
for q in pts.into_iter().flatten() {
|
||
groups.push(loops_at(s, q)?);
|
||
}
|
||
if objects {
|
||
let hs = targets(s, p)?;
|
||
if st.separate {
|
||
groups.extend(loops_from_handles(s, &hs)?.into_iter().map(|l| vec![HatchLoop { outer: true, ..l }]));
|
||
} else {
|
||
groups.push(loops_from_handles(s, &hs)?);
|
||
}
|
||
}
|
||
if groups.iter().all(Vec::is_empty) {
|
||
return Err(bad("hatch", "give internal `points` or boundary `handles`"));
|
||
}
|
||
let hs = add_hatches(s, &st, groups)?;
|
||
let hex: Vec<String> = hs.iter().map(|h| h.hex()).collect();
|
||
Ok(json!({ "handle": hex.first(), "handles": hex }))
|
||
}
|
||
|
||
/// True when two loops have the same vertex sequence (ignoring cyclic shift and winding order).
|
||
fn same_loop(a: &HatchLoop, b: &HatchLoop) -> bool {
|
||
if a.vertices.len() != b.vertices.len() {
|
||
return false;
|
||
}
|
||
let n = a.vertices.len();
|
||
if n == 0 {
|
||
return true;
|
||
}
|
||
let near = |v1: &PolyVertex, v2: &PolyVertex| v1.p.near(v2.p, 1e-6) && (v1.bulge - v2.bulge).abs() < 1e-6;
|
||
(0..n).any(|k| (0..n).all(|i| near(&a.vertices[i], &b.vertices[(i + k) % n])))
|
||
|| (0..n).any(|k| {
|
||
(0..n).all(|i| {
|
||
let j = (k + n - (i % n)) % n;
|
||
let prev_j = (j + n - 1) % n;
|
||
a.vertices[i].p.near(b.vertices[j].p, 1e-6) && (a.vertices[i].bulge + b.vertices[prev_j].bulge).abs() < 1e-6
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Deduplicate boundary loops that describe the same boundary.
|
||
fn dedup_loops(loops: &mut Vec<HatchLoop>) {
|
||
let mut unique: Vec<HatchLoop> = Vec::with_capacity(loops.len());
|
||
for l in loops.drain(..) {
|
||
if let Some(existing) = unique.iter_mut().find(|u| same_loop(u, &l)) {
|
||
for s in l.sources {
|
||
if !existing.sources.contains(&s) {
|
||
existing.sources.push(s);
|
||
}
|
||
}
|
||
existing.outer = existing.outer || l.outer;
|
||
} else {
|
||
unique.push(l);
|
||
}
|
||
}
|
||
*loops = unique;
|
||
}
|
||
|
||
/// Add the hatches for groups of loops: one per group when separate, otherwise one for all.
|
||
fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>) -> Result<Vec<Handle>> {
|
||
let groups: Vec<Vec<HatchLoop>> = if st.separate {
|
||
let mut unique_groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||
for mut g in groups {
|
||
dedup_loops(&mut g);
|
||
if g.is_empty() {
|
||
continue;
|
||
}
|
||
if !unique_groups.iter().any(|u| u.len() == g.len() && u.iter().zip(&g).all(|(a, b)| same_loop(a, b))) {
|
||
unique_groups.push(g);
|
||
}
|
||
}
|
||
unique_groups
|
||
} else {
|
||
let mut all: Vec<HatchLoop> = groups.into_iter().flatten().collect();
|
||
dedup_loops(&mut all);
|
||
vec![all]
|
||
};
|
||
let mut out = Vec::new();
|
||
for loops in groups.into_iter().filter(|g| !g.is_empty()) {
|
||
let h = s.add_entity(st.hatch(loops))?;
|
||
// Hatches go under other objects (HPDRAWORDER = behind boundary).
|
||
let space = s.space();
|
||
if let Some(store) = s.doc_mut()?.space_mut(&space) {
|
||
store.send_to_back(h);
|
||
}
|
||
out.push(h);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||
patfile::load_param(p)?;
|
||
// Like the prompt: a name without a definition would be drawn as ANSI31.
|
||
if let Some(up) = str_param(p, "pattern").map(str::to_ascii_uppercase)
|
||
&& cadcraft_doc::library::pattern(&up).is_none()
|
||
&& patfile::user_pattern(&up).is_none()
|
||
{
|
||
return Err(bad("hatch", format!("unknown pattern {up}: not built in; load it with patternFile or patternText (a .pat file)")));
|
||
}
|
||
make(s, p, false)
|
||
}
|
||
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||
make(s, p, true)
|
||
}
|
||
|
||
fn boundary_at(s: &mut Session, q: Vec2) -> Result<Vec<Handle>> {
|
||
let loops = loops_at(s, q)?;
|
||
let mut out = Vec::new();
|
||
for l in loops {
|
||
out.push(s.add_entity(lwpoly(l.vertices, true))?);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Most internal points one BOUNDARY call accepts (checked before the list is copied).
|
||
const MAX_BOUNDARY_POINTS: usize = 256;
|
||
/// Most polylines one BOUNDARY call may create. Each pick adds polylines the next pick traces
|
||
/// against, so this bounds the aggregate tracing work of the batch.
|
||
const MAX_BOUNDARY_OUTPUT: usize = 1024;
|
||
|
||
fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||
if raw.len() > MAX_BOUNDARY_POINTS {
|
||
return Err(bad("boundary", format!("at most {MAX_BOUNDARY_POINTS} points per call (got {})", raw.len())));
|
||
}
|
||
let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||
let before = s.state()?.doc.clone();
|
||
let mut out = Vec::new();
|
||
for q in pts {
|
||
out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex()));
|
||
if out.len() > MAX_BOUNDARY_OUTPUT {
|
||
// The refused call leaves no partial result behind.
|
||
s.state_mut()?.doc = before;
|
||
return Err(bad("boundary", format!("more than {MAX_BOUNDARY_OUTPUT} boundary polylines in one call; split the work")));
|
||
}
|
||
}
|
||
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
|
||
}
|
||
|
||
/// REGION: each closed object becomes a region bounded by it (and replaces it).
|
||
fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let hs = targets(s, p)?;
|
||
let mut n = 0;
|
||
for h in hs {
|
||
let vs = {
|
||
let d = s.doc()?;
|
||
d.entity(h).and_then(|e| closed_loop(d, e).map(|v| (v, e.common.clone())))
|
||
};
|
||
if let Some((vs, common)) = vs {
|
||
let d = s.doc_mut()?;
|
||
d.modify_entity(h, |e| {
|
||
e.kind = EntityKind::Region(cadcraft_doc::Region::from_loop(vs));
|
||
e.common = common;
|
||
})?;
|
||
n += 1;
|
||
}
|
||
}
|
||
Ok(json!({ "message": format!("{n} Region(s) created.") }))
|
||
}
|
||
|
||
/// The origin sub-prompt's keywords (a point sets a new origin).
|
||
const ORIGIN_KW: [&str; 6] = ["Bottom left", "bottom Right", "Top left", "top riGht", "Center", "Use current"];
|
||
|
||
/// What a HATCH sub-prompt asks for.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum HatchAsk {
|
||
Pattern,
|
||
Scale,
|
||
Angle,
|
||
Origin,
|
||
}
|
||
|
||
struct HatchM {
|
||
gradient: bool,
|
||
settings: HatchSettings,
|
||
/// Loops by pick (one hatch each when separate).
|
||
loops: Vec<Vec<HatchLoop>>,
|
||
asking: Option<HatchAsk>,
|
||
/// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again.
|
||
selecting: bool,
|
||
}
|
||
|
||
impl HatchM {
|
||
fn new(s: &Session, gradient: bool) -> Self {
|
||
HatchM {
|
||
gradient,
|
||
settings: HatchSettings::from(s, &Value::Null, gradient),
|
||
loops: Vec::new(),
|
||
asking: None,
|
||
selecting: s.last_used.hatch_select,
|
||
}
|
||
}
|
||
fn set_mode(&mut self, s: &mut Session, selecting: bool) {
|
||
self.selecting = selecting;
|
||
s.last_used.hatch_select = selecting;
|
||
}
|
||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||
add_hatches(s, &self.settings, std::mem::take(&mut self.loops))?;
|
||
Ok(Step::Done)
|
||
}
|
||
fn set_origin(&mut self, s: &mut Session, origin: Option<Vec2>, mode: OriginMode) -> Result<()> {
|
||
let h = &mut s.doc_mut()?.header;
|
||
if let Some(o) = origin.filter(|o| o.is_finite()) {
|
||
self.settings.origin = o;
|
||
h.set("HPORIGIN", cadcraft_doc::HVal::Point(o.to3(0.0)));
|
||
}
|
||
self.settings.origin_mode = mode;
|
||
h.set_i64("HPORIGINMODE", mode.index());
|
||
Ok(())
|
||
}
|
||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> {
|
||
match a {
|
||
HatchAsk::Pattern => {
|
||
let up = match patfile::typed_file(s, t.trim()) {
|
||
Some(n) if n.is_empty() => return Ok(()),
|
||
Some(n) => n,
|
||
None => t.trim().to_ascii_uppercase(),
|
||
};
|
||
if cadcraft_doc::library::pattern(&up).is_some() || patfile::user_pattern(&up).is_some() {
|
||
self.settings.pattern = up.clone();
|
||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||
} else {
|
||
s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", ")));
|
||
}
|
||
}
|
||
HatchAsk::Scale => {
|
||
if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||
self.settings.scale = v;
|
||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||
}
|
||
}
|
||
HatchAsk::Angle => {
|
||
// Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1.
|
||
if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) {
|
||
self.settings.angle = a;
|
||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||
}
|
||
}
|
||
HatchAsk::Origin => {
|
||
let mode = match t {
|
||
"Bottom left" => OriginMode::BottomLeft,
|
||
"bottom Right" => OriginMode::BottomRight,
|
||
"Top left" => OriginMode::TopLeft,
|
||
"top riGht" => OriginMode::TopRight,
|
||
"Center" => OriginMode::Center,
|
||
_ => OriginMode::Current,
|
||
};
|
||
self.set_origin(s, None, mode)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Interactive for HatchM {
|
||
fn name(&self) -> &'static str {
|
||
if self.gradient { "GRADIENT" } else { "HATCH" }
|
||
}
|
||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||
s.echo(format!(
|
||
"Current pattern: {} Scale: {:.4} Angle: {:.0}",
|
||
self.settings.pattern,
|
||
self.settings.scale,
|
||
self.settings.angle.to_degrees()
|
||
));
|
||
Ok(Step::Continue)
|
||
}
|
||
fn prompt(&self, s: &Session) -> Prompt {
|
||
// The sub-prompts offer the current value: Enter keeps it.
|
||
match self.asking {
|
||
Some(HatchAsk::Pattern) => {
|
||
return Prompt::new("Enter a pattern name", Accept::TEXT)
|
||
.default(self.settings.pattern.clone())
|
||
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
|
||
}
|
||
Some(HatchAsk::Scale) => {
|
||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||
}
|
||
Some(HatchAsk::Angle) => {
|
||
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
||
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
|
||
let a = if s.angle_settings().clockwise { -self.settings.angle } else { self.settings.angle };
|
||
let d = crate::units::format_angle(a, au, ap);
|
||
return Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER).default(d);
|
||
}
|
||
Some(HatchAsk::Origin) => return Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"),
|
||
None => {}
|
||
}
|
||
// The pattern picker stays offered while boundaries are picked: a choice sets the pattern.
|
||
let picker = |p: Prompt| if self.gradient { p } else { p.picker(Picker::HatchPattern(self.settings.pattern.clone())) };
|
||
if self.selecting {
|
||
return picker(Prompt::new("Select objects", Accept::SELECT).kw(&[
|
||
"picK internal point",
|
||
"Undo",
|
||
"seTtings",
|
||
"sCale",
|
||
"Angle",
|
||
"Origin",
|
||
"sEparate",
|
||
]));
|
||
}
|
||
picker(Prompt::new("Pick internal point", Accept::POINT).kw(&[
|
||
"Select objects",
|
||
"Undo",
|
||
"seTtings",
|
||
"Pattern",
|
||
"sCale",
|
||
"Angle",
|
||
"Origin",
|
||
"sEparate",
|
||
]))
|
||
}
|
||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||
if let Some(a) = self.asking.take() {
|
||
match &i {
|
||
Input::Point(p) if a == HatchAsk::Origin => self.set_origin(s, Some(*p), OriginMode::Current)?,
|
||
Input::Text(t) | Input::Keyword(t) => self.answer(s, a, t)?,
|
||
_ => {}
|
||
}
|
||
return Ok(Step::Continue);
|
||
}
|
||
match i {
|
||
Input::Point(p) if !self.selecting => {
|
||
match loops_at(s, p) {
|
||
Ok(l) => {
|
||
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
|
||
self.loops.push(l);
|
||
}
|
||
Err(e) => s.echo(e.to_string()),
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
Input::Pick(hs) => {
|
||
match loops_from_handles(s, &hs) {
|
||
Ok(l) => {
|
||
s.echo(format!("{} found", hs.len()));
|
||
if self.settings.separate {
|
||
self.loops.extend(l.into_iter().map(|l| vec![HatchLoop { outer: true, ..l }]));
|
||
} else {
|
||
self.loops.push(l);
|
||
}
|
||
}
|
||
Err(e) => s.echo(e.to_string()),
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
Input::Keyword(k) => {
|
||
match k.as_str() {
|
||
"Select objects" => self.set_mode(s, true),
|
||
"picK internal point" => self.set_mode(s, false),
|
||
"Undo" => self.loops.clear(),
|
||
"Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern),
|
||
"sCale" => self.asking = Some(HatchAsk::Scale),
|
||
"Angle" => self.asking = Some(HatchAsk::Angle),
|
||
"Origin" => self.asking = Some(HatchAsk::Origin),
|
||
"sEparate" => {
|
||
self.settings.separate = !self.settings.separate;
|
||
s.doc_mut()?.header.set_i64("HPSEPARATE", i64::from(self.settings.separate));
|
||
s.echo(if self.settings.separate { "Separate hatches: on" } else { "Separate hatches: off" });
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
// A pattern chosen in the picker while boundaries are picked.
|
||
Input::Text(t) if !self.gradient => {
|
||
self.answer(s, HatchAsk::Pattern, &t)?;
|
||
Ok(Step::Continue)
|
||
}
|
||
// Enter ends boundary picking in either mode and creates the hatch.
|
||
Input::Enter => self.finish(s),
|
||
_ => Ok(Step::Continue),
|
||
}
|
||
}
|
||
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
|
||
if self.loops.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
vec![self.settings.hatch(self.loops.iter().flatten().cloned().collect())]
|
||
}
|
||
}
|
||
|
||
struct BoundaryM {
|
||
region: bool,
|
||
}
|
||
|
||
impl Interactive for BoundaryM {
|
||
fn name(&self) -> &'static str {
|
||
if self.region { "REGION" } else { "BOUNDARY" }
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
Prompt::new("Pick internal point", Accept::POINT)
|
||
}
|
||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||
match i {
|
||
Input::Point(p) => {
|
||
match boundary_at(s, p) {
|
||
Ok(hs) => s.echo(format!("BOUNDARY created {} polyline(s)", hs.len())),
|
||
Err(e) => s.echo(e.to_string()),
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
Input::Enter => Ok(Step::Done),
|
||
_ => Ok(Step::Continue),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
|
||
use super::*;
|
||
|
||
/// The drawing's only hatch and its handle (hatches go to the back of the draw order, so not the last entity).
|
||
fn only_hatch(s: &Session) -> (String, Hatch) {
|
||
let d = s.doc().unwrap();
|
||
d.model.iter().find_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((e.handle.hex(), h.clone())) } else { None }).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn hatchedit_pattern_replaces_gradient() {
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("gradient", &json!({ "points": [[5, 5]], "color1": "1", "color2": "3" })).unwrap();
|
||
let (h, hatch) = only_hatch(&s);
|
||
assert!(hatch.gradient.is_some());
|
||
// Scale-only and angle-only edits keep the gradient.
|
||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "scale": 2, "angle": 30 })).unwrap();
|
||
assert!(only_hatch(&s).1.gradient.is_some());
|
||
s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31" })).unwrap();
|
||
let hatch = only_hatch(&s).1;
|
||
assert_eq!(hatch.pattern, "ANSI31");
|
||
assert!(!hatch.solid);
|
||
assert!(hatch.gradient.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn hatchedit_solid_replaces_gradient() {
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("gradient", &json!({ "points": [[5, 5]], "color1": "1", "color2": "3" })).unwrap();
|
||
let (h, before) = only_hatch(&s);
|
||
assert!(before.gradient.is_some());
|
||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "SOLID" })).unwrap();
|
||
let (h2, hatch) = only_hatch(&s);
|
||
assert_eq!(h2, h);
|
||
assert!(hatch.solid);
|
||
assert!(hatch.gradient.is_none(), "SOLID is a plain fill, not the gradient");
|
||
assert_eq!(hatch.loops, before.loops);
|
||
}
|
||
|
||
#[test]
|
||
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.start("hatch").unwrap();
|
||
let picker = s.current_prompt().and_then(|p| p.picker);
|
||
assert_eq!(picker, Some(Picker::HatchPattern(HatchSettings::from(&s, &Value::Null, false).pattern)));
|
||
// A choice made in the picker while boundaries are picked.
|
||
s.input(Input::Text("AR-CONC".into())).unwrap();
|
||
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("AR-CONC".into())));
|
||
// And at the pattern prompt.
|
||
s.input(Input::Keyword("Pattern".into())).unwrap();
|
||
assert!(s.current_prompt().unwrap().picker.is_some());
|
||
s.input(Input::Text("BRICK".into())).unwrap();
|
||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||
s.input(Input::Enter).unwrap();
|
||
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
|
||
// An unknown name keeps the pattern.
|
||
s.start("hatch").unwrap();
|
||
s.input(Input::Text("NO-SUCH".into())).unwrap();
|
||
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("BRICK".into())));
|
||
// GRADIENT offers no pattern picker.
|
||
s.cancel();
|
||
s.start("gradient").unwrap();
|
||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "GRAVEL" })).unwrap_err();
|
||
assert!(e.to_string().contains("patternFile"), "{e}");
|
||
let spec = specs().into_iter().find(|c| c.id == "hatch").unwrap();
|
||
assert!(spec.params.contains("patternFile") && spec.params.contains("patternText"), "{}", spec.params);
|
||
}
|
||
|
||
#[test]
|
||
fn json_hatch_refuses_an_undefined_pattern() {
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "NOPE" })).unwrap_err();
|
||
assert!(e.to_string().contains("unknown pattern NOPE"), "{e}");
|
||
assert_eq!(s.doc().unwrap().model.len(), 1, "only the rectangle");
|
||
for name in ["ansi31", "SOLID"] {
|
||
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": name })).unwrap();
|
||
}
|
||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn pattern_choices_list_the_library_with_its_lines() {
|
||
let all = pattern_choices();
|
||
assert_eq!(all.first().map(|c| c.name.as_str()), Some("SOLID"));
|
||
assert!(all[0].lines.is_empty());
|
||
let ansi31 = all.iter().find(|c| c.name == "ANSI31").unwrap();
|
||
assert_eq!(ansi31.lines.len(), 1);
|
||
assert!(!ansi31.description.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn repeated_picks_and_handles_in_one_region_do_not_produce_duplicate_loops() {
|
||
// One internal point
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap();
|
||
let (_, h1) = only_hatch(&s);
|
||
assert_eq!(h1.loops.len(), 1);
|
||
|
||
// Two different points in the same rectangle
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("hatch", &json!({ "points": [[2, 2], [8, 8]], "pattern": "SOLID" })).unwrap();
|
||
let (_, h2) = only_hatch(&s);
|
||
assert_eq!(h2.loops.len(), 1, "two points in same region must deduplicate loops");
|
||
|
||
// The same point twice
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("hatch", &json!({ "points": [[5, 5], [5, 5]], "pattern": "SOLID" })).unwrap();
|
||
let (_, h3) = only_hatch(&s);
|
||
assert_eq!(h3.loops.len(), 1, "identical points must deduplicate loops");
|
||
|
||
// Three points in the same rectangle
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("hatch", &json!({ "points": [[2, 2], [5, 5], [8, 8]], "pattern": "SOLID" })).unwrap();
|
||
let (_, h4) = only_hatch(&s);
|
||
assert_eq!(h4.loops.len(), 1);
|
||
|
||
// Same boundary handle twice
|
||
let mut s = Session::new();
|
||
let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
let handle = r["handle"].as_str().unwrap();
|
||
s.execute("hatch", &json!({ "handles": [handle, handle], "pattern": "SOLID" })).unwrap();
|
||
let (_, h5) = only_hatch(&s);
|
||
assert_eq!(h5.loops.len(), 1, "repeated boundary handle must deduplicate loops");
|
||
|
||
// Two points in disjoint rectangles: both regions preserved
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap();
|
||
s.execute("hatch", &json!({ "points": [[5, 5], [25, 5]], "pattern": "SOLID" })).unwrap();
|
||
let (_, h6) = only_hatch(&s);
|
||
assert_eq!(h6.loops.len(), 2, "disjoint rectangles must both be kept");
|
||
|
||
// Gradient with duplicate points in the same rectangle
|
||
let mut s = Session::new();
|
||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||
s.execute("gradient", &json!({ "points": [[2, 2], [8, 8]], "color1": "1", "color2": "3" })).unwrap();
|
||
let (_, hg) = only_hatch(&s);
|
||
assert_eq!(hg.loops.len(), 1, "gradient with repeated picks in same region must deduplicate loops");
|
||
}
|
||
}
|