Files
cadcraft/crates/engine/tests/hatch_dialog_helpers.rs
T
5183f63bc5 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]>
2026-10-13 04:34:09 -04:00

250 lines
9.8 KiB
Rust

//! 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());
}