mirror of
https://github.com/aphrody-labs/cadcraft.git
synced 2026-10-14 03:29:06 +00:00
Hatch: HPGAPTOL, pattern origin, separate hatches, island style; HATCHEDIT options and -HATCHEDIT (fixes #451) (#457)
Squash of PR #457 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd2a7597c5
commit
d6aa802c75
6 files changed
+911
-53
No files matched your search
+195
-39
@@ -10,15 +10,16 @@ use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod assoc;
|
||||
mod edit;
|
||||
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)))),
|
||||
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? (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? | handles?, color1?, color2?, angle?}").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", run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he"]).params("{handles?, pattern?, scale?, angle?}"),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -114,6 +115,77 @@ fn island_curve(d: &Drawing, e: &Entity) -> bool {
|
||||
) && 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()?;
|
||||
@@ -121,7 +193,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
||||
let ext = d.extents(&space);
|
||||
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
|
||||
let (polys, owners) = outlines(d, &space, None);
|
||||
let found = cadcraft_geom::region::boundary_at(&polys, p, tol).ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
|
||||
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);
|
||||
@@ -180,6 +253,11 @@ struct HatchSettings {
|
||||
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 {
|
||||
@@ -203,6 +281,20 @@ impl HatchSettings {
|
||||
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 {
|
||||
@@ -213,14 +305,14 @@ impl HatchSettings {
|
||||
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: 0,
|
||||
style: self.style,
|
||||
elevation: 0.0,
|
||||
gradient: self.gradient.clone(),
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
})
|
||||
@@ -230,26 +322,50 @@ impl HatchSettings {
|
||||
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`"));
|
||||
}
|
||||
let st = HatchSettings::from(s, p, gradient);
|
||||
let mut loops = Vec::new();
|
||||
// Loops by picked point or selected object: one hatch each when separate.
|
||||
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||||
if let Some(pts) = points_param(p, "points") {
|
||||
for q in pts {
|
||||
loops.extend(loops_at(s, q)?);
|
||||
groups.push(loops_at(s, q)?);
|
||||
}
|
||||
} else {
|
||||
let hs = targets(s, p)?;
|
||||
loops = loops_from_handles(s, &hs)?;
|
||||
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 loops.is_empty() {
|
||||
if groups.iter().all(Vec::is_empty) {
|
||||
return Err(bad("hatch", "give internal `points` or boundary `handles`"));
|
||||
}
|
||||
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);
|
||||
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 }))
|
||||
}
|
||||
|
||||
/// 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 { groups } else { vec![groups.into_iter().flatten().collect()] };
|
||||
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(json!({ "handle": h.hex() }))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -298,14 +414,8 @@ fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": format!("{n} Region(s) created.") }))
|
||||
}
|
||||
|
||||
fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
}
|
||||
s.execute("properties.set", &q)
|
||||
}
|
||||
/// 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)]
|
||||
@@ -313,12 +423,14 @@ enum HatchAsk {
|
||||
Pattern,
|
||||
Scale,
|
||||
Angle,
|
||||
Origin,
|
||||
}
|
||||
|
||||
struct HatchM {
|
||||
gradient: bool,
|
||||
settings: HatchSettings,
|
||||
loops: Vec<HatchLoop>,
|
||||
/// 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,
|
||||
@@ -339,16 +451,19 @@ impl HatchM {
|
||||
s.last_used.hatch_select = selecting;
|
||||
}
|
||||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if !self.loops.is_empty() {
|
||||
let k = self.settings.hatch(std::mem::take(&mut self.loops));
|
||||
let h = s.add_entity(k)?;
|
||||
let space = s.space();
|
||||
if let Some(store) = s.doc_mut()?.space_mut(&space) {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
@@ -376,6 +491,17 @@ impl HatchM {
|
||||
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(())
|
||||
}
|
||||
@@ -408,17 +534,37 @@ impl Interactive for HatchM {
|
||||
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 => {}
|
||||
}
|
||||
if self.selecting {
|
||||
return Prompt::new("Select objects", Accept::SELECT).kw(&["picK internal point", "Undo", "seTtings", "sCale", "Angle"]);
|
||||
return Prompt::new("Select objects", Accept::SELECT).kw(&[
|
||||
"picK internal point",
|
||||
"Undo",
|
||||
"seTtings",
|
||||
"sCale",
|
||||
"Angle",
|
||||
"Origin",
|
||||
"sEparate",
|
||||
]);
|
||||
}
|
||||
Prompt::new("Pick internal point", Accept::POINT).kw(&["Select objects", "Undo", "seTtings", "Pattern", "sCale", "Angle"])
|
||||
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() {
|
||||
if let Input::Text(t) | Input::Keyword(t) = &i {
|
||||
self.answer(s, a, t)?;
|
||||
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);
|
||||
}
|
||||
@@ -427,7 +573,7 @@ impl Interactive for HatchM {
|
||||
match loops_at(s, p) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
|
||||
self.loops.extend(l);
|
||||
self.loops.push(l);
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
@@ -437,7 +583,11 @@ impl Interactive for HatchM {
|
||||
match loops_from_handles(s, &hs) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("{} found", hs.len()));
|
||||
self.loops.extend(l);
|
||||
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()),
|
||||
}
|
||||
@@ -451,6 +601,12 @@ impl Interactive for HatchM {
|
||||
"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)
|
||||
@@ -464,7 +620,7 @@ impl Interactive for HatchM {
|
||||
if self.loops.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![self.settings.hatch(self.loops.clone())]
|
||||
vec![self.settings.hatch(self.loops.iter().flatten().cloned().collect())]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ 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};
|
||||
use super::{closed_loop, gap_tolerance, object_polys};
|
||||
|
||||
/// Upper bound on the source objects of one loop that are traced (hostile files).
|
||||
const MAX_SOURCES: usize = 4096;
|
||||
@@ -89,16 +89,17 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
|
||||
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 gap = gap_tolerance(after);
|
||||
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))
|
||||
.and_then(|q| region::enclosing_loop_gap(&polys, map_point(q, &ob, &nb), snap, gap))
|
||||
.or_else(|| region::outline(&polys, snap, gap))
|
||||
} else {
|
||||
region::outline(&polys, snap)
|
||||
region::outline(&polys, snap, gap)
|
||||
};
|
||||
match found {
|
||||
Some(pts) => Refill::Loop(pts.into_iter().map(PolyVertex::new).collect()),
|
||||
@@ -107,7 +108,7 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
|
||||
}
|
||||
|
||||
/// Two loops cover the same area (same extents and area, within a small tolerance).
|
||||
fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool {
|
||||
pub(super) 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()));
|
||||
@@ -119,7 +120,7 @@ fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool {
|
||||
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) {
|
||||
pub(super) fn detach(h: &mut Hatch) {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! HATCHEDIT and -HATCHEDIT: pattern, scale and angle, island style, pattern origin,
|
||||
//! associativity (unlink, or link to boundary objects), separate hatches and recreated
|
||||
//! boundaries of existing hatches.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Hatch, HatchLoop, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::assoc::{detach, same_shape};
|
||||
use super::{ORIGIN_KW, OriginMode, closed_loop, island_style};
|
||||
use crate::cmd::helpers::lwpoly;
|
||||
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on hatches edited by one call.
|
||||
const MAX_EDITED: usize = 10_000;
|
||||
/// Upper bound on loops looked up when linking hatches to boundary objects in one call (each
|
||||
/// lookup scans the hatch's space), and on the loops of a hatch split into separate hatches.
|
||||
const MAX_LOOPS: usize = 2000;
|
||||
|
||||
fn hatches(s: &Session, p: &Value) -> Result<Vec<Handle>> {
|
||||
let hs = targets(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let out: Vec<Handle> = hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).take(MAX_EDITED).collect();
|
||||
if out.is_empty() {
|
||||
return Err(bad("hatchedit", "select one or more hatches"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn outline(l: &HatchLoop) -> Vec<Vec2> {
|
||||
Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(1e-3)
|
||||
}
|
||||
|
||||
/// Link the loops of `h` to closed objects of the same shape: the `boundary` objects when given,
|
||||
/// otherwise any in the hatch's space. Returns the number of loops left without an object.
|
||||
fn link(d: &Drawing, handle: Handle, h: &mut Hatch, boundary: Option<&[Handle]>, budget: &mut usize) -> usize {
|
||||
let space = d.space_of(handle);
|
||||
let store = space.as_ref().and_then(|sp| d.space(sp));
|
||||
let mut missing = 0;
|
||||
for l in &mut h.loops {
|
||||
if boundary.is_none() && !l.sources.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if *budget == 0 {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
*budget -= 1;
|
||||
let lb = Bounds2::from_points(outline(l));
|
||||
let eps = 1e-4 * (1.0 + lb.width() + lb.height());
|
||||
let fits = |e: &cadcraft_doc::Entity| {
|
||||
let eb = entity_bounds(d, e, 0);
|
||||
e.handle != handle
|
||||
&& eb.min.dist(lb.min) <= eps
|
||||
&& eb.max.dist(lb.max) <= eps
|
||||
&& closed_loop(d, e).is_some_and(|vs| same_shape(&vs, &l.vertices))
|
||||
};
|
||||
let found = match boundary {
|
||||
Some(hs) => hs.iter().filter_map(|h| d.entity(*h)).find(|e| fits(e)).map(|e| e.handle),
|
||||
None => store.and_then(|st| st.iter().find(|e| fits(e)).map(|e| e.handle)),
|
||||
};
|
||||
match found {
|
||||
Some(src) => l.sources = vec![src],
|
||||
None => missing += 1,
|
||||
}
|
||||
}
|
||||
h.associative = missing == 0 && h.loops.iter().all(|l| !l.sources.is_empty());
|
||||
if !h.associative && missing == 0 {
|
||||
missing = h.loops.len();
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// The loops of `h` in groups that fill separate areas: each loop not inside another one
|
||||
/// (counting nesting, so an island's island starts a new area) with the islands directly in it.
|
||||
fn areas(h: &Hatch) -> Vec<Vec<HatchLoop>> {
|
||||
let polys: Vec<Vec<Vec2>> = h.loops.iter().map(outline).collect();
|
||||
let first: Vec<Option<Vec2>> = polys.iter().map(|p| p.first().copied()).collect();
|
||||
let area = |i: usize| polys.get(i).map_or(0.0, |p| cadcraft_geom::shoelace(p).abs());
|
||||
// The loops containing each loop, smallest first.
|
||||
let parents: Vec<Vec<usize>> = (0..polys.len())
|
||||
.map(|i| {
|
||||
let mut ps: Vec<usize> = (0..polys.len())
|
||||
.filter(|&j| j != i && first.get(i).copied().flatten().zip(polys.get(j)).is_some_and(|(q, pj)| point_in_polygon(pj, q)))
|
||||
.collect();
|
||||
ps.sort_by(|a, b| area(*a).total_cmp(&area(*b)));
|
||||
ps
|
||||
})
|
||||
.collect();
|
||||
let mut groups: Vec<(usize, Vec<HatchLoop>)> = Vec::new();
|
||||
for (i, ps) in parents.iter().enumerate() {
|
||||
if ps.len() % 2 == 0
|
||||
&& let Some(l) = h.loops.get(i)
|
||||
{
|
||||
groups.push((i, vec![HatchLoop { outer: true, ..l.clone() }]));
|
||||
}
|
||||
}
|
||||
for (i, ps) in parents.iter().enumerate() {
|
||||
if ps.len() % 2 == 1
|
||||
&& let (Some(l), Some(top)) = (h.loops.get(i), ps.first())
|
||||
&& let Some(g) = groups.iter_mut().find(|g| g.0 == *top)
|
||||
{
|
||||
g.1.push(HatchLoop { outer: false, ..l.clone() });
|
||||
}
|
||||
}
|
||||
groups.into_iter().map(|g| g.1).collect()
|
||||
}
|
||||
|
||||
/// Split a hatch into one hatch per separate area. Returns the new hatches.
|
||||
fn separate(s: &mut Session, handle: Handle) -> Result<Vec<Handle>> {
|
||||
let (h, common, space) = {
|
||||
let d = s.doc()?;
|
||||
let Some(e) = d.entity(handle) else { return Ok(Vec::new()) };
|
||||
let EntityKind::Hatch(h) = &e.kind else { return Ok(Vec::new()) };
|
||||
(h.clone(), e.common.clone(), d.space_of(handle))
|
||||
};
|
||||
if h.loops.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("a hatch with more than {MAX_LOOPS} loops can't be separated")));
|
||||
}
|
||||
let mut groups = areas(&h).into_iter();
|
||||
let (Some(first), Some(space)) = (groups.next(), space) else { return Ok(Vec::new()) };
|
||||
let rest: Vec<Vec<HatchLoop>> = groups.collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for loops in rest {
|
||||
let mut nh = Hatch { loops, ..h.clone() };
|
||||
nh.associative = h.associative && nh.loops.iter().all(|l| !l.sources.is_empty());
|
||||
let n = d.add(&space, common.clone(), EntityKind::Hatch(nh))?;
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.send_to_back(n);
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
let kept = Hatch { loops: first, ..h };
|
||||
d.modify_entity(handle, |e| e.kind = EntityKind::Hatch(kept))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pattern, scale and angle (degrees) from the parameters.
|
||||
fn properties(h: &mut Hatch, p: &Value) {
|
||||
if let Some(v) = str_param(p, "pattern").map(str::trim).filter(|v| !v.is_empty()) {
|
||||
h.pattern = v.to_ascii_uppercase();
|
||||
h.solid = h.pattern == "SOLID";
|
||||
if !h.solid {
|
||||
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
|
||||
h.gradient = None;
|
||||
if cadcraft_doc::library::pattern(&h.pattern).is_some() {
|
||||
h.pattern_lines.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(x) = p.get("scale").and_then(Value::as_f64).filter(|x| x.is_finite() && *x > 0.0) {
|
||||
h.scale = x;
|
||||
}
|
||||
if let Some(x) = p.get("angle").and_then(Value::as_f64).filter(|x| x.is_finite()) {
|
||||
h.angle = x.to_radians();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = hatches(s, p)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
|
||||
None => None,
|
||||
};
|
||||
let origin = point_param(p, "origin").filter(|o| o.is_finite());
|
||||
let mode = match str_param(p, "originMode") {
|
||||
Some(t) => Some(OriginMode::parse(t).ok_or_else(|| bad("hatchedit", "unknown `originMode`"))?),
|
||||
None => None,
|
||||
};
|
||||
let recreate = match p.get("recreate") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => false,
|
||||
Some(Value::Bool(true)) => true,
|
||||
Some(Value::String(t)) if t.eq_ignore_ascii_case("polyline") => true,
|
||||
Some(Value::String(t)) if t.eq_ignore_ascii_case("region") => {
|
||||
return Err(bad("hatchedit", "region boundaries are not available yet; use \"polyline\""));
|
||||
}
|
||||
Some(_) => return Err(bad("hatchedit", "`recreate` is \"polyline\"")),
|
||||
};
|
||||
// Like the prompt: a pattern must be one CADCraft defines, SOLID, or the hatches' own (a
|
||||
// pattern read from a file keeps its definition).
|
||||
if let Some(up) = str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty())
|
||||
&& up != "SOLID"
|
||||
&& cadcraft_doc::library::pattern(&up).is_none()
|
||||
{
|
||||
let d = s.doc()?;
|
||||
let own = hs.iter().all(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(x)) if x.pattern == up));
|
||||
if !own {
|
||||
return Err(bad("hatchedit", format!("unknown pattern {up}")));
|
||||
}
|
||||
}
|
||||
let boundary: Option<Vec<Handle>> = match p.get("boundary") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).take(MAX_EDITED).collect()),
|
||||
Some(_) => return Err(bad("hatchedit", "`boundary` is an array of object handles")),
|
||||
};
|
||||
let associate = bool_or(p, "associate", false);
|
||||
let mut created = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
let mut budget = MAX_LOOPS;
|
||||
for handle in &hs {
|
||||
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
|
||||
properties(&mut h, p);
|
||||
if let Some(st) = style {
|
||||
h.style = st;
|
||||
}
|
||||
if origin.is_some() || mode.is_some() {
|
||||
h.origin = mode.unwrap_or_default().origin(&h.loops, origin.unwrap_or(h.origin));
|
||||
}
|
||||
match p.get("associative").and_then(Value::as_bool) {
|
||||
Some(false) => detach(&mut h),
|
||||
Some(true) => {
|
||||
// All loops or none: a hatch with loops left over keeps what it had.
|
||||
let mut linked = h.clone();
|
||||
let missing = link(s.doc()?, *handle, &mut linked, boundary.as_deref(), &mut budget);
|
||||
if missing == 0 {
|
||||
h = linked;
|
||||
} else {
|
||||
notes.push(format!("Hatch {}: no boundary object found for {missing} loop(s); it stays non-associative.", handle.hex()));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if recreate {
|
||||
let mut new = Vec::new();
|
||||
for l in &h.loops {
|
||||
new.push(s.add_entity(lwpoly(l.vertices.clone(), true))?);
|
||||
}
|
||||
if associate {
|
||||
for (l, n) in h.loops.iter_mut().zip(&new) {
|
||||
l.sources = vec![*n];
|
||||
}
|
||||
h.associative = true;
|
||||
}
|
||||
created.extend(new);
|
||||
}
|
||||
s.doc_mut()?.modify_entity(*handle, |e| e.kind = EntityKind::Hatch(h))?;
|
||||
if bool_or(p, "separate", false) {
|
||||
created.extend(separate(s, *handle)?);
|
||||
}
|
||||
}
|
||||
for n in ¬es {
|
||||
s.echo(n.clone());
|
||||
}
|
||||
Ok(json!({ "changed": hs.len(), "handles": created.iter().map(|h| h.hex()).collect::<Vec<_>>(), "messages": notes }))
|
||||
}
|
||||
|
||||
/// What a -HATCHEDIT prompt asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Ask {
|
||||
Option,
|
||||
Style,
|
||||
Pattern,
|
||||
Scale,
|
||||
Angle,
|
||||
Origin,
|
||||
Recreate,
|
||||
RecreateLink,
|
||||
Link,
|
||||
}
|
||||
|
||||
const OPTIONS: [&str; 7] = ["Disassociate", "Style", "Properties", "recreate Boundary", "ASsociate", "separate Hatches", "Origin"];
|
||||
|
||||
/// -HATCHEDIT: select hatches, then one option.
|
||||
pub(crate) struct HatchEditM {
|
||||
hatches: Vec<Handle>,
|
||||
ask: Ask,
|
||||
/// Properties typed so far (pattern, scale, angle in degrees).
|
||||
props: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
impl HatchEditM {
|
||||
pub(crate) fn new(s: &Session) -> Self {
|
||||
let hatches = s
|
||||
.doc()
|
||||
.map(|d| s.selection().into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).collect())
|
||||
.unwrap_or_default();
|
||||
HatchEditM { hatches, ask: Ask::Option, props: serde_json::Map::new() }
|
||||
}
|
||||
fn first(&self, s: &Session) -> Option<Hatch> {
|
||||
let d = s.doc().ok()?;
|
||||
self.hatches.iter().find_map(|h| match &d.entity(*h)?.kind {
|
||||
EntityKind::Hatch(x) => Some(x.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn apply(&self, s: &mut Session, mut p: Value) -> Result<Step> {
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("handles".into(), json!(self.hatches.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
}
|
||||
match run_hatchedit(s, &p) {
|
||||
Ok(v) => {
|
||||
if let Some(n) = v.get("handles").and_then(Value::as_array).map(Vec::len).filter(|n| *n > 0) {
|
||||
s.echo(format!("{n} object(s) created."));
|
||||
}
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for HatchEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"HATCHEDIT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.hatches.is_empty() {
|
||||
return Prompt::new("Select hatch object", Accept::SELECT);
|
||||
}
|
||||
let h = self.first(s);
|
||||
match self.ask {
|
||||
Ask::Option => Prompt::new("Enter a hatch option", Accept::TEXT).kw(&OPTIONS),
|
||||
Ask::Style => {
|
||||
let cur = match h.map_or(0, |h| h.style) {
|
||||
1 => "Outer",
|
||||
2 => "Ignore",
|
||||
_ => "Normal",
|
||||
};
|
||||
Prompt::new("Island detection style", Accept::TEXT).kw(&["Ignore", "Outer", "Normal"]).default(cur)
|
||||
}
|
||||
Ask::Pattern => Prompt::new("Enter a pattern name", Accept::TEXT).default(h.map(|h| h.pattern).unwrap_or_default()),
|
||||
Ask::Scale => Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", h.map_or(1.0, |h| h.scale))),
|
||||
Ask::Angle => Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER)
|
||||
.default(format!("{:.0}", h.map_or(0.0, |h| h.angle.to_degrees()))),
|
||||
Ask::Origin => Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"),
|
||||
// Region boundaries come with REGION objects; polylines until then.
|
||||
Ask::Recreate => Prompt::new("Type of boundary object", Accept::TEXT).kw(&["Polyline"]).default("Polyline"),
|
||||
Ask::RecreateLink => Prompt::new("Associate the hatch with the new boundary?", Accept::TEXT).kw(&["Yes", "No"]).default("No"),
|
||||
Ask::Link => Prompt::new("Select boundary objects (Enter finds them)", Accept::SELECT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.hatches.is_empty() {
|
||||
return Ok(match i {
|
||||
Input::Pick(hs) => {
|
||||
let d = s.doc()?;
|
||||
self.hatches = hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).collect();
|
||||
if self.hatches.is_empty() {
|
||||
s.echo("No hatch selected.");
|
||||
}
|
||||
Step::Continue
|
||||
}
|
||||
Input::Enter | Input::Cancel => Step::Done,
|
||||
_ => Step::Continue,
|
||||
});
|
||||
}
|
||||
let text = match &i {
|
||||
Input::Keyword(t) | Input::Text(t) => Some(t.trim().to_string()),
|
||||
_ => None,
|
||||
};
|
||||
match (self.ask, i) {
|
||||
(_, Input::Cancel) => Ok(Step::Cancel),
|
||||
(Ask::Option, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::Option, _) => {
|
||||
match text.as_deref() {
|
||||
Some("Disassociate") => return self.apply(s, json!({ "associative": false })),
|
||||
Some("separate Hatches") => return self.apply(s, json!({ "separate": true })),
|
||||
Some("Style") => self.ask = Ask::Style,
|
||||
Some("Properties") => self.ask = Ask::Pattern,
|
||||
Some("recreate Boundary") => self.ask = Ask::Recreate,
|
||||
Some("ASsociate") => self.ask = Ask::Link,
|
||||
Some("Origin") => self.ask = Ask::Origin,
|
||||
_ => s.echo("Choose one of the options."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Ask::Style, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::Style, _) => match text.as_deref().and_then(island_style) {
|
||||
Some(st) => {
|
||||
let name = ["normal", "outer", "ignore"].get(usize::from(st)).copied().unwrap_or("normal");
|
||||
self.apply(s, json!({ "style": name }))
|
||||
}
|
||||
None => Ok(Step::Continue),
|
||||
},
|
||||
(Ask::Pattern, _) => {
|
||||
if let Some(t) = text.filter(|t| !t.is_empty()) {
|
||||
let up = t.to_ascii_uppercase();
|
||||
if cadcraft_doc::library::pattern(&up).is_none() {
|
||||
s.echo(format!("Unknown pattern {up}."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.props.insert("pattern".into(), json!(up));
|
||||
}
|
||||
self.ask = Ask::Scale;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Ask::Scale, _) => {
|
||||
if let Some(t) = text {
|
||||
match crate::cmd::machines::number(&t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
Some(v) => {
|
||||
self.props.insert("scale".into(), json!(v));
|
||||
}
|
||||
None => return Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
self.ask = Ask::Angle;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Ask::Angle, _) => {
|
||||
if let Some(t) = text {
|
||||
match s.angle_settings().rotation(&t).filter(|a| a.is_finite()) {
|
||||
Some(a) => {
|
||||
self.props.insert("angle".into(), json!(a.to_degrees()));
|
||||
}
|
||||
None => return Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
self.apply(s, Value::Object(self.props.clone()))
|
||||
}
|
||||
(Ask::Origin, Input::Point(p)) => self.apply(s, json!({ "origin": [p.x, p.y], "originMode": "current" })),
|
||||
(Ask::Origin, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::Origin, _) => {
|
||||
let mode = match text.as_deref() {
|
||||
Some("Bottom left") => "bottomLeft",
|
||||
Some("bottom Right") => "bottomRight",
|
||||
Some("Top left") => "topLeft",
|
||||
Some("top riGht") => "topRight",
|
||||
Some("Center") => "center",
|
||||
Some("Use current") => return Ok(Step::Done),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.apply(s, json!({ "originMode": mode }))
|
||||
}
|
||||
(Ask::Recreate, _) => {
|
||||
self.ask = Ask::RecreateLink;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Ask::RecreateLink, _) => self.apply(s, json!({ "recreate": "polyline", "associate": text.as_deref() == Some("Yes") })),
|
||||
(Ask::Link, Input::Pick(hs)) => self.apply(s, json!({ "associative": true, "boundary": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })),
|
||||
(Ask::Link, Input::Enter) => self.apply(s, json!({ "associative": true })),
|
||||
(Ask::Link, _) => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! HATCH and HATCHEDIT options (#451): HPGAPTOL gap tolerance, pattern origin, separate
|
||||
//! hatches, island style, linking and unlinking boundaries, recreated boundaries, -HATCHEDIT.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle, Hatch};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn hatch(s: &Session, h: &str) -> Hatch {
|
||||
match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
|
||||
EntityKind::Hatch(x) => x.clone(),
|
||||
k => panic!("not a hatch: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn all_hatches(s: &Session) -> Vec<(String, Hatch)> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((e.handle.hex(), h.clone())) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn handle(v: &Value) -> String {
|
||||
v["handle"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
handle(&s.execute("rectang", &json!({ "p1": a, "p2": b })).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_tolerance_closes_small_gaps() {
|
||||
let mut s = Session::new();
|
||||
for pts in [[[0.0, 0.0], [9.5, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.5]]] {
|
||||
s.execute("line", &json!({ "points": pts })).unwrap();
|
||||
}
|
||||
assert!(s.execute("hatch", &json!({ "points": [[5, 5]] })).is_err(), "open without a gap tolerance");
|
||||
s.execute("setvar", &json!({ "name": "HPGAPTOL", "value": 1 })).unwrap();
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap());
|
||||
let hh = hatch(&s, &h);
|
||||
assert_eq!(hh.loops[0].sources.len(), 4);
|
||||
// Still associative: the gaps stay within the tolerance after a move of one line.
|
||||
let right = s.doc().unwrap().model.iter().nth(1).unwrap().handle.hex();
|
||||
s.execute("move", &json!({ "handles": [right], "delta": [0.3, 0] })).unwrap();
|
||||
assert!(hatch(&s, &h).associative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_origin_options() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [5.0, 5.0], [15.0, 10.0]);
|
||||
let a = handle(&s.execute("hatch", &json!({ "points": [[6, 6]], "originMode": "bottomLeft" })).unwrap());
|
||||
assert_eq!(hatch(&s, &a).origin, Vec2::new(5.0, 5.0));
|
||||
let b = handle(&s.execute("hatch", &json!({ "points": [[6, 6]], "origin": [1, 2] })).unwrap());
|
||||
assert_eq!(hatch(&s, &b).origin, Vec2::new(1.0, 2.0));
|
||||
// HPORIGIN / HPORIGINMODE are the defaults.
|
||||
s.execute("setvar", &json!({ "name": "HPORIGIN", "value": [3, 4] })).unwrap();
|
||||
let c = handle(&s.execute("hatch", &json!({ "points": [[6, 6]] })).unwrap());
|
||||
assert_eq!(hatch(&s, &c).origin, Vec2::new(3.0, 4.0));
|
||||
s.execute("setvar", &json!({ "name": "HPORIGINMODE", "value": 3 })).unwrap();
|
||||
let d = handle(&s.execute("hatch", &json!({ "points": [[6, 6]] })).unwrap());
|
||||
assert_eq!(hatch(&s, &d).origin, Vec2::new(15.0, 10.0));
|
||||
s.execute("hatchedit", &json!({ "handles": [d.clone()], "originMode": "center" })).unwrap();
|
||||
assert_eq!(hatch(&s, &d).origin, Vec2::new(10.0, 7.5));
|
||||
s.execute("hatchedit", &json!({ "handles": [d.clone()], "origin": [0, 0] })).unwrap();
|
||||
assert_eq!(hatch(&s, &d).origin, Vec2::ZERO);
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [d], "originMode": "middle" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_hatches_and_island_style() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap();
|
||||
rect(&mut s, [20.0, 0.0], [30.0, 10.0]);
|
||||
let r = s.execute("hatch", &json!({ "points": [[1, 1], [21, 1]], "separate": true, "style": "outer" })).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
assert!(all_hatches(&s).iter().all(|(_, h)| h.style == 1));
|
||||
s.execute("erase", &json!({ "handles": r["handles"].clone() })).unwrap();
|
||||
// One hatch for both areas, split by HATCHEDIT: the island stays with its area.
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[1, 1], [21, 1]] })).unwrap());
|
||||
assert_eq!(hatch(&s, &h).loops.len(), 3);
|
||||
let r = s.execute("hatchedit", &json!({ "handles": [h.clone()], "separate": true, "style": "ignore" })).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 1);
|
||||
let hs = all_hatches(&s);
|
||||
assert_eq!(hs.len(), 2);
|
||||
let mut loops: Vec<usize> = hs.iter().map(|(_, h)| h.loops.len()).collect();
|
||||
loops.sort();
|
||||
assert_eq!(loops, vec![1, 2]);
|
||||
assert!(hs.iter().all(|(_, h)| h.associative && h.style == 2));
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h], "style": "odd" })).is_err());
|
||||
assert!(s.execute("hatch", &json!({ "points": [[21, 1]], "style": "odd" })).is_err());
|
||||
assert!(s.execute("hatch", &json!({ "points": [[21, 1]], "originMode": "middle" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_unlinks_relinks_and_recreates_boundaries() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [4.0, 2.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "handles": [r.clone()] })).unwrap());
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "associative": false })).unwrap();
|
||||
assert!(!hatch(&s, &h).associative);
|
||||
// Linked again to the rectangle of the same shape, it follows it.
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "associative": true })).unwrap();
|
||||
assert!(hatch(&s, &h).associative);
|
||||
s.execute("move", &json!({ "handles": [r.clone()], "delta": [10, 0] })).unwrap();
|
||||
assert_eq!(hatch(&s, &h).loops[0].vertices[0].p.x, 10.0);
|
||||
// With the boundary erased, a recreated boundary takes its place.
|
||||
s.execute("erase", &json!({ "handles": [r] })).unwrap();
|
||||
assert!(!hatch(&s, &h).associative);
|
||||
let v = s.execute("hatchedit", &json!({ "handles": [h.clone()], "associative": true })).unwrap();
|
||||
assert_eq!(v["messages"].as_array().unwrap().len(), 1, "nothing to link to");
|
||||
let v = s.execute("hatchedit", &json!({ "handles": [h.clone()], "recreate": "polyline", "associate": true })).unwrap();
|
||||
let p = v["handles"][0].as_str().unwrap().to_string();
|
||||
assert!(hatch(&s, &h).associative);
|
||||
s.execute("move", &json!({ "handles": [p], "delta": [0, 10] })).unwrap();
|
||||
assert_eq!(hatch(&s, &h).loops[0].vertices[0].p.y, 10.0);
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h], "recreate": 5 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_needs_a_hatch() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [4.0, 2.0]);
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [r], "scale": 2 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_origin_and_separate_keywords_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
rect(&mut s, [20.0, 0.0], [30.0, 10.0]);
|
||||
s.script("-HATCH\nE\nO\nB\n1,1\n21,1\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let hs = all_hatches(&s);
|
||||
assert_eq!(hs.len(), 2, "separate hatches");
|
||||
let mut origins: Vec<f64> = hs.iter().map(|(_, h)| h.origin.x).collect();
|
||||
origins.sort_by(f64::total_cmp);
|
||||
assert_eq!(origins, vec![0.0, 20.0], "each from its own bottom-left corner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_hatchedit_options_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[1, 1]] })).unwrap());
|
||||
s.set_selection(vec![Handle::parse_hex(&h).unwrap()]);
|
||||
s.script("-HATCHEDIT\nP\nANSI32\n2\n45\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let hh = hatch(&s, &h);
|
||||
assert_eq!((hh.pattern.as_str(), hh.scale), ("ANSI32", 2.0));
|
||||
assert!((hh.angle.to_degrees() - 45.0).abs() < 1e-9);
|
||||
s.set_selection(vec![Handle::parse_hex(&h).unwrap()]);
|
||||
s.script("-HATCHEDIT\nD\n").unwrap();
|
||||
assert!(!hatch(&s, &h).associative);
|
||||
s.set_selection(Vec::new());
|
||||
s.script("-HATCHEDIT\nW\n-1,-1\n11,11\nAS\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(hatch(&s, &h).associative, "{:?}", s.log);
|
||||
assert_eq!(hatch(&s, &h).loops[0].sources, vec![Handle::parse_hex(&r).unwrap()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_refuses_unknown_patterns_and_region_boundaries() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [4.0, 2.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "handles": [r] })).unwrap());
|
||||
let before = hatch(&s, &h).pattern;
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "NO-SUCH-PATTERN" })).is_err());
|
||||
assert_eq!(hatch(&s, &h).pattern, before);
|
||||
// Its own pattern, SOLID and library patterns are fine.
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": before.to_lowercase() })).unwrap();
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "solid" })).unwrap();
|
||||
assert!(hatch(&s, &h).solid);
|
||||
// Region boundaries need REGION objects; the hatch is left as it was.
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h], "recreate": "region" })).is_err());
|
||||
}
|
||||
@@ -20,6 +20,10 @@ pub const MAX_SEGMENTS: usize = 60_000;
|
||||
/// Upper bound on islands reported for one boundary (hostile input with many tiny shapes).
|
||||
pub const MAX_ISLANDS: usize = 4096;
|
||||
|
||||
/// Upper bound on open curve ends considered for closing gaps, and on the pairs compared.
|
||||
const MAX_GAP_ENDS: usize = 50_000;
|
||||
const MAX_GAP_PAIRS: usize = 5_000_000;
|
||||
|
||||
/// The closed boundary around a point and the islands inside it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Boundary {
|
||||
@@ -177,6 +181,57 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// The graph of the curves with gaps of up to `gap` between open ends bridged: each open end
|
||||
/// joins the nearest other open end within reach.
|
||||
fn build_gap(polys: &[Vec<Vec2>], tol: f64, gap: f64) -> Option<Graph> {
|
||||
let mut g = build(polys, tol.max(1e-12))?;
|
||||
if !(gap.is_finite() && gap > tol) {
|
||||
return Some(g);
|
||||
}
|
||||
let mut ends: Vec<(Vec2, usize)> =
|
||||
g.adj.iter().enumerate().filter(|(_, ns)| ns.len() == 1).filter_map(|(u, _)| Some((*g.pts.get(u)?, u))).take(MAX_GAP_ENDS).collect();
|
||||
ends.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
let mut joined = vec![false; ends.len()];
|
||||
let mut budget = MAX_GAP_PAIRS;
|
||||
for i in 0..ends.len() {
|
||||
let (Some(&(pu, u)), Some(false)) = (ends.get(i), joined.get(i).copied()) else { continue };
|
||||
let lo = ends.partition_point(|e| e.0.x < pu.x - gap);
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (j, &(pv, v)) in ends.iter().enumerate().skip(lo) {
|
||||
if pv.x > pu.x + gap || budget == 0 {
|
||||
break;
|
||||
}
|
||||
budget -= 1;
|
||||
if j == i || joined.get(j).copied().unwrap_or(true) || g.adj.get(u).is_some_and(|l| l.contains(&v)) {
|
||||
continue;
|
||||
}
|
||||
let d = pu.dist(pv);
|
||||
if d <= gap && best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, j));
|
||||
}
|
||||
}
|
||||
let Some((_, j)) = best else { continue };
|
||||
let Some(&(_, v)) = ends.get(j) else { continue };
|
||||
let owner = g.adj.get(u).and_then(|l| l.first()).and_then(|w| g.edge_src.get(&(u.min(*w), u.max(*w))).copied());
|
||||
if let Some(l) = g.adj.get_mut(u) {
|
||||
l.push(v);
|
||||
}
|
||||
if let Some(l) = g.adj.get_mut(v) {
|
||||
l.push(u);
|
||||
}
|
||||
if let Some(k) = owner {
|
||||
g.edge_src.insert((u.min(v), u.max(v)), k);
|
||||
}
|
||||
if let Some(f) = joined.get_mut(i) {
|
||||
*f = true;
|
||||
}
|
||||
if let Some(f) = joined.get_mut(j) {
|
||||
*f = true;
|
||||
}
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Trace the face left of half-edge u→v (vertex indices), giving up after `max_steps` edges.
|
||||
fn trace(g: &Graph, u0: usize, v0: usize, max_steps: usize) -> Option<Vec<usize>> {
|
||||
let (mut u, mut v) = (u0, v0);
|
||||
@@ -371,13 +426,23 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
|
||||
/// The closed loop enclosing `p`, counter-clockwise, or `None`.
|
||||
pub fn enclosing_loop(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Vec<Vec2>> {
|
||||
let g = build(polys, tol.max(1e-12))?;
|
||||
enclosing_loop_gap(polys, p, tol, 0.0)
|
||||
}
|
||||
|
||||
/// [`enclosing_loop`], with gaps of up to `gap` between curve ends closed (HPGAPTOL).
|
||||
pub fn enclosing_loop_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Option<Vec<Vec2>> {
|
||||
let g = build_gap(polys, tol, gap)?;
|
||||
points(&g, &outer_face(&g, p)?)
|
||||
}
|
||||
|
||||
/// The closed loop enclosing `p` plus the islands inside it, or `None` when `p` isn't enclosed.
|
||||
pub fn boundary_at(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Boundary> {
|
||||
let g = build(polys, tol.max(1e-12))?;
|
||||
boundary_at_gap(polys, p, tol, 0.0)
|
||||
}
|
||||
|
||||
/// [`boundary_at`], with gaps of up to `gap` between curve ends closed (HPGAPTOL).
|
||||
pub fn boundary_at_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Option<Boundary> {
|
||||
let g = build_gap(polys, tol, gap)?;
|
||||
let ids = outer_face(&g, p)?;
|
||||
let outer = points(&g, &ids)?;
|
||||
let islands = islands(&g, &ids, p);
|
||||
@@ -390,8 +455,9 @@ pub fn boundary_at(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Boundary> {
|
||||
|
||||
/// 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))?;
|
||||
/// Gaps of up to `gap` between curve ends are closed (HPGAPTOL).
|
||||
pub fn outline(polys: &[Vec<Vec2>], tol: f64, gap: f64) -> Option<Vec<Vec2>> {
|
||||
let g = build_gap(polys, tol, gap)?;
|
||||
let core = prune(&g);
|
||||
let comp = components(&core);
|
||||
let mut start: Option<usize> = None;
|
||||
@@ -609,10 +675,10 @@ mod tests {
|
||||
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();
|
||||
let l = outline(&lines_of(&square(5.0, 5.0, 5.0)), 1e-9, 0.0).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());
|
||||
assert!(outline(&[vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]], 1e-9, 0.0).is_none());
|
||||
assert!(outline(&[square(0.0, 0.0, 1.0), square(5.0, 5.0, 1.0)], 1e-9, 0.0).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)];
|
||||
@@ -623,4 +689,26 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_tolerance_closes_small_gaps() {
|
||||
// Four lines with 0.5 gaps at two corners.
|
||||
let polys = vec![
|
||||
vec![Vec2::new(0.0, 0.0), Vec2::new(9.5, 0.0)],
|
||||
vec![Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)],
|
||||
vec![Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)],
|
||||
vec![Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.5)],
|
||||
];
|
||||
let p = Vec2::new(5.0, 5.0);
|
||||
assert!(enclosing_loop(&polys, p, 1e-9).is_none());
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, 0.25).is_none(), "gap larger than the tolerance");
|
||||
let l = enclosing_loop_gap(&polys, p, 1e-9, 0.6).unwrap();
|
||||
assert!((shoelace(&l) - 100.0).abs() < 1e-9, "{}", shoelace(&l));
|
||||
let b = boundary_at_gap(&polys, p, 1e-9, 0.6).unwrap();
|
||||
assert_eq!(b.outer_sources, vec![0, 1, 2, 3]);
|
||||
assert!(outline(&polys, 1e-9, 0.6).is_some());
|
||||
// Hostile gaps.
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::NAN).is_none());
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::INFINITY).is_none());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ do them first.
|
||||
| F10 | MLEADER: no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
|
||||
| F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 |
|
||||
| F12 | Annotative scaling: the flag is stored, nothing scales; no scale list, no "Annotative Object Scale" commands | 4 uncovered menu items | Medium-high in layouts | 10–15 |
|
||||
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); gradients render as a flat colour; associativity not updated when the boundary moves; origin always 0,0; island style always normal; no gap tolerance, separate hatches, hatch dialog, MPolygon | `doc/src/library.rs`, `render/lib.rs:430-437`, `cmd/hatch.rs:198,201` | Medium | 15–25 |
|
||||
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); no hatch dialog (HATCH and HATCHEDIT options work at the command line and in JSON), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 |
|
||||
| F14 | **[alpha blocker]** No plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200). Plot styles (CTB and STB, [plot-styles.md](plot-styles.md)) apply since #410; their end/join/fill styles aren't applied and binary `.ctb`/`.stb` files can't be read | `palettes.rs:666` | High for sheet output | 15–25 |
|
||||
| F15 | Viewports: no polygonal, object or clipped viewports; no viewport scale control in the UI | menu items uncovered | Medium | 6–10 |
|
||||
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
|
||||
|
||||
Reference in new issue
Block a user