diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 341e6eb..1952a4e 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -15,15 +15,22 @@ use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Ses mod assoc; mod edit; +mod pick; pub(crate) use assoc::update as refill_associative; +pub use pick::{HatchPicks, PickMode}; pub fn specs() -> Vec { vec![ - CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (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("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\", 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?, 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)))), + 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], 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()))), ] } @@ -250,6 +257,17 @@ pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result Option<&'static str> { + let up = t.trim().to_ascii_uppercase(); + GRADIENT_NAMES.iter().copied().find(|n| *n == up) +} + #[derive(Clone)] struct HatchSettings { pattern: String, @@ -273,11 +291,11 @@ impl HatchSettings { )); 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").unwrap_or("LINEAR").to_string(), + 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: true, + centered: bool_or(p, "centered", true), }); HatchSettings { pattern, @@ -333,14 +351,21 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result { 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::new(); - if let Some(pts) = points_param(p, "points") { - for q in pts { - groups.push(loops_at(s, q)?); - } - } else { + // 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 }])); diff --git a/crates/engine/src/cmd/hatch/edit.rs b/crates/engine/src/cmd/hatch/edit.rs index ce4e0d3..db56167 100644 --- a/crates/engine/src/cmd/hatch/edit.rs +++ b/crates/engine/src/cmd/hatch/edit.rs @@ -2,12 +2,13 @@ //! 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_color::Color; +use cadcraft_doc::{Drawing, EntityKind, Gradient, 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 super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style}; use crate::cmd::helpers::lwpoly; use crate::cmd::{bad, bool_or, point_param, str_param, targets}; use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step}; @@ -165,6 +166,87 @@ fn properties(h: &mut Hatch, p: &Value) { } } +/// HATCHEDIT's `gradient`: the fields to change, all checked before any hatch is touched. +struct GradientEdit { + name: Option<&'static str>, + color1: Option, + color2: Option, + /// Radians. + angle: Option, + centered: Option, +} + +impl GradientEdit { + /// Make `h` a gradient fill (from the default LINEAR blue-to-white one when it has none) and + /// set the given fields. + fn apply(&self, h: &mut Hatch) { + let mut g = h.gradient.clone().unwrap_or(Gradient { + name: "LINEAR".into(), + color1: Color::Index(5), + color2: Color::Index(7), + angle: 0.0, + centered: true, + }); + if let Some(n) = self.name { + g.name = n.into(); + } + if let Some(c) = self.color1 { + g.color1 = c; + } + if let Some(c) = self.color2 { + g.color2 = c; + } + if let Some(a) = self.angle { + g.angle = a; + } + if let Some(c) = self.centered { + g.centered = c; + } + h.gradient = Some(g); + h.pattern = "SOLID".into(); + h.solid = true; + h.pattern_lines.clear(); + } +} + +fn gradient_edit(p: &Value) -> Result> { + let g = match p.get("gradient") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Object(g)) => g, + Some(_) => return Err(bad("hatchedit", "`gradient` is an object {name?, color1?, color2?, angle?, centered?}")), + }; + let field = |k: &str| g.get(k).filter(|v| !v.is_null()); + let name = match field("name") { + None => None, + Some(v) => Some( + v.as_str() + .and_then(gradient_name) + .ok_or_else(|| bad("hatchedit", format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))))?, + ), + }; + let color = |k: &str| -> Result> { + match field(k) { + None => Ok(None), + Some(v) => v + .as_str() + .and_then(Color::parse) + .map(Some) + .ok_or_else(|| bad("hatchedit", format!("`gradient.{k}` is a colour such as \"5\", \"red\" or \"255,128,0\""))), + } + }; + let angle = match field("angle") { + None => None, + Some(v) => Some( + v.as_f64().map(f64::to_radians).filter(|a| a.is_finite()).ok_or_else(|| bad("hatchedit", "`gradient.angle` is a number of degrees"))?, + ), + }; + let centered = match field("centered") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad("hatchedit", "`gradient.centered` is true or false"))?), + }; + Ok(Some(GradientEdit { name, color1: color("color1")?, color2: color("color2")?, angle, centered })) +} + pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result { let hs = hatches(s, p)?; let style = match str_param(p, "style") { @@ -185,6 +267,10 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result { } Some(_) => return Err(bad("hatchedit", "`recreate` is \"polyline\"")), }; + let grad = gradient_edit(p)?; + if grad.is_some() && str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).is_some_and(|v| !v.is_empty() && v != "SOLID") { + return Err(bad("hatchedit", "give `pattern` or `gradient`, not both")); + } // 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()) @@ -210,6 +296,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result { 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(g) = &grad { + g.apply(&mut h); + } if let Some(st) = style { h.style = st; } diff --git a/crates/engine/src/cmd/hatch/pick.rs b/crates/engine/src/cmd/hatch/pick.rs new file mode 100644 index 0000000..b5fc607 --- /dev/null +++ b/crates/engine/src/cmd/hatch/pick.rs @@ -0,0 +1,224 @@ +//! HATCHPICK: the Hatch and Gradient dialog's picks (internal points, boundary objects, a pattern +//! origin, or the hatches to edit), kept in the session for the dialog to take. It never changes +//! the drawing. + +use cadcraft_doc::{EntityKind, Handle, HatchLoop}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::{HatchSettings, closed_loop, loops_at, loops_from_handles}; +use crate::cmd::{bad, points_param, targets}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +/// Upper bound on the points and objects one HATCHPICK keeps. +pub(crate) const MAX_PICKS: usize = 1000; + +/// The first prompt's keywords: what to pick. +const MODES: [&str; 4] = ["Points", "Objects", "oRigin", "Hatch"]; + +/// What HATCHPICK picks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PickMode { + /// Internal points, each inside a closed boundary. + #[default] + Points, + /// Closed boundary objects. + Objects, + /// One point: the pattern origin. + Origin, + /// Hatches to edit. + Hatch, +} + +/// What the last HATCHPICK kept ([`Session::hatch_picks`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HatchPicks { + pub mode: PickMode, + pub points: Vec, + /// Closed boundary objects (Objects) or hatches (Hatch). + pub handles: Vec, + /// The pattern origin (Origin). + pub origin: Option, +} + +fn mode_named(t: &str) -> Option { + Some(match t.trim().to_ascii_lowercase().as_str() { + "points" | "p" => PickMode::Points, + "objects" | "o" => PickMode::Objects, + "origin" | "r" => PickMode::Origin, + "hatch" | "h" => PickMode::Hatch, + _ => return None, + }) +} + +/// JSON form: `{points?, handles?}`, checked as the prompts check them. +pub(super) fn run_hatchpick(s: &mut Session, p: &Value) -> Result { + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if !given("points") && !given("handles") { + return Err(bad("hatchpick", "give internal `points` or boundary `handles`")); + } + let mut picks = HatchPicks::default(); + if given("points") { + let pts = points_param(p, "points").ok_or_else(|| bad("hatchpick", "`points` is an array of [x, y]"))?; + if pts.len() > MAX_PICKS { + return Err(bad("hatchpick", format!("at most {MAX_PICKS} points"))); + } + for q in pts { + loops_at(s, q).map_err(|_| bad("hatchpick", format!("no closed boundary around [{}, {}]", q.x, q.y)))?; + picks.points.push(q); + } + } + if given("handles") { + let hs = targets(s, p)?; + if hs.len() > MAX_PICKS { + return Err(bad("hatchpick", format!("at most {MAX_PICKS} objects"))); + } + let d = s.doc()?; + for h in hs { + match d.entity(h) { + Some(e) if closed_loop(d, e).is_some() => picks.handles.push(h), + _ => return Err(bad("hatchpick", format!("{} is not a closed object", h.hex()))), + } + } + if picks.points.is_empty() { + picks.mode = PickMode::Objects; + } + } + let out = json!({ + "points": picks.points.iter().map(|q| [q.x, q.y]).collect::>(), + "handles": picks.handles.iter().map(|h| h.hex()).collect::>(), + }); + s.hatch_picks = Some(picks); + Ok(out) +} + +/// One pick, so Undo can drop it. +enum Pick { + Point(Vec2, Vec), + Objects(Vec, Vec), +} + +#[derive(Default)] +pub(super) struct HatchPickM { + mode: Option, + picks: Vec, +} + +impl HatchPickM { + fn count(&self) -> usize { + self.picks + .iter() + .map(|p| match p { + Pick::Point(..) => 1, + Pick::Objects(hs, _) => hs.len(), + }) + .sum() + } + fn keep(&self, s: &mut Session, mode: PickMode) { + let mut out = HatchPicks { mode, ..HatchPicks::default() }; + for p in &self.picks { + match p { + Pick::Point(q, _) => out.points.push(*q), + Pick::Objects(hs, _) => out.handles.extend(hs.iter().copied()), + } + } + s.hatch_picks = Some(out); + } +} + +impl Interactive for HatchPickM { + fn name(&self) -> &'static str { + "HATCHPICK" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.mode { + None => Prompt::new("Enter what to pick", Accept::TEXT).kw(&MODES), + Some(PickMode::Points) => Prompt::new("Pick internal point", Accept::POINT).kw(&["Undo"]), + Some(PickMode::Objects) => Prompt::new("Select objects", Accept::SELECT).kw(&["Undo"]), + Some(PickMode::Origin) => Prompt::new("Specify the pattern origin", Accept::POINT), + Some(PickMode::Hatch) => Prompt::new("Select hatch object", Accept::SELECT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some(mode) = self.mode else { + match &i { + Input::Keyword(t) | Input::Text(t) => self.mode = mode_named(t), + Input::Enter | Input::Cancel => return Ok(Step::Cancel), + _ => {} + } + return Ok(Step::Continue); + }; + match (mode, i) { + (_, Input::Cancel) => Ok(Step::Cancel), + (PickMode::Points | PickMode::Objects, Input::Keyword(k)) if k == "Undo" => { + if self.picks.pop().is_none() { + s.echo("Nothing to undo."); + } + Ok(Step::Continue) + } + (PickMode::Points | PickMode::Objects, Input::Enter) => { + self.keep(s, mode); + Ok(Step::Done) + } + (PickMode::Points, Input::Point(q)) => { + if self.count() >= MAX_PICKS { + s.echo(format!("At most {MAX_PICKS} picks.")); + return Ok(Step::Continue); + } + match loops_at(s, q) { + Ok(l) => { + s.echo(format!("Analyzing the selected data... {} loop(s)", l.len())); + self.picks.push(Pick::Point(q, l)); + } + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Continue) + } + (PickMode::Objects, Input::Pick(hs)) => { + let room = MAX_PICKS.saturating_sub(self.count()); + let closed: Vec = { + let d = s.doc()?; + hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| closed_loop(d, e).is_some())).take(room).collect() + }; + let others = hs.len().saturating_sub(closed.len()); + if !closed.is_empty() { + let loops = loops_from_handles(s, &closed)?; + self.picks.push(Pick::Objects(closed.clone(), loops)); + } + s.echo(if others == 0 { format!("{} found", closed.len()) } else { format!("{} found, {others} not closed", closed.len()) }); + Ok(Step::Continue) + } + (PickMode::Origin, Input::Point(q)) => { + s.hatch_picks = Some(HatchPicks { mode, origin: Some(q), ..HatchPicks::default() }); + Ok(Step::Done) + } + (PickMode::Hatch, Input::Pick(hs)) => { + let hatches: Vec = { + let d = s.doc()?; + hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).take(MAX_PICKS).collect() + }; + if hatches.is_empty() { + s.echo("No hatch selected."); + return Ok(Step::Continue); + } + s.hatch_picks = Some(HatchPicks { mode, handles: hatches, ..HatchPicks::default() }); + Ok(Step::Done) + } + (PickMode::Origin | PickMode::Hatch, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, _c: Vec2) -> Vec { + let loops: Vec = self + .picks + .iter() + .flat_map(|p| match p { + Pick::Point(_, l) | Pick::Objects(_, l) => l.iter().cloned(), + }) + .collect(); + if loops.is_empty() { + return Vec::new(); + } + vec![HatchSettings::from(s, &Value::Null, false).hatch(loops)] + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c55f1d..e8e586c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,7 +37,7 @@ use serde_json::Value; pub use cadcraft_doc as doc; pub use cadcraft_geom as geom; pub use cadcraft_render as render; -pub use cmd::hatch::{PatternChoice, pattern_choices}; +pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step}; @@ -606,6 +606,8 @@ pub struct Session { pub clipboard_source: Option, /// The window last picked with PLOTWINDOW (the Plot dialog's "Window <"). pub plot_window: Option, + /// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks). + pub hatch_picks: Option, } impl Default for Session { @@ -650,6 +652,7 @@ impl Session { last_used: LastUsed::default(), clipboard_source: None, plot_window: None, + hatch_picks: None, } } pub fn new_drawing(&mut self, metric: bool) -> usize { diff --git a/crates/engine/src/sysvars/table.rs b/crates/engine/src/sysvars/table.rs index c4341d3..b1a91b8 100644 --- a/crates/engine/src/sysvars/table.rs +++ b/crates/engine/src/sysvars/table.rs @@ -55,6 +55,8 @@ const HEADER: &[(&str, Kind, Def)] = &[ ("DONUTOD", Kind::Real { min: 0.0, strict: true }, Def::Real(1.0)), ("EDGEMODE", Kind::Choice(&[0, 1]), Def::Int(0)), ("HPANG", Kind::Angle, Def::Real(0.0)), + // 1: HATCH and GRADIENT typed or from a menu open the Hatch and Gradient dialog; 0: the command line. + ("HPDLGMODE", Kind::Choice(&[0, 1]), Def::Int(1)), ("HPNAME", Kind::Pattern, Def::Str("ANSI31")), ("HPSCALE", Kind::Real { min: 0.0, strict: true }, Def::Real(1.0)), ("INSNAME", Kind::Name, Def::Str("")), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 889ae10..03d9f24 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -638,6 +638,8 @@ fn hostile_params_never_panic() { json!({"points": [[1e308, -1e308], ["a"]]}), json!({"handles": ["zz", 99999]}), json!({"radius": -1, "center": [0, 0]}), + json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}), + json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}), ] { let _ = s.execute(c.id, &p); } diff --git a/crates/engine/tests/hatch_dialog_helpers.rs b/crates/engine/tests/hatch_dialog_helpers.rs new file mode 100644 index 0000000..f622d8d --- /dev/null +++ b/crates/engine/tests/hatch_dialog_helpers.rs @@ -0,0 +1,249 @@ +//! The engine side of the Hatch and Gradient dialog: GRADIENT's name and centred, points and +//! objects in one HATCH call, HATCHEDIT's gradient, HATCHPICK and HPDLGMODE. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle, Hatch}; +use serde_json::{Value, json}; + +fn rect(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + s.execute("rectang", &json!({ "p1": a, "p2": b })).unwrap()["handle"].as_str().unwrap().to_string() +} + +/// The handle of the object added last. +fn last(s: &Session) -> String { + s.doc().unwrap().model.iter().last().unwrap().handle.hex() +} + +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 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 count(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +fn undo_depth(s: &Session) -> usize { + s.state().unwrap().undo.len() +} + +fn handle(v: &Value) -> String { + v["handle"].as_str().unwrap().to_string() +} + +#[test] +fn gradient_takes_a_name_and_centred() { + let mut s = Session::new(); + rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + let h = handle( + &s.execute("gradient", &json!({ "points": [[5, 5]], "name": "invspherical", "centered": false, "color1": "1", "color2": "3", "angle": 45 })) + .unwrap(), + ); + let g = hatch(&s, &h).gradient.unwrap(); + assert_eq!(g.name, "INVSPHERICAL"); + assert!(!g.centered); + assert!((g.angle - 45f64.to_radians()).abs() < 1e-12); + // Linear and centred unless told otherwise. + let h = handle(&s.execute("gradient", &json!({ "points": [[5, 5]] })).unwrap()); + let g = hatch(&s, &h).gradient.unwrap(); + assert_eq!((g.name.as_str(), g.centered), ("LINEAR", true)); + // An unknown name is refused and adds nothing. + let n = count(&s); + assert!(s.execute("gradient", &json!({ "points": [[5, 5]], "name": "RAINBOW" })).is_err()); + assert_eq!(count(&s), n); +} + +#[test] +fn hatch_takes_points_and_objects_in_one_call() { + let mut s = Session::new(); + rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + s.execute("circle", &json!({ "center": [30, 5], "radius": 3 })).unwrap(); + let c = last(&s); + let depth = undo_depth(&s); + s.execute("hatch", &json!({ "points": [[5, 5]], "handles": [c], "pattern": "SOLID" })).unwrap(); + let hs = hatches(&s); + assert_eq!(hs.len(), 1, "one hatch for both picks"); + assert_eq!(hs[0].1.loops.len(), 2); + assert_eq!(undo_depth(&s), depth + 1, "one undo step"); + // Separate: one hatch per point and one per object. + s.execute("hatch", &json!({ "points": [[5, 5]], "handles": [c], "separate": true })).unwrap(); + assert_eq!(hatches(&s).len(), 3); + s.undo().unwrap(); + assert_eq!(hatches(&s).len(), 1, "one undo removes both"); + // Points alone still ignore the selection. + s.set_selection(vec![Handle::parse_hex(&c).unwrap()]); + let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()); + assert_eq!(hatch(&s, &h).loops.len(), 1); +} + +use cadcraft_engine::doc::color::{Color, Rgb}; + +#[test] +fn hatchedit_turns_a_pattern_into_a_gradient_and_back() { + let mut s = Session::new(); + rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31" })).unwrap()); + let depth = undo_depth(&s); + s.execute( + "hatchedit", + &json!({ "handles": [h], "gradient": { "name": "cylinder", "color1": "red", "color2": "255,128,0", "angle": 30, "centered": false } }), + ) + .unwrap(); + let x = hatch(&s, &h); + let g = x.gradient.clone().unwrap(); + assert_eq!((g.name.as_str(), g.color1, g.color2, g.centered), ("CYLINDER", Color::Index(1), Color::True(Rgb(255, 128, 0)), false)); + assert!((g.angle - 30f64.to_radians()).abs() < 1e-12); + assert!(x.solid); + assert_eq!(x.pattern, "SOLID"); + assert_eq!(undo_depth(&s), depth + 1, "one undo step"); + // Only the fields given change. + s.execute("hatchedit", &json!({ "handles": [h], "gradient": { "color2": "3" } })).unwrap(); + let g = hatch(&s, &h).gradient.unwrap(); + assert_eq!((g.name.as_str(), g.color1, g.color2), ("CYLINDER", Color::Index(1), Color::Index(3))); + // A pattern turns it back. + s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31" })).unwrap(); + let x = hatch(&s, &h); + assert!(x.gradient.is_none()); + assert!(!x.solid); + s.undo().unwrap(); + assert!(hatch(&s, &h).gradient.is_some()); +} + +#[test] +fn hatchedit_refuses_a_bad_gradient_and_changes_nothing() { + let mut s = Session::new(); + rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31" })).unwrap()); + let before = hatch(&s, &h); + for bad in [ + json!("x"), + json!({ "name": "RAINBOW" }), + json!({ "name": 5 }), + json!({ "color1": "nope" }), + json!({ "angle": "x" }), + json!({ "centered": 1 }), + ] { + assert!(s.execute("hatchedit", &json!({ "handles": [h], "gradient": bad })).is_err(), "{bad}"); + assert_eq!(hatch(&s, &h), before, "{bad}"); + } + assert!(s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31", "gradient": { "name": "LINEAR" } })).is_err()); + assert_eq!(hatch(&s, &h), before); +} + +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, PickMode}; + +fn h(hex: &str) -> Handle { + Handle::parse_hex(hex).unwrap() +} + +#[test] +fn hatchpick_keeps_points_inside_boundaries_and_changes_nothing() { + let mut s = Session::new(); + rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + let (n, depth) = (count(&s), undo_depth(&s)); + s.start("hatchpick").unwrap(); + s.input(Input::Keyword("Points".into())).unwrap(); + assert!(s.prompt_text().contains("Pick internal point"), "{}", s.prompt_text()); + s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + assert!(!s.preview(Vec2::new(6.0, 6.0)).is_empty(), "the found boundary is previewed"); + // Outside every boundary: reported, not kept. + s.input(Input::Point(Vec2::new(50.0, 50.0))).unwrap(); + assert!(s.log.iter().any(|l| l.contains("Valid hatch boundary not found")), "{:?}", s.log); + s.input(Input::Point(Vec2::new(2.0, 2.0))).unwrap(); + s.input(Input::Keyword("Undo".into())).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.running.is_none()); + let p = s.hatch_picks.clone().unwrap(); + assert_eq!(p.mode, PickMode::Points); + assert_eq!(p.points, vec![Vec2::new(5.0, 5.0)]); + assert_eq!((count(&s), undo_depth(&s)), (n, depth), "nothing added, no undo step"); +} + +#[test] +fn hatchpick_keeps_closed_objects_only() { + let mut s = Session::new(); + let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap(); + let l = last(&s); + s.start("hatchpick").unwrap(); + s.input(Input::Keyword("Objects".into())).unwrap(); + s.input(Input::Pick(vec![h(&r), h(&l)])).unwrap(); + s.input(Input::Enter).unwrap(); + let p = s.hatch_picks.clone().unwrap(); + assert_eq!((p.mode, p.handles), (PickMode::Objects, vec![h(&r)])); +} + +#[test] +fn hatchpick_origin_and_hatch_modes() { + let mut s = Session::new(); + let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + let hx = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()); + s.start("hatchpick").unwrap(); + s.input(Input::Keyword("oRigin".into())).unwrap(); + s.input(Input::Point(Vec2::new(1.0, 2.0))).unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.hatch_picks.clone().unwrap().origin, Some(Vec2::new(1.0, 2.0))); + s.hatch_picks = None; + s.start("hatchpick").unwrap(); + s.input(Input::Keyword("Hatch".into())).unwrap(); + // Not a hatch: asked again. + s.input(Input::Pick(vec![h(&r)])).unwrap(); + assert!(s.running.is_some()); + s.input(Input::Pick(vec![h(&r), h(&hx)])).unwrap(); + assert!(s.running.is_none()); + let p = s.hatch_picks.clone().unwrap(); + assert_eq!((p.mode, p.handles), (PickMode::Hatch, vec![h(&hx)])); +} + +#[test] +fn hatchpick_typed_mode_and_escape_keeps_nothing() { + let mut s = Session::new(); + rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + s.cmdline("hatchpick points").unwrap(); + assert!(s.prompt_text().contains("Pick internal point"), "{}", s.prompt_text()); + s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + s.cancel(); + assert!(s.running.is_none()); + assert!(s.hatch_picks.is_none()); +} + +#[test] +fn hatchpick_json_form_validates_like_the_prompts() { + let mut s = Session::new(); + let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]); + s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap(); + let l = last(&s); + s.execute("hatchpick", &json!({ "points": [[5, 5]], "handles": [r] })).unwrap(); + let p = s.hatch_picks.clone().unwrap(); + assert_eq!((p.points, p.handles), (vec![Vec2::new(5.0, 5.0)], vec![h(&r)])); + let too_many: Vec<[f64; 2]> = vec![[5.0, 5.0]; 1001]; + for bad in [ + json!({}), + json!({ "points": [[50, 50]] }), + json!({ "points": [["a"]] }), + json!({ "handles": [l] }), + json!({ "handles": ["zz"] }), + json!({ "handles": "x" }), + json!({ "points": too_many }), + ] { + s.hatch_picks = None; + assert!(s.execute("hatchpick", &bad).is_err(), "{bad}"); + assert!(s.hatch_picks.is_none(), "{bad}"); + } +} + +#[test] +fn hpdlgmode_defaults_to_1_and_takes_0_or_1() { + let mut s = Session::new(); + assert_eq!(cadcraft_engine::sysvars::get(&s, "HPDLGMODE"), Some(json!(1))); + s.execute("setvar", &json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); + assert_eq!(cadcraft_engine::sysvars::get(&s, "HPDLGMODE"), Some(json!(0))); + assert!(s.execute("setvar", &json!({ "name": "HPDLGMODE", "value": 2 })).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 027c681..0f8fa89 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -898,6 +898,20 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } + // Double-click a hatch (no command running) to edit it in the Hatch Edit dialog. The click is + // used up: it must not also start a grip edit on the hatch's centre grip below. + let mut hatch_dialog = false; + if inside + && dbl_primary + && app.session.running.is_none() + && let Some(hp) = hover_pos + && let Some(h) = hatch_at(app, xf.to_world(hp), scale) + { + app.session.set_selection(vec![h]); + crate::hatchdlg::open_edit(app); + hatch_dialog = true; + } + let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p)); let dyn_frame = crate::cmdline::dyn_frame(app); if let Some(w) = raw_world { @@ -949,7 +963,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { None => app.cmd.options = None, } } - let pressed_primary = pressed_primary && !picked; + let pressed_primary = pressed_primary && !picked && !hatch_dialog; // Clicks. if inside && pressed_primary @@ -1320,6 +1334,15 @@ fn tooltip(p: &egui::Painter, at: Pos2, text: &str) { p.galley(r.min + vec2(4.0, 2.0), galley, Color32::BLACK); } +/// The hatch under world point `w` within the pick aperture (PICKBOX × 1.5 pixels at `scale` +/// pixels per unit), if any. +fn hatch_at(app: &CadApp, w: Vec2, scale: f64) -> Option { + let st = app.session.state().ok()?; + let aperture = app.session.settings.pickbox.max(1.0) * 1.5 / scale.max(1e-12); + let h = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, aperture)?; + matches!(st.doc.entity(h)?.kind, cadcraft_doc::EntityKind::Hatch(_)).then_some(h) +} + /// Half the side of the pick box: the pick aperture (PICKBOX × 1.5 pixels, as `select::pick` is /// called with), so the square shows what a click hits. `None` when PICKBOX is 0 (no box). /// The candidate Tab has cycled to, if cycling: a Tab press starts or advances it, and the @@ -1824,3 +1847,51 @@ mod tests { /// The COMPARE overlay. mod compare; + +#[cfg(test)] +mod hatch_pick_tests { + use cadcraft_engine::Session; + use cadcraft_geom::Vec2; + use serde_json::json; + + use crate::{CadApp, Services}; + + #[test] + fn hatch_at_finds_a_hatch_under_the_cursor_only() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.session.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap()["handle"].as_str().unwrap().to_string(); + app.session.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap(); + assert_eq!(super::hatch_at(&app, Vec2::new(5.0, 5.0), 10.0).map(|h| h.hex()), Some(hx)); + assert_eq!(super::hatch_at(&app, Vec2::new(25.0, 0.0), 10.0), None, "a line is not a hatch"); + assert_eq!(super::hatch_at(&app, Vec2::new(60.0, 60.0), 10.0), None); + } + + #[test] + fn double_clicking_a_hatch_opens_the_hatch_edit_dialog() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.session.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap()["handle"].as_str().unwrap().to_string(); + let _ = app.session.zoom_extents(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))); + let frame = |app: &mut CadApp, events: Vec, time: f64| { + app.logic(&ctx); + let input = egui::RawInput { screen_rect: screen, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + }; + frame(&mut app, Vec::new(), 0.0); + let at = app.canvas.xf.unwrap().to_screen(Vec2::new(5.0, 5.0)); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at)], 0.1); + frame(&mut app, vec![button(true), button(false)], 0.2); + frame(&mut app, vec![button(true), button(false)], 0.3); + frame(&mut app, Vec::new(), 0.4); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.as_ref().map(|s| s.edit.iter().map(|h| h.hex()).collect::>()), Some(vec![hx])); + // The double-click (at the hatch's centre grip) only opens the dialog: no grip edit. + assert!(app.session.running.is_none(), "{:?}", app.session.running.as_ref().map(|r| r.machine.name())); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7d95bf..d353655 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open), "dsettings" => dsettings(app, ctx, &mut open), "units" => units(app, ctx, &mut open), + "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open), "preview" => crate::exchange::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open), diff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs new file mode 100644 index 0000000..7388e5a --- /dev/null +++ b/crates/ui-egui/src/hatchdlg.rs @@ -0,0 +1,968 @@ +//! The Hatch and Gradient dialog (HATCH, GRADIENT) and, on existing hatches, the Hatch Edit +//! dialog (HATCHEDIT, double-click): one window in two modes. It never edits the drawing itself: +//! picks go through HATCHPICK, and OK runs one HATCH, GRADIENT or HATCHEDIT call. + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, EntityKind, Handle, Space}; +use cadcraft_engine::{HatchPicks, PickMode}; +use serde_json::{Value, json}; + +use egui::vec2; + +use crate::CadApp; +use crate::theme::Tokens; + +/// The gradient shapes the dialog offers; each can also be inverted (`INV` + name). +pub(crate) const SHAPES: [&str; 5] = ["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL"]; + +/// HPORIGINMODE 1–5, by the names HATCH's `originMode` takes. +pub(crate) const ORIGIN_MODES: [&str; 5] = ["bottomLeft", "bottomRight", "topRight", "topLeft", "center"]; + +/// The fill and options the dialog shows. +#[derive(Clone, Debug, PartialEq)] +pub struct Fill { + pub gradient: bool, + pub pattern: String, + pub scale: f64, + /// Pattern or gradient angle, degrees. + pub angle: f64, + /// One of [`SHAPES`]. + pub shape: String, + pub inverted: bool, + pub color1: Color, + pub color2: Color, + pub centered: bool, + /// "Specified origin" rather than "Use current origin". + pub specified: bool, + /// A clicked origin; when `None` a specified origin is `origin_mode` of the extents. + pub origin: Option<[f64; 2]>, + /// One of [`ORIGIN_MODES`]. + pub origin_mode: String, + pub associative: bool, + /// Island detection: 0 normal, 1 outer, 2 ignore. + pub style: u8, +} + +/// The dialog while open (and while a pick it started runs). +#[derive(Clone, Debug, PartialEq)] +pub struct HatchDlgState { + pub fill: Fill, + /// The fill as the dialog opened: edit mode sends only what changed. + pub opened: Fill, + /// The hatches being edited; empty in create mode. + pub edit: Vec, + pub separate: bool, + /// Internal points and boundary objects picked so far (create mode). + pub points: Vec<[f64; 2]>, + pub handles: Vec, + /// A HATCHPICK the dialog started that hasn't ended yet. + pub picking: Option, + /// The last command error, shown in the dialog. + pub error: Option, + /// The drawing the dialog belongs to (`DocState::uid`). + pub uid: u64, + /// The space its picks were made in (`DocState::edit_space`, where HATCH adds the hatch): a + /// switch to another layout drops the dialog. + pub space: Space, +} + +/// Create mode, from the drawing's HATCH settings (HPNAME, HPSCALE, HPANG, HPASSOC, HPSEPARATE, +/// HPORIGINMODE, HPISLANDDETECTION); values a file got wrong fall back to the defaults. +pub fn create_state(d: &Drawing, uid: u64, gradient: bool) -> HatchDlgState { + let h = &d.header; + let scale = Some(h.f64("HPSCALE", 1.0)).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0); + let angle = Some(h.f64("HPANG", 0.0).to_degrees()).filter(|v| v.is_finite()).unwrap_or(0.0); + let mode = h.i64("HPORIGINMODE", 0); + let origin_mode = mode.checked_sub(1).and_then(|i| usize::try_from(i).ok()).and_then(|i| ORIGIN_MODES.get(i)).copied().unwrap_or("bottomLeft"); + let fill = Fill { + gradient, + pattern: h.str("HPNAME", "ANSI31"), + scale, + angle, + shape: "LINEAR".into(), + inverted: false, + color1: Color::Index(5), + color2: Color::Index(7), + centered: true, + // As HATCH reads HPORIGINMODE: 1–5 a corner or the centre, anything else the current origin. + specified: (1..=5).contains(&mode), + origin: None, + origin_mode: origin_mode.into(), + associative: h.i64("HPASSOC", 1) != 0, + style: u8::try_from(h.i64("HPISLANDDETECTION", 0).clamp(0, 2)).unwrap_or(0), + }; + HatchDlgState { + opened: fill.clone(), + fill, + edit: Vec::new(), + separate: h.i64("HPSEPARATE", 0) != 0, + points: Vec::new(), + handles: Vec::new(), + picking: None, + error: None, + uid, + space: Space::Model, + } +} + +/// Edit mode on the hatches among `handles`, showing the first one; `None` when there are none. +pub fn edit_state(d: &Drawing, uid: u64, handles: Vec) -> Option { + let hatches: Vec = handles.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).collect(); + let first = hatches.first().and_then(|h| match &d.entity(*h)?.kind { + EntityKind::Hatch(x) => Some(x.clone()), + _ => None, + })?; + let mut st = create_state(d, uid, first.gradient.is_some()); + let f = &mut st.fill; + // A gradient's own pattern is SOLID; its Pattern tab offers the current pattern (HPNAME). + if first.gradient.is_none() { + f.pattern = first.pattern.clone(); + } + f.scale = if first.scale.is_finite() && first.scale > 0.0 { first.scale } else { 1.0 }; + f.angle = Some(first.angle.to_degrees()).filter(|a| a.is_finite()).unwrap_or(0.0); + f.associative = first.associative; + f.style = first.style.min(2); + f.specified = false; + f.origin = None; + if let Some(g) = &first.gradient { + let up = g.name.trim().to_ascii_uppercase(); + let (inverted, base) = match up.strip_prefix("INV") { + Some(b) => (true, b.to_string()), + None => (false, up.clone()), + }; + f.shape = if SHAPES.contains(&base.as_str()) { base } else { "LINEAR".into() }; + f.inverted = inverted; + f.color1 = g.color1; + f.color2 = g.color2; + f.angle = Some(g.angle.to_degrees()).filter(|a| a.is_finite()).unwrap_or(0.0); + f.centered = g.centered; + } + st.opened = st.fill.clone(); + st.edit = hatches; + st.separate = false; + Some(st) +} + +fn style_name(s: u8) -> &'static str { + match s { + 1 => "outer", + 2 => "ignore", + _ => "normal", + } +} + +fn gradient_name(f: &Fill) -> String { + format!("{}{}", if f.inverted { "INV" } else { "" }, f.shape) +} + +fn hexes(hs: &[Handle]) -> Value { + json!(hs.iter().map(|h| h.hex()).collect::>()) +} + +/// The command OK runs: HATCH or GRADIENT with the picks (create mode), or HATCHEDIT with what +/// changed (edit mode). `None` when there is nothing to run. +pub fn ok_call(st: &HatchDlgState) -> Option<(&'static str, Value)> { + let f = &st.fill; + let mut p = serde_json::Map::new(); + if st.edit.is_empty() { + if st.points.is_empty() && st.handles.is_empty() { + return None; + } + if !st.points.is_empty() { + p.insert("points".into(), json!(st.points)); + } + if !st.handles.is_empty() { + p.insert("handles".into(), hexes(&st.handles)); + } + if f.gradient { + p.insert("name".into(), json!(gradient_name(f))); + p.insert("color1".into(), json!(f.color1.name())); + p.insert("color2".into(), json!(f.color2.name())); + p.insert("centered".into(), json!(f.centered)); + } else { + p.insert("pattern".into(), json!(f.pattern)); + p.insert("scale".into(), json!(f.scale)); + } + p.insert("angle".into(), json!(f.angle)); + p.insert("associative".into(), json!(f.associative)); + p.insert("separate".into(), json!(st.separate)); + p.insert("style".into(), json!(style_name(f.style))); + match (f.specified, f.origin) { + (true, Some(o)) => p.insert("origin".into(), json!(o)), + (true, None) => p.insert("originMode".into(), json!(f.origin_mode)), + (false, _) => p.insert("originMode".into(), json!("current")), + }; + return Some((if f.gradient { "gradient" } else { "hatch" }, Value::Object(p))); + } + let o = &st.opened; + if f.gradient { + let changed = !o.gradient + || f.shape != o.shape + || f.inverted != o.inverted + || f.color1 != o.color1 + || f.color2 != o.color2 + || f.angle != o.angle + || f.centered != o.centered; + if changed { + p.insert( + "gradient".into(), + json!({ "name": gradient_name(f), "color1": f.color1.name(), "color2": f.color2.name(), "angle": f.angle, "centered": f.centered }), + ); + } + } else { + if o.gradient || f.pattern != o.pattern { + p.insert("pattern".into(), json!(f.pattern)); + } + if f.scale != o.scale { + p.insert("scale".into(), json!(f.scale)); + } + if f.angle != o.angle { + p.insert("angle".into(), json!(f.angle)); + } + } + if f.associative != o.associative { + p.insert("associative".into(), json!(f.associative)); + } + if f.style != o.style { + p.insert("style".into(), json!(style_name(f.style))); + } + if f.specified { + match f.origin { + Some(q) => p.insert("origin".into(), json!(q)), + None => p.insert("originMode".into(), json!(f.origin_mode)), + }; + } + if p.is_empty() { + return None; + } + p.insert("handles".into(), hexes(&st.edit)); + Some(("hatchedit", Value::Object(p))) +} + +/// Take a HATCHPICK's picks into the dialog (Hatch picks are taken by `resume`). +pub fn merge_picks(st: &mut HatchDlgState, picks: HatchPicks) { + match picks.mode { + PickMode::Points | PickMode::Objects => { + st.points.extend(picks.points.iter().map(|q| [q.x, q.y])); + for h in picks.handles { + if !st.handles.contains(&h) { + st.handles.push(h); + } + } + } + PickMode::Origin => { + if let Some(q) = picks.origin { + st.fill.origin = Some([q.x, q.y]); + st.fill.specified = true; + } + } + PickMode::Hatch => {} + } + st.error = None; +} + +/// Whether typed or menu HATCH and GRADIENT open the dialog (HPDLGMODE, default 1). +pub fn dialog_on(app: &CadApp) -> bool { + app.session.doc().is_ok_and(|d| d.header.i64("HPDLGMODE", 1) != 0) +} + +/// Create mode (HATCH, GRADIENT): the dialog from the drawing's hatch settings. +pub fn open_create(app: &mut CadApp, gradient: bool) { + let Ok(st) = app.session.state() else { return }; + let mut dlg = create_state(&st.doc, st.uid, gradient); + dlg.space = st.edit_space(); + app.hatch_dlg = Some(dlg); + app.ui.dialog = Some("hatch".into()); +} + +/// Edit mode (HATCHEDIT, right-click, double-click) on the selected hatches; with none selected, +/// pick a hatch first. +pub fn open_edit(app: &mut CadApp) { + let Ok(st) = app.session.state() else { return }; + let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.selection.clone()), create_state(&st.doc, st.uid, false)); + create.space = st.edit_space(); + if let Some(e) = edit.as_mut() { + e.space = st.edit_space(); + } + match edit { + Some(e) => { + app.hatch_dlg = Some(e); + app.ui.dialog = Some("hatch".into()); + } + None => { + app.hatch_dlg = Some(create); + start_pick(app, PickMode::Hatch); + } + } +} + +/// Hide the dialog and run HATCHPICK in `mode`; [`resume`] brings the dialog back. +pub fn start_pick(app: &mut CadApp, mode: PickMode) { + let Some(st) = app.hatch_dlg.as_mut() else { return }; + st.picking = Some(mode); + st.error = None; + app.session.hatch_picks = None; + app.ui.dialog = None; + app.start("hatchpick"); + let kw = match mode { + PickMode::Points => "Points", + PickMode::Objects => "Objects", + PickMode::Origin => "oRigin", + PickMode::Hatch => "Hatch", + }; + if app.session.running.is_some() + && let Err(e) = app.session.input(cadcraft_engine::Input::Keyword(kw.into())) + { + app.session.echo(e.to_string()); + } +} + +/// Every frame: once a HATCHPICK the dialog started has ended, take its picks and bring the +/// dialog back (unchanged when the pick was cancelled). A different drawing by then drops the +/// dialog and its picks; a Hatch pick that found none closes it. +pub fn resume(app: &mut CadApp) { + let Some(mode) = app.hatch_dlg.as_ref().and_then(|s| s.picking) else { return }; + if app.session.running.is_some() { + return; + } + let picks = app.session.hatch_picks.take(); + let here = app.session.state().ok().map(|s| (s.uid, s.edit_space())); + let Some(mut st) = app.hatch_dlg.take() else { return }; + if here != Some((st.uid, st.space.clone())) { + return; + } + st.picking = None; + if mode == PickMode::Hatch { + let next = picks.and_then(|p| app.session.doc().ok().and_then(|d| edit_state(d, st.uid, p.handles))); + let Some(mut next) = next else { return }; + next.space = st.space.clone(); + st = next; + } else if let Some(p) = picks { + merge_picks(&mut st, p); + } + app.hatch_dlg = Some(st); + app.ui.dialog = Some("hatch".into()); +} + +/// OK: run the dialog's command. True when the dialog closes; a failed command keeps it open +/// with the error (the drawing is unchanged). +pub fn ok(app: &mut CadApp) -> bool { + let Some(call) = app.hatch_dlg.as_ref().map(ok_call) else { return true }; + let Some((cmd, p)) = call else { + app.hatch_dlg = None; + return true; + }; + match app.run(cmd, p) { + Ok(_) => { + app.hatch_dlg = None; + true + } + Err(e) => { + if let Some(st) = app.hatch_dlg.as_mut() { + st.error = Some(e); + } + false + } + } +} + +enum Action { + None, + Ok, + Cancel, + Pick(PickMode), +} + +fn shape_label(name: &str) -> &'static str { + match name { + "CYLINDER" => crate::tl!("Cylinder"), + "CURVED" => crate::tl!("Curved"), + "SPHERICAL" => crate::tl!("Spherical"), + "HEMISPHERICAL" => crate::tl!("Hemispherical"), + _ => crate::tl!("Linear"), + } +} + +fn origin_label(mode: &str) -> &'static str { + match mode { + "bottomRight" => crate::tl!("Bottom right"), + "topRight" => crate::tl!("Top right"), + "topLeft" => crate::tl!("Top left"), + "center" => crate::tl!("Center"), + _ => crate::tl!("Bottom left"), + } +} + +fn num(v: f64) -> String { + let s = format!("{v:.4}"); + if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } +} + +/// The Hatch and Gradient / Hatch Edit window (`app.ui.dialog == "hatch"`). +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let here = app.session.state().ok().map(|s| (s.uid, s.edit_space())); + // Opened by name (control channel) with no state: create mode from the header. + if app.hatch_dlg.is_none() { + open_create(app, false); + } + // No drawing, or another drawing or layout than the one the dialog was opened on: close. + let Some(mut st) = app.hatch_dlg.take().filter(|s| here.as_ref() == Some(&(s.uid, s.space.clone()))) else { + *open = false; + return; + }; + let title = if st.edit.is_empty() { crate::tl!("Hatch and Gradient") } else { crate::tl!("Hatch Edit") }; + let mut action = Action::None; + egui::Window::new(title).id(egui::Id::new("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(340.0); + fill_ui(ui, &mut st.fill, &mut action); + }); + // Space, not a vertical separator: a separator takes the height available, and the + // window grew by it every frame until the buttons left the screen. + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_width(230.0); + options_ui(ui, &mut st, &mut action); + }); + }); + if let Some(e) = &st.error { + ui.colored_label(Tokens::get().warn, e); + } + ui.separator(); + ui.horizontal(|ui| { + let ready = !st.edit.is_empty() || !(st.points.is_empty() && st.handles.is_empty()); + if ui.add_enabled(ready, egui::Button::new(crate::tl!("OK"))).clicked() { + action = Action::Ok; + } + if ui.button(crate::tl!("Cancel")).clicked() { + action = Action::Cancel; + } + }); + }); + app.hatch_dlg = Some(st); + match action { + Action::Ok => { + if ok(app) { + *open = false; + } + } + Action::Cancel => { + app.hatch_dlg = None; + *open = false; + } + Action::Pick(mode) => start_pick(app, mode), + Action::None => { + if !*open { + app.hatch_dlg = None; + } + } + } +} + +fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { + ui.horizontal(|ui| { + ui.selectable_value(&mut f.gradient, false, crate::tl!("Pattern")); + ui.selectable_value(&mut f.gradient, true, crate::tl!("Gradient")); + }); + ui.separator(); + if f.gradient { + egui::Grid::new("hatch_gradient").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + ui.label(crate::tl!("Color 1")); + if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color1"), f.color1, false, 160.0) { + f.color1 = c; + } + ui.end_row(); + ui.label(crate::tl!("Color 2")); + if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color2"), f.color2, false, 160.0) { + f.color2 = c; + } + ui.end_row(); + ui.label(crate::tl!("Shape")); + egui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| { + for name in SHAPES { + ui.selectable_value(&mut f.shape, name.to_string(), shape_label(name)); + } + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut f.inverted, crate::tl!("Inverted")); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut f.centered, crate::tl!("Centered")); + ui.end_row(); + ui.label(crate::tl!("Angle")); + ui.add(egui::DragValue::new(&mut f.angle).speed(1.0).range(-360.0..=360.0).clamp_existing_to_range(false).suffix("°")); + ui.end_row(); + }); + } else { + egui::ScrollArea::vertical().id_salt("hatch_swatches").max_height(220.0).show(ui, |ui| { + if let Some(name) = crate::palettes::patterns::grid(ui, &f.pattern, "") { + f.pattern = name; + } + }); + egui::Grid::new("hatch_pattern").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + ui.label(crate::tl!("Angle")); + ui.add(egui::DragValue::new(&mut f.angle).speed(1.0).range(-360.0..=360.0).clamp_existing_to_range(false).suffix("°")); + ui.end_row(); + ui.label(crate::tl!("Scale")); + ui.add(egui::DragValue::new(&mut f.scale).speed(0.05).range(0.0001..=1.0e6).clamp_existing_to_range(false)); + ui.end_row(); + }); + } + ui.separator(); + ui.strong(crate::tl!("Hatch origin")); + ui.radio_value(&mut f.specified, false, crate::tl!("Use current origin")); + ui.radio_value(&mut f.specified, true, crate::tl!("Specified origin")); + ui.add_enabled_ui(f.specified, |ui| { + if ui.button(crate::tl!("Click to set new origin")).clicked() { + *action = Action::Pick(PickMode::Origin); + } + if let Some([x, y]) = f.origin { + ui.label(crate::tf!("Origin: {x}, {y}", x = num(x), y = num(y))); + } + let mut extents = f.origin.is_none(); + if ui.add_enabled(f.origin.is_some(), egui::Checkbox::new(&mut extents, crate::tl!("Default to boundary extents"))).changed() && extents { + f.origin = None; + } + ui.add_enabled_ui(f.origin.is_none(), |ui| { + egui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| { + for m in ORIGIN_MODES { + ui.selectable_value(&mut f.origin_mode, m.to_string(), origin_label(m)); + } + }); + }); + }); +} + +fn options_ui(ui: &mut egui::Ui, st: &mut HatchDlgState, action: &mut Action) { + if st.edit.is_empty() { + ui.strong(crate::tl!("Boundaries")); + if ui.button(crate::tl!("Add: Pick points")).clicked() { + *action = Action::Pick(PickMode::Points); + } + if ui.button(crate::tl!("Add: Select objects")).clicked() { + *action = Action::Pick(PickMode::Objects); + } + ui.horizontal(|ui| { + ui.label(crate::tf!("Boundaries picked: {n}", n = st.points.len() + st.handles.len())); + let any = !(st.points.is_empty() && st.handles.is_empty()); + if ui.add_enabled(any, egui::Button::new(crate::tl!("Clear"))).clicked() { + st.points.clear(); + st.handles.clear(); + } + }); + ui.separator(); + } + ui.strong(crate::tl!("Options")); + ui.checkbox(&mut st.fill.associative, crate::tl!("Associative")); + if st.edit.is_empty() { + ui.checkbox(&mut st.separate, crate::tl!("Create separate hatches")); + } + ui.label(crate::tl!("Island detection")); + ui.radio_value(&mut st.fill.style, 0, crate::tl!("Normal")); + ui.radio_value(&mut st.fill.style, 1, crate::tl!("Outer")); + ui.radio_value(&mut st.fill.style, 2, crate::tl!("Ignore")); +} + +#[cfg(test)] +mod tests { + use cadcraft_color::Color; + use cadcraft_doc::Handle; + use cadcraft_engine::geom::Vec2; + use cadcraft_engine::{HatchPicks, PickMode, Session}; + use serde_json::json; + + use super::*; + + fn session_with_rect() -> (Session, Handle) { + let mut s = Session::new(); + let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) + } + + use crate::{CadApp, Services}; + + fn app_with_rect() -> (CadApp, Handle) { + let (s, r) = session_with_rect(); + (CadApp::new(s, Services::default()), r) + } + + #[test] + fn pick_points_hides_the_dialog_and_brings_it_back_with_the_points() { + let (mut app, _) = app_with_rect(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Points); + assert_eq!(app.ui.dialog, None, "hidden while picking"); + assert!(app.session.prompt_text().contains("Pick internal point")); + resume(&mut app); + assert_eq!(app.ui.dialog, None, "still picking"); + app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + let st = app.hatch_dlg.clone().unwrap(); + assert_eq!((st.points, st.picking), (vec![[5.0, 5.0]], None)); + } + + #[test] + fn escape_brings_the_dialog_back_unchanged() { + let (mut app, r) = app_with_rect(); + open_create(&mut app, false); + if let Some(st) = app.hatch_dlg.as_mut() { + st.handles.push(r); + } + let before = app.hatch_dlg.clone(); + start_pick(&mut app, PickMode::Points); + app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + app.session.cancel(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg, before); + } + + #[test] + fn switching_drawings_drops_the_dialog() { + let (mut app, _) = app_with_rect(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Points); + app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + app.session.new_drawing(false); + resume(&mut app); + assert_eq!(app.ui.dialog, None); + assert_eq!(app.hatch_dlg, None); + } + + #[test] + fn hatch_pick_opens_edit_mode_or_nothing() { + let (mut app, _) = app_with_rect(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Hatch); + app.session.input(cadcraft_engine::Input::Pick(vec![Handle::parse_hex(&hx).unwrap()])).unwrap(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.clone().unwrap().edit, vec![Handle::parse_hex(&hx).unwrap()]); + // Nothing picked: no dialog. + start_pick(&mut app, PickMode::Hatch); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + resume(&mut app); + assert_eq!((app.ui.dialog.clone(), app.hatch_dlg.clone()), (None, None)); + } + + #[test] + fn create_state_reads_the_header_and_survives_bad_values() { + let (mut s, _) = session_with_rect(); + for (k, v) in [("HPNAME", json!("BRICK")), ("HPSCALE", json!(2.5)), ("HPANG", json!(30)), ("HPSEPARATE", json!(1)), ("HPASSOC", json!(0))] { + s.execute("setvar", &json!({ "name": k, "value": v })).unwrap(); + } + let st = create_state(s.doc().unwrap(), 7, false); + let f = &st.fill; + assert_eq!((f.pattern.as_str(), f.scale, f.associative, st.separate, st.uid), ("BRICK", 2.5, false, true, 7)); + assert!((f.angle - 30.0).abs() < 1e-9); + assert!(st.edit.is_empty() && st.points.is_empty() && st.handles.is_empty()); + assert_eq!(st.fill, st.opened); + // Hostile header values from a file. + let h = &mut s.doc_mut().unwrap().header; + h.set_f64("HPSCALE", f64::NAN); + h.set_f64("HPANG", f64::INFINITY); + h.set_i64("HPORIGINMODE", i64::MIN); + h.set_i64("HPISLANDDETECTION", -5); + let st = create_state(s.doc().unwrap(), 7, true); + // An HPORIGINMODE out of 0–5 is "use current", as HATCH reads it. + assert_eq!((st.fill.scale, st.fill.angle, st.fill.style, st.fill.specified), (1.0, 0.0, 0, false)); + assert!(ORIGIN_MODES.contains(&st.fill.origin_mode.as_str())); + assert!(st.fill.gradient); + s.doc_mut().unwrap().header.set_f64("HPSCALE", 0.0); + s.doc_mut().unwrap().header.set_i64("HPORIGINMODE", 99); + let st = create_state(s.doc().unwrap(), 7, false); + assert_eq!((st.fill.scale, st.fill.specified), (1.0, false)); + assert!(ORIGIN_MODES.contains(&st.fill.origin_mode.as_str())); + s.doc_mut().unwrap().header.set_i64("HPORIGINMODE", 3); + let st = create_state(s.doc().unwrap(), 7, false); + assert_eq!((st.fill.specified, st.fill.origin_mode.as_str()), (true, "topRight")); + } + + #[test] + fn create_ok_hatches_points_and_objects_in_one_call() { + let (s, r) = session_with_rect(); + let mut st = create_state(s.doc().unwrap(), 1, false); + assert_eq!(ok_call(&st), None, "no boundary yet"); + st.points.push([5.0, 5.0]); + st.handles.push(r); + st.fill.pattern = "ANSI37".into(); + st.fill.scale = 2.0; + st.fill.angle = 15.0; + st.fill.style = 1; + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "hatch"); + assert_eq!(p["points"], json!([[5.0, 5.0]])); + assert_eq!(p["handles"], json!([r.hex()])); + assert_eq!((p["pattern"].as_str(), p["scale"].as_f64(), p["angle"].as_f64()), (Some("ANSI37"), Some(2.0), Some(15.0))); + assert_eq!((p["style"].as_str(), p["originMode"].as_str()), (Some("outer"), Some("current"))); + // A clicked origin, then a corner of the extents. + st.fill.specified = true; + st.fill.origin = Some([1.0, 2.0]); + assert_eq!(ok_call(&st).unwrap().1["origin"], json!([1.0, 2.0])); + st.fill.origin = None; + st.fill.origin_mode = "topRight".into(); + assert_eq!(ok_call(&st).unwrap().1["originMode"], json!("topRight")); + // Gradient. + st.fill.gradient = true; + st.fill.shape = "SPHERICAL".into(); + st.fill.inverted = true; + st.fill.color1 = Color::Index(1); + st.fill.centered = false; + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "gradient"); + assert_eq!((p["name"].as_str(), p["color1"].as_str(), p["centered"].as_bool()), (Some("INVSPHERICAL"), Some("Red"), Some(false))); + assert!(p.get("pattern").is_none()); + } + + #[test] + fn create_ok_runs_and_makes_one_hatch() { + let (mut s, _) = session_with_rect(); + s.execute("circle", &json!({ "center": [30, 5], "radius": 3 })).unwrap(); + let c = s.doc().unwrap().model.iter().last().unwrap().handle; + let mut st = create_state(s.doc().unwrap(), 1, false); + st.points.push([5.0, 5.0]); + st.handles.push(c); + let (cmd, p) = ok_call(&st).unwrap(); + s.execute(cmd, &p).unwrap(); + let n = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_))).count(); + assert_eq!(n, 1); + } + + #[test] + fn edit_ok_sends_only_what_changed() { + let (mut s, _) = session_with_rect(); + let h = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31", "scale": 2 })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&h).unwrap(); + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert_eq!((st.fill.pattern.as_str(), st.fill.scale, st.edit.clone()), ("ANSI31", 2.0, vec![h])); + assert_eq!(ok_call(&st), None, "nothing changed: OK runs nothing"); + st.fill.scale = 3.0; + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "hatchedit"); + assert_eq!(p, json!({ "handles": [h.hex()], "scale": 3.0 })); + // To a gradient: the whole gradient, no pattern fields. + st.fill.scale = 2.0; + st.fill.gradient = true; + let (_, p) = ok_call(&st).unwrap(); + assert_eq!(p["gradient"]["name"], json!("LINEAR")); + assert!(p.get("pattern").is_none() && p.get("scale").is_none()); + s.execute("hatchedit", &p).unwrap(); + // Back from a gradient hatch: the pattern is sent even if its name didn't change. + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert!(st.fill.gradient); + st.fill.gradient = false; + st.fill.pattern = "ANSI31".into(); + assert_eq!(ok_call(&st).unwrap().1["pattern"], json!("ANSI31")); + // Not a hatch: no edit state. + let line = s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + assert!(edit_state(s.doc().unwrap(), 1, vec![Handle::parse_hex(&line).unwrap()]).is_none()); + } + + #[test] + fn picks_merge_into_the_state() { + let (s, r) = session_with_rect(); + let mut st = create_state(s.doc().unwrap(), 1, false); + st.error = Some("old".into()); + merge_picks(&mut st, HatchPicks { mode: PickMode::Points, points: vec![Vec2::new(5.0, 5.0)], ..HatchPicks::default() }); + merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![r, r], ..HatchPicks::default() }); + merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![r], ..HatchPicks::default() }); + assert_eq!((st.points.clone(), st.handles.clone(), st.error.clone()), (vec![[5.0, 5.0]], vec![r], None)); + merge_picks(&mut st, HatchPicks { mode: PickMode::Origin, origin: Some(Vec2::new(1.0, 2.0)), ..HatchPicks::default() }); + assert_eq!((st.fill.specified, st.fill.origin), (true, Some([1.0, 2.0]))); + } + + fn frames(app: &mut CadApp) -> egui::Context { + let ctx = egui::Context::default(); + app.logic(&ctx); + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + // Enough frames for a window that grows each frame to show it. + for _ in 0..30 { + let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui)); + output.textures_delta.clear(); + } + ctx + } + + #[test] + fn the_dialog_fits_on_screen_with_its_buttons() { + for gradient in [false, true] { + let (mut app, _) = app_with_rect(); + open_create(&mut app, gradient); + let ctx = frames(&mut app); + let r = ctx.memory(|m| m.area_rect(egui::Id::new("hatch_dialog"))).unwrap(); + // The screen is 1000 points high: the window keeps to its content. + assert!(r.height() < 700.0, "gradient {gradient}: {r:?}"); + } + } + + #[test] + fn the_dialog_draws_both_modes_and_tabs_and_stays_open() { + let (mut app, _) = app_with_rect(); + // Opened by name with no state: create mode from the header. + app.ui.dialog = Some("hatch".into()); + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert!(app.hatch_dlg.as_ref().is_some_and(|s| s.edit.is_empty())); + if let Some(st) = app.hatch_dlg.as_mut() { + st.fill.gradient = true; + } + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + let st = app.session.state().unwrap(); + app.hatch_dlg = edit_state(&st.doc, st.uid, vec![Handle::parse_hex(&hx).unwrap()]); + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + } + + #[test] + fn ok_makes_the_hatch_and_closes() { + let (mut app, _) = app_with_rect(); + open_create(&mut app, false); + if let Some(st) = app.hatch_dlg.as_mut() { + st.points.push([5.0, 5.0]); + } + assert!(ok(&mut app)); + assert_eq!(app.hatch_dlg, None); + assert!(app.session.doc().unwrap().model.iter().any(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_)))); + } + + #[test] + fn a_failed_ok_keeps_the_dialog_open_with_the_error() { + let (mut app, r) = app_with_rect(); + open_create(&mut app, false); + if let Some(st) = app.hatch_dlg.as_mut() { + st.handles.push(r); + } + // The picked boundary is erased before OK. + app.session.execute("erase", &json!({ "handles": [r.hex()] })).unwrap(); + let n = app.session.doc().unwrap().model.len(); + assert!(!ok(&mut app)); + let st = app.hatch_dlg.clone().unwrap(); + assert!(st.error.is_some_and(|e| !e.is_empty())); + assert_eq!(app.session.doc().unwrap().model.len(), n, "drawing unchanged"); + } + + #[test] + fn typed_and_menu_hatch_open_the_dialog_but_dash_and_json_do_not() { + let (mut app, _) = app_with_rect(); + app.cmdline("hatch"); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert!(app.session.running.is_none()); + assert!(!app.hatch_dlg.clone().unwrap().fill.gradient); + for (typed, gradient) in [("h", false), ("bh", false), ("gradient", true), ("gd", true)] { + app.ui.dialog = None; + app.cmdline(typed); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch"), "{typed}"); + assert_eq!(app.hatch_dlg.clone().unwrap().fill.gradient, gradient, "{typed}"); + } + app.ui.dialog = None; + app.start("hatch"); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch"), "menu"); + // -HATCH: the command line. + app.ui.dialog = None; + app.cmdline("-hatch"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.prompt_text().contains("Pick internal point"), "{}", app.session.prompt_text()); + app.session.cancel(); + // JSON: runs, never opens. + app.run("hatch", json!({ "points": [[5, 5]] })).unwrap(); + assert_eq!(app.ui.dialog, None); + app.cmdline("hatch {\"points\": [[5, 5]]}"); + assert_eq!(app.ui.dialog, None); + // HPDLGMODE 0: the command line. + app.run("setvar", json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); + app.cmdline("hatch"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.running.is_some()); + } + + #[test] + fn hatchedit_opens_edit_mode_on_the_selection_or_asks_for_a_hatch() { + let (mut app, _) = app_with_rect(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&hx).unwrap(); + // Nothing selected: pick one first. + app.cmdline("hatchedit"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.prompt_text().contains("Select hatch object"), "{}", app.session.prompt_text()); + app.session.input(cadcraft_engine::Input::Pick(vec![h])).unwrap(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.clone().unwrap().edit, vec![h]); + // Selected (right-click Hatch Edit, typed HE), also with HPDLGMODE 0. + app.ui.dialog = None; + app.hatch_dlg = None; + app.run("setvar", json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); + app.session.set_selection(vec![h]); + app.start("he"); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + // OK applies the change. + if let Some(st) = app.hatch_dlg.as_mut() { + st.fill.pattern = "BRICK".into(); + } + assert!(ok(&mut app)); + let d = app.session.doc().unwrap(); + assert!(matches!(&d.entity(h).unwrap().kind, cadcraft_doc::EntityKind::Hatch(x) if x.pattern == "BRICK")); + // -HATCHEDIT: the command line (the window, not `ok`, closes the dialog). + app.ui.dialog = None; + app.cmdline("-hatchedit"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.running.is_some()); + } + + #[test] + fn edit_mode_on_an_out_of_range_hatch_changes_nothing_by_itself() { + let (mut app, _) = app_with_rect(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "scale": 5.0e6, "angle": 400 })).unwrap()["handle"] + .as_str() + .unwrap() + .to_string(); + app.session.set_selection(vec![Handle::parse_hex(&hx).unwrap()]); + open_edit(&mut app); + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.as_ref().and_then(ok_call), None, "the widgets must not clamp the hatch's own values"); + } + + #[test] + fn switching_to_a_layout_drops_the_picks() { + let (mut app, r) = app_with_rect(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Objects); + app.session.input(cadcraft_engine::Input::Pick(vec![r])).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + app.run("layout", json!({ "option": "set", "name": "Layout1" })).unwrap(); + resume(&mut app); + assert_eq!((app.ui.dialog.clone(), app.hatch_dlg.clone()), (None, None), "model-space picks never hatch a layout"); + // An open dialog closes too. + app.run("layout", json!({ "option": "set", "name": "Model" })).unwrap(); + open_create(&mut app, false); + app.run("layout", json!({ "option": "set", "name": "Layout1" })).unwrap(); + frames(&mut app); + assert_eq!(app.hatch_dlg, None); + } + + #[test] + fn a_gradient_hatch_offers_the_current_pattern_on_the_pattern_tab() { + let (mut s, _) = session_with_rect(); + let h = s.execute("gradient", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&h).unwrap(); + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert_eq!(st.fill.pattern, "ANSI31", "HPNAME, not the gradient's SOLID"); + st.fill.gradient = false; + let (cmd, p) = ok_call(&st).unwrap(); + s.execute(cmd, &p).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + cadcraft_doc::EntityKind::Hatch(x) => assert!(x.gradient.is_none() && x.pattern == "ANSI31"), + k => panic!("{k:?}"), + } + } +} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6bd9804..0be170b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -96,6 +96,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { include_str!("../palettes/quickcalc.rs"), include_str!("../palettes/patterns.rs"), include_str!("../dialogs.rs"), + include_str!("../hatchdlg.rs"), include_str!("../layers.rs"), include_str!("../canvas/compare.rs"), include_str!("../palettes.rs"), @@ -348,6 +349,7 @@ fn dialogs_render_in_ukrainian_and_keep_their_source_ids() { ("qselect", "Швидкий вибір"), ("parameters", "Диспетчер параметрів"), ("language", "Мова інтерфейсу"), + ("hatch", "Штрихування та градієнт"), ] { let mut app = CadApp::new(Session::new(), Services::default()); app.ui.interface_language = Preference::Ukrainian; @@ -541,3 +543,8 @@ fn chinese_needs_an_installed_cjk_font() { assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("No Chinese font"))); } } + +#[test] +fn hatch_dialog_extents_label_says_extents_in_ukrainian() { + assert_eq!(tr("uk", "Default to boundary extents"), "Типово за габаритами контуру"); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 41aab5c..4e7569c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1233,3 +1233,34 @@ Hatch Pattern Зразок штрихування Search for pattern Пошук зразка Run Script... Виконати сценарій... + Hatch Pick Вибір для штрихування +# Hatch and Gradient dialog + Hatch and Gradient Штрихування та градієнт + Color 1 Колір 1 + Color 2 Колір 2 + Shape Форма + Curved Вигнутий + Spherical Сферичний + Hemispherical Напівсферичний + Inverted Інвертований + Centered По центру + Hatch origin Початок штрихування + Use current origin Використати поточний початок + Specified origin Заданий початок + Click to set new origin Клацніть, щоб задати новий початок + Default to boundary extents Типово за габаритами контуру + Bottom left Унизу ліворуч + Bottom right Унизу праворуч + Top left Угорі ліворуч + Top right Угорі праворуч + Boundaries Контури + Add: Pick points Додати: вказати точки + Add: Select objects Додати: вибрати об'єкти + Boundaries picked: {n} Вибрано контурів: {n} + Associative Асоціативне + Create separate hatches Створити окремі штрихування + Island detection Виявлення островів + Normal Звичайне + Outer Зовнішнє + Ignore Ігнорувати + Origin: {x}, {y} Початок: {x}, {y} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 199c52d..8993f17 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod docking; +pub mod hatchdlg; pub mod spacemouse; // The External References palette. pub mod xrefs; @@ -216,6 +217,8 @@ pub struct CadApp { pub gpu_faults: std::sync::Arc, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]). + pub hatch_dlg: Option, } impl CadApp { @@ -249,6 +252,7 @@ impl CadApp { recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), gpu_notice: None, + hatch_dlg: None, } } @@ -438,6 +442,7 @@ impl CadApp { self.issue_screenshots(ctx); self.gpu_fault_tick(ctx); managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); self.autosave_tick(ctx); menus::shortcuts(self, ctx); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e3539ef..635c824 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -355,6 +355,16 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + crate::hatchdlg::open_create(app, matches!(id, "gradient" | "gd")); + Ok(Value::Null) + } + "hatchedit" | "he" if params.is_null() && app.session.state().is_ok() => { + crate::hatchdlg::open_edit(app); + Ok(Value::Null) + } "properties" | "pr" | "props" | "ch" if params.is_null() => { crate::docking::command(app, false, &json!({"operation":"open", "panel":"properties"})) } diff --git a/docs/gaps.md b/docs/gaps.md index 28a6a25..81c1c9d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (SCRIPT command reading `.scr` files landed; E2 removed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Hatch and Gradient / Hatch Edit dialogs landed; F13 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -35,7 +35,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 (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 | | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | -| 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 | +| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties and can't add or remove boundaries of an existing hatch (HPDLGMODE 0 keeps HATCH on the command line), 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]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3). The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, and plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 | | 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 | @@ -146,6 +146,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed | | 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes | | 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 41568f0..6bf799c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (feature-area evidence refreshed for merged work; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (hatch dialogs added to the Hatch row's evidence; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, @@ -153,7 +153,7 @@ presence: an option that prints "not available yet" doesn't count. | Dimensions | 9% | 45% | 25–35 | all core DIM types and associativity; style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT, QDIM modes (#507) and TOLERANCE (#420); no DIMSTYLE manager dialog or smart DIM | [gaps](gaps.md#features) | | Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); editor is a dialog, not in place; no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) | | Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); tables with cell styles, formulas, breaking and CSV data links (#534); no MLEADEREDIT/ALIGN/COLLECT, no FIELD | [gaps](gaps.md#features) | -| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); no hatch dialog | [gaps](gaps.md#features) | +| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) | | Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); no BEDIT, dynamic blocks, REFEDIT, XCLIP, DesignCenter | [gaps](gaps.md#features) | | Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) | | Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563), MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | @@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured | | 2026-10-11 | minor | Evidence text of the feature-area rows refreshed for merged work (#397, #406, #408, #417, #420, #433, #448, #451, #453, #459, #468, #507, #510, #525, #534, #546, #547, #558, #563, #564, #567, #571); parity percentages and hours not re-measured | | 2026-10-10 | minor | Merged main: dynamic input pointer boxes (#60) lift UI/UX to 43%, Ukrainian catalog (#36) lifts localization to 5%; ready 37% → 38%, mainstream 27% → 28%, essentials 45% → 46% | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (1,150–1,850 h), mainstream (570–880 h) and essentials (140–225 h); full number confirmed as an additive weighted sum, unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3791b5b..fc62fd4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Properties Inspector shows block insertion X, Y and stored Z) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Hatch and Gradient / Hatch Edit dialogs) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD @@ -92,6 +92,7 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours** | Snap (rectangular, isometric, polar snap) | yes | rectangular and isometric (SNAPSTYL 1: the snap Y spacing along the isometric axes), from SNAPBASE turned by SNAPANG | | Isometric drafting (ISODRAFT, isoplanes F5) | yes | yes (#556): ISODRAFT and the status-bar toggle set SNAPSTYL and the plane; ISOPLANE / F5 / Ctrl+E cycle Left, Top, Right (SNAPISOPAIR); ortho, direct distance entry and the crosshair follow the plane's axes; ELLIPSE Isocircle (radius or Diameter, also for elliptical arcs); Drafting Settings snap style radio. Engine: `snap/iso.rs`, `cmd/settings/iso.rs` | | Drafting Settings dialog | yes | yes (`dialogs.rs:36-80`) | +| Hatch and Gradient / Hatch Edit dialogs | yes | yes (`hatchdlg.rs`): pattern swatches, gradient colours and shapes, origin, pick points or objects, associative, separate, island detection; opened by HATCH, GRADIENT (HPDLGMODE), HATCHEDIT, right-click or double-clicking a hatch; no Preview or Inherit Properties | ## Dynamic input (≈ 45%) @@ -201,6 +202,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Hatch and Gradient / Hatch Edit dialogs (HATCH, GRADIENT, HATCHEDIT, double-click a hatch; HPDLGMODE) | | 2026-10-12 | minor | Block references now show their stored insertion X/Y/Z in the Properties Inspector, including nonzero Z; fields are read-only | | 2026-10-11 | minor | Isometric drafting (#556): isometric snap and grid, ISODRAFT, ISOPLANE (F5), ortho along isoplane axes, ELLIPSE Isocircle | | 2026-10-11 | minor | Object snap tracking, Extension, Parallel, Apparent Intersection, additional polar angles (#379): tracking 30% → 70%, object snaps 40% → 45%; total 43% → 48% |