Guide sketch patterns with live previews and editable offsets (#966)
Sketch Mirror, rectangular and circular Pattern, and Offset now show a green live preview that follows the selection and settings; OK commits, Cancel leaves the sketch unchanged. The shared dialogs show the current selection step and source counts, sources can be toggled with Ctrl/Cmd-click, cleared and reselected. Offsets keep editable driving distance dimensions (One Side, Two Sides, Symmetric) that update the derived curves without moving the source, with undo/redo and save/reopen. Format 19: older files keep their fixed offsets. New dialog labels in en, zh, ja, de and ko. Fixes #696. Fixes #517.
This commit is contained in:
1 parent
9c51f9d433
commit
47a7f29485
29 files changed
+1107
-186
No files matched your search
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::CurveKind;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -1062,8 +1062,10 @@ fn offset_symmetric_puts_the_same_distance_on_both_sides_of_a_chain() {
|
||||
// The two distances are equal and opposite.
|
||||
let d: Vec<f64> = o["sides"].as_array().unwrap().iter().map(|x| x["distance"].as_f64().unwrap()).collect();
|
||||
assert!(close(d[0], -d[1]) && close(d[0].abs(), 5.0), "{d:?}");
|
||||
// Both sides are parallel to their sources, so each one added four constraints.
|
||||
assert_eq!(ids(&o["constraints"]).len(), 8, "{o}");
|
||||
// Each side has four parallels and a persistent offset-distance driver.
|
||||
assert_eq!(ids(&o["constraints"]).len(), 10, "{o}");
|
||||
assert_eq!(sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::Parallel { .. })).count(), 8);
|
||||
assert_eq!(sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1171,9 +1173,9 @@ fn offset_refuses_the_combinations_that_would_be_guesswork() {
|
||||
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "symmetric": true, "distance2": 3}));
|
||||
assert!(e.contains("`distance2` or `symmetric`"), "{e}");
|
||||
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "distance2": -3}));
|
||||
assert!(e.contains("`distance2` must be a positive number"), "{e}");
|
||||
assert!(e.contains("`distance2` must be a positive length"), "{e}");
|
||||
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "distance2": 0}));
|
||||
assert!(e.contains("`distance2` must be a positive number"), "{e}");
|
||||
assert!(e.contains("`distance2` must be a positive length"), "{e}");
|
||||
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "direction": "negative"}));
|
||||
assert!(e.contains("no negative direction"), "{e}");
|
||||
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "direction": "sideways"}));
|
||||
@@ -1199,3 +1201,224 @@ fn offset_one_side_still_reports_one_set_of_curves_and_its_association() {
|
||||
assert_eq!(ids(&o["curves"]), ids(&o["sides"][0]["curves"]), "{o}");
|
||||
assert!(close(o["sides"][0]["distance"].as_f64().unwrap().abs(), 0.2), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_edits_reopens_and_undoes_without_moving_sources() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
|
||||
let original = sketch(&s).points.clone();
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":"2 mm","side":[20,-10]}));
|
||||
let param = result["param"].as_str().unwrap().to_string();
|
||||
let outputs = ids(&result["curves"]);
|
||||
let check = |s: &Session, distance: f64| {
|
||||
let state = s.world_state();
|
||||
let sk = &state.sketch(1).unwrap().sketch;
|
||||
assert!((sk.offset_distance(&outputs[0]).unwrap().abs() - distance).abs() < 1e-8);
|
||||
let constraint = sk.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap();
|
||||
assert!(solidcraft_sketch::dim_frame(sk, &constraint.kind).is_some());
|
||||
for (a, b) in sk.points.iter().zip(&original) {
|
||||
assert!(a.pos.dist(b.pos) < 1e-7);
|
||||
}
|
||||
let boundary =
|
||||
outputs.iter().flat_map(|id| sk.segs(sk.curve_index(id).unwrap())).flat_map(|s| [s.point_at(0.0), s.point_at(1.0)]).collect::<Vec<_>>();
|
||||
let min_x = boundary.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
|
||||
let max_x = boundary.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_y = boundary.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
|
||||
let max_y = boundary.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!((min_x + distance).abs() < 1e-7 && (max_x - 40.0 - distance).abs() < 1e-7);
|
||||
assert!((min_y + distance).abs() < 1e-7 && (max_y - 30.0 - distance).abs() < 1e-7);
|
||||
};
|
||||
check(&s, 2.0);
|
||||
run(&mut s, "parameters.change", json!({"name":param,"expression":"4 mm"}));
|
||||
check(&s, 4.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, 2.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s, 4.0);
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened, 4.0);
|
||||
run(&mut reopened, "parameters.change", json!({"name":param,"expression":"3 mm"}));
|
||||
check(&reopened, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_covers_disconnected_selections_and_refuses_bad_values_atomically() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[30,0],"radius":10}));
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1","c1"],"distance":"2 mm","chain":false}));
|
||||
assert_eq!(result["curves"].as_array().unwrap().len(), 2);
|
||||
let param = result["param"].as_str().unwrap();
|
||||
run(&mut s, "parameters.change", json!({"name":param,"expression":"3 mm"}));
|
||||
assert!(sketch(&s).offsets.iter().all(|offset| (offset.distance.abs() - 3.0).abs() < 1e-8));
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for value in [json!(0), json!("0 mm"), json!("1 deg"), json!("1e20 mm")] {
|
||||
assert!(s.execute("sketch.offset", &json!({"curves":["l1"],"distance":value})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
assert!(s.execute("sketch.offset", &json!({"curves":["l1","missing"],"distance":1})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_delete_and_break_remove_annotation_and_preserve_geometry() {
|
||||
for source in ["rectangle", "ellipse"] {
|
||||
let mut s = new_sketch();
|
||||
let id = if source == "rectangle" {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
|
||||
"l1"
|
||||
} else {
|
||||
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[20,0],"minor_radius":10}));
|
||||
"e1"
|
||||
};
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":[id],"distance":1}));
|
||||
let outputs = ids(&result["curves"]);
|
||||
let param = result["param"].as_str().unwrap();
|
||||
run(&mut s, "sketch.offset.break", json!({"curve":outputs[0]}));
|
||||
assert!(!sketch(&s).constraints.iter().any(|c| c.param.as_deref() == Some(param)));
|
||||
assert!(s.doc.param(param).is_none());
|
||||
assert!(outputs.iter().all(|id| sketch(&s).curve_index(id).is_some()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| c.param.as_deref() == Some(param)));
|
||||
run(&mut s, "sketch.delete", json!({"entities":outputs}));
|
||||
assert!(s.doc.param(param).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_uses_design_units_and_dimension_delete_freezes_current_distance() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[100,0]]}));
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":"0.1","chain":false}));
|
||||
let param = result["param"].as_str().unwrap();
|
||||
assert!((sketch(&s).offsets[0].distance - 2.54).abs() < 1e-8);
|
||||
run(&mut s, "parameters.change", json!({"name":param,"expression":"0.2 in"}));
|
||||
let sk = sketch(&s);
|
||||
let annotation = sk.constraints.iter().find(|c| c.param.as_deref() == Some(param)).unwrap().id.clone();
|
||||
run(&mut s, "sketch.delete", json!({"entities":[annotation]}));
|
||||
assert!((sketch(&s).offsets[0].distance - 5.08).abs() < 1e-8);
|
||||
assert!(s.doc.param(param).is_none());
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((reopened.world_state().sketch(1).unwrap().sketch.offsets[0].distance - 5.08).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_arc_first_retires_breaks_undoes_and_reopens() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,-15],"p1":[20,15]}));
|
||||
let fillet = run(&mut s, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"2 mm"}));
|
||||
let arc = fillet["curves"][0].as_str().unwrap();
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":[arc],"distance":"1 mm"}));
|
||||
let param = result["param"].as_str().unwrap();
|
||||
run(&mut s, "parameters.change", json!({"name":param,"expression":"3 mm"}));
|
||||
let sk = sketch(&s);
|
||||
let driver = sk.constraints.iter().find(|c| c.param.as_deref() == Some(param)).unwrap();
|
||||
let solidcraft_sketch::ConstraintKind::OffsetDistance { output, .. } = &driver.kind else {
|
||||
panic!("missing offset driver");
|
||||
};
|
||||
let record = sk.offsets.iter().find(|o| o.owns_output(output)).unwrap();
|
||||
assert!(!record.outputs.contains(output), "the dimension's first output arc must be absent from the live boundary");
|
||||
assert!(record.topology.as_ref().unwrap().roles.iter().any(|r| r.output == *output && r.retired));
|
||||
let survivor = sk.offsets[0].outputs.first().unwrap().clone();
|
||||
let before = sk.segs(sk.curve_index(&survivor).unwrap());
|
||||
run(&mut s, "sketch.offset.break", json!({"curve":survivor}));
|
||||
assert!(s.doc.param(param).is_none());
|
||||
assert!(sketch(&s).offsets.is_empty());
|
||||
assert_eq!(sketch(&s).segs(sketch(&s).curve_index(&survivor).unwrap()), before);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.world_state().sketch(1).unwrap().sketch.offsets.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.param(param).is_some());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.param(param).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_symmetric_disconnected_sources_share_one_distance() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[20,20],[30,20]]}));
|
||||
let source = sketch(&s).points.clone();
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1","l2"],"distance":"2 mm","symmetric":true,"chain":false}));
|
||||
assert_eq!(ids(&result["curves"]).len(), 4);
|
||||
assert_eq!(result["sides"].as_array().unwrap().len(), 4);
|
||||
let param = result["param"].as_str().unwrap();
|
||||
assert!(result["sides"].as_array().unwrap().iter().all(|side| side["param"] == param));
|
||||
run(&mut s, "parameters.change", json!({"name":param,"expression":"3 mm"}));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.offsets.len(), 4);
|
||||
assert!(sk.offsets.iter().all(|offset| (offset.distance.abs() - 3.0).abs() < 1e-8));
|
||||
assert!(sk.points.iter().zip(&source).all(|(a, b)| a.pos.dist(b.pos) < 1e-8));
|
||||
// Breaking one side keeps the shared parameter while another association uses it.
|
||||
let first = result["curves"][0].as_str().unwrap();
|
||||
run(&mut s, "sketch.offset.break", json!({"curve":first}));
|
||||
assert!(s.doc.param(param).is_some());
|
||||
run(&mut s, "parameters.change", json!({"name":param,"expression":"4 mm"}));
|
||||
assert!(sketch(&s).offsets.iter().all(|offset| (offset.distance.abs() - 4.0).abs() < 1e-8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_two_sides_edit_independently_and_reopen() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":"2 mm","distance2":"1 mm","side":[20,15]}));
|
||||
let primary = result["param"].as_str().unwrap();
|
||||
let secondary = result["param2"].as_str().unwrap();
|
||||
assert_ne!(primary, secondary);
|
||||
let inside = side_curves(&result, 0);
|
||||
let outside = side_curves(&result, 1);
|
||||
run(&mut s, "parameters.change", json!({"name":primary,"expression":"4 mm"}));
|
||||
assert!(box_is(&sketch(&s), &inside, [4.0, 4.0], [36.0, 26.0]));
|
||||
assert!(box_is(&sketch(&s), &outside, [-1.0, -1.0], [41.0, 31.0]));
|
||||
run(&mut s, "parameters.change", json!({"name":secondary,"expression":"3 mm"}));
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
|
||||
assert!(box_is(&sketch(&reopened), &inside, [4.0, 4.0], [36.0, 26.0]));
|
||||
assert!(box_is(&sketch(&reopened), &outside, [-3.0, -3.0], [43.0, 33.0]));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(box_is(&sketch(&s), &outside, [-1.0, -1.0], [41.0, 31.0]));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(box_is(&sketch(&s), &outside, [-3.0, -3.0], [43.0, 33.0]));
|
||||
let before = s.doc.to_json();
|
||||
for second in [json!(0), json!(-1), json!("1 deg"), json!("1e20 mm")] {
|
||||
assert!(s.execute("sketch.offset", &json!({"curves":["l1"],"distance":1,"distance2":second})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_later_groups_never_walk_outputs_created_by_earlier_groups() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,2],[20,2]]}));
|
||||
// The first offset ends exactly on the second source; it must not join its chain.
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1","l2"],"distance":2}));
|
||||
assert_eq!(ids(&result["curves"]).len(), 2);
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.offsets.len(), 2);
|
||||
assert_eq!(sk.offsets[0].sources, vec![("l1".into(), true)]);
|
||||
assert_eq!(sk.offsets[1].sources, vec![("l2".into(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":2}));
|
||||
let param = result["param"].as_str().unwrap();
|
||||
let mut doc = (*s.doc).clone();
|
||||
doc.sketch_mut(1).unwrap().constraints.retain(|c| !matches!(c.kind, ConstraintKind::OffsetDistance { .. }));
|
||||
doc.params.retain(|p| p.name != param);
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = json!("solidcraft/18");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.format, "solidcraft/19");
|
||||
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
|
||||
let sk = sketch(&reopened);
|
||||
assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8);
|
||||
assert!(!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })));
|
||||
assert_eq!(ids(&result["curves"]).len(), sk.offsets[0].outputs.len());
|
||||
}
|
||||
Reference in new issue
Block a user