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:
Karl Von Richter authored and GitHub committed 2026-10-14 01:58:01 -04:00
1 parent 9c51f9d433
commit 47a7f29485
29 files changed
+1107 -186

No files matched your search

+228 -5
View File
@@ -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(&param)).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());
}