mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
17 files changed
+1710
-19
No files matched your search
@@ -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 }]));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"), "Типово за габаритами контуру");
|
||||
}
|
||||
@@ -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}
|
||||
|
@@ -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"))]
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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% |
|
||||
|
||||
Reference in new issue
Block a user