Hatch and Gradient dialog (create and edit) (#753)

* HATCH/GRADIENT: points and objects in one call; GRADIENT name and centered

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* HATCHEDIT: gradient parameter (make or change a gradient fill)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* HATCHPICK: pick hatch boundaries, an origin or hatches for the dialog

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog: state, defaults and the OK command call

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog: pick points, objects, origin or hatch and come back

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch and Gradient dialog window (pattern swatches, gradient, origin, boundaries, options)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* HATCH, GRADIENT and HATCHEDIT open the hatch dialog; double-click a hatch to edit; HPDLGMODE

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog: keep the window to its content (OK and Cancel stayed off screen); double-click test

A vertical separator between the two columns took the height available, so the window grew
every frame until its buttons left the screen. Found in the running app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Docs: hatch dialogs in gaps, target-app parity and UI parity

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog review fixes

- Double-clicking a hatch at its centre opened the dialog and also started a grip edit.
- Edit mode no longer clamps an out-of-range scale or angle into a change nobody made.
- HPORIGINMODE outside 1-5 means the current origin, as HATCH reads it.
- Picks made in one space are dropped when the dialog comes back in another (layout tab).
- A gradient hatch's Pattern tab offers the current pattern (HPNAME), not SOLID.
- Ukrainian: "Default to boundary extents" said "outside the boundary".

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 04:34:09 -04:00
1 parent bcd6322ee9
commit 5183f63bc5
17 files changed
+1710 -19

No files matched your search

+35 -10
View File
@@ -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<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? (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<Vec<Hatch
Ok(loops)
}
/// Gradient fills by name: the shapes the renderer draws (`render/src/hatch.rs`); the INV forms
/// swap the two colours.
pub(crate) const GRADIENT_NAMES: [&str; 10] =
["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL", "INVLINEAR", "INVCYLINDER", "INVCURVED", "INVSPHERICAL", "INVHEMISPHERICAL"];
/// A gradient name as stored (upper case), or None when it isn't one of [`GRADIENT_NAMES`].
pub(crate) fn gradient_name(t: &str) -> Option<&'static str> {
let up = t.trim().to_ascii_uppercase();
GRADIENT_NAMES.iter().copied().find(|n| *n == up)
}
#[derive(Clone)]
struct HatchSettings {
pattern: String,
@@ -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<Value> {
if str_param(p, "originMode").is_some_and(|t| OriginMode::parse(t).is_none()) {
return Err(bad(cmd, "unknown `originMode`"));
}
if gradient && str_param(p, "name").is_some_and(|t| gradient_name(t).is_none()) {
return Err(bad(cmd, format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))));
}
let st = HatchSettings::from(s, p, gradient);
// Loops by picked point or selected object: one hatch each when separate.
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
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 }]));
+91 -2
View File
@@ -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<Color>,
color2: Option<Color>,
/// Radians.
angle: Option<f64>,
centered: Option<bool>,
}
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<Option<GradientEdit>> {
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<Option<Color>> {
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<Value> {
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<Value> {
}
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<Value> {
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;
}
+224
View File
@@ -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<Vec2>,
/// Closed boundary objects (Objects) or hatches (Hatch).
pub handles: Vec<Handle>,
/// The pattern origin (Origin).
pub origin: Option<Vec2>,
}
fn mode_named(t: &str) -> Option<PickMode> {
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<Value> {
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::<Vec<_>>(),
"handles": picks.handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
});
s.hatch_picks = Some(picks);
Ok(out)
}
/// One pick, so Undo can drop it.
enum Pick {
Point(Vec2, Vec<HatchLoop>),
Objects(Vec<Handle>, Vec<HatchLoop>),
}
#[derive(Default)]
pub(super) struct HatchPickM {
mode: Option<PickMode>,
picks: Vec<Pick>,
}
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<Step> {
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<Handle> = {
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<Handle> = {
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<EntityKind> {
let loops: Vec<HatchLoop> = 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)]
}
}
+4 -1
View File
@@ -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<cmd::clipboard::ClipSource>,
/// The window last picked with PLOTWINDOW (the Plot dialog's "Window <").
pub plot_window: Option<cadcraft_geom::Bounds2>,
/// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks).
pub hatch_picks: Option<cmd::hatch::HatchPicks>,
}
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 {
+2
View File
@@ -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("")),
+2
View File
@@ -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);
}
+249
View File
@@ -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());
}
+72 -1
View File
@@ -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<Handle> {
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<egui::Event>, 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::<Vec<_>>()), 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()));
}
}
+1
View File
@@ -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),
+968
View File
@@ -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<Handle>,
pub separate: bool,
/// Internal points and boundary objects picked so far (create mode).
pub points: Vec<[f64; 2]>,
pub handles: Vec<Handle>,
/// A HATCHPICK the dialog started that hasn't ended yet.
pub picking: Option<PickMode>,
/// The last command error, shown in the dialog.
pub error: Option<String>,
/// 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<Handle>) -> Option<HatchDlgState> {
let hatches: Vec<Handle> = 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::<Vec<_>>())
}
/// 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:?}"),
}
}
}
+7
View File
@@ -96,6 +96,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
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"), "Типово за габаритами контуру");
}
+31
View File
@@ -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}
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1233
1234
1235
1236
1237 # Hatch and Gradient dialog
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
+5
View File
@@ -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<gpu::GpuFaults>,
/// The notice shown after one (see [`autosave::gpu_notice`]).
pub gpu_notice: Option<autosave::GpuNotice>,
/// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]).
pub hatch_dlg: Option<hatchdlg::HatchDlgState>,
}
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"))]
+10
View File
@@ -355,6 +355,16 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
app.ui.dialog = Some("dsettings".into());
Ok(Value::Null)
}
// Typed or from a menu (no parameters): the Hatch and Gradient dialog (HPDLGMODE 1) and the
// Hatch Edit dialog. -HATCH, -HATCHEDIT and JSON calls keep the command line and the JSON form.
"hatch" | "h" | "bh" | "gradient" | "gd" if params.is_null() && crate::hatchdlg::dialog_on(app) => {
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"}))
}
+3 -2
View File
@@ -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) |
+3 -2
View File
@@ -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 |
+3 -1
View File
@@ -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% |