Sketch: associative, editable sketch patterns (#1103)

Rectangular and circular sketch patterns stay linked to their source: editing the pattern's count, spacing or the source geometry updates the copies, and the copies keep their point and radius relations. Patterns are saved under format 20; older designs keep their independent copies.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-14 14:09:37 -04:00
1 parent 4cfa9c5d34
commit 9a4e8fbd19
20 files changed
+888 -55

No files matched your search

+12
View File
@@ -705,6 +705,13 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
return v;
}
let ents: Vec<String> = curves.iter().chain(points.iter()).cloned().collect();
for pattern in &ss.sketch.patterns {
if ents.iter().any(|id| pattern.contains(id))
|| app.session.selection.iter().filter_map(|sel| crate::sketch_modify_ui::point_id(&app.session, sel)).any(|id| pattern.contains(id))
{
v.push(act("ui.editSketchPattern", "Edit Sketch Pattern", "sketch_circ_pattern").with(json!({"pattern":pattern.id})));
}
}
if crate::sketch_text::selected_entity(app).is_some() {
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
}
@@ -791,6 +798,11 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
"ui.delete" => delete(app),
"ui.finishSketch" => app.finish_sketch(),
"ui.editSketchPattern" => {
if let Some(id) = p.get("pattern").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_pattern(app, id);
}
}
"ui.hide" => {
for b in bodies {
if !app.session.visibility.hidden_bodies.contains(&b) {
+52
View File
@@ -179,6 +179,58 @@ pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) ->
}
}
/// Reopen the same pattern window with its stored parameters. OK reaches the same engine
/// command with the pattern id, so previews and programmatic edits share the implementation.
pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
use solidcraft_engine::sketch::SketchPatternKind;
let Some(sid) = app.session.active_sketch else { return false };
let state = app.session.world_state();
let Some(sk) = state.sketch(sid).map(|s| &s.sketch) else { return false };
let Some(pattern) = sk.patterns.iter().find(|p| p.id == id) else { return false };
let kind = match &pattern.kind {
SketchPatternKind::Circular { count, angle, .. } => Mod::PatternCirc { count: count.to_string(), angle: format!("{angle} deg") },
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => Mod::PatternRect {
count: count.to_string(),
spacing: format!("{spacing} mm"),
count2: count2.to_string(),
spacing2: format!("{spacing2} mm"),
},
};
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
if let Some(input) = dialog.inputs.first_mut() {
input.items = pattern
.entities
.iter()
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
.collect();
}
match &pattern.kind {
SketchPatternKind::Circular { center, .. } => {
if let Some(input) = dialog.inputs.get_mut(1) {
input.items = vec![Sel::SketchPoint { id: center.clone() }];
}
}
SketchPatternKind::Rectangular { dir, .. } => {
dialog.extra.insert("dir".into(), json!(dir));
}
}
dialog.extra.insert("pattern".into(), json!(id));
app.tool = None;
app.dialog = Some(dialog);
true
}
pub fn double_click_pattern(app: &mut SolveApp, pos: Pos2) -> bool {
let Some(glyph) = crate::sketch_tools::glyph_at(pos) else { return false };
let Some(sid) = app.session.active_sketch else { return false };
let id = app
.session
.world_state()
.sketch(sid)
.and_then(|ss| ss.sketch.patterns.iter().find(|p| p.instances.iter().any(|i| i.constraints.contains(&glyph))).map(|p| p.id.clone()));
id.is_some_and(|id| edit_pattern(app, &id))
}
/// Place a circular pattern centre in empty sketch space without creating a document point.
pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool {
if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 {
@@ -16,6 +16,50 @@ use crate::scenario::Harness;
use crate::selection::{AXES, CURVES, POINTS, SelInput};
use crate::sketch_modify_ui::{Mod, commands};
#[test]
fn existing_patterns_reopen_from_menu_and_badge_and_edit_without_changing_surviving_ids() {
for circular in [false, true] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]})).unwrap();
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
let p = if circular {
json!({"entities":["l1"],"center":"origin","count":4})
} else {
json!({"entities":["l1"],"dir":[0,1],"count":4,"spacing":12})
};
let out = h.app.run(cmd, p).unwrap();
let before = h.app.session.doc.to_json();
h.app.session.selection = vec![Sel::SketchCurve { id: "l2".into() }];
h.settle();
let menu = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport);
let item = menu.iter().find(|item| item.id == "ui.editSketchPattern").expect("a copy offers Edit Sketch Pattern");
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
assert_eq!(h.app.session.doc.to_json(), before, "opening the editor commits nothing");
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.extra["pattern"], out["pattern"]);
let cmds = apply_commands(&h.app, d).unwrap();
if !circular {
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]));
}
h.app.dialog = None;
h.settle();
let cid = out["constraints"][0].as_str().unwrap();
let at = crate::sketch_tools::glyph_positions().into_iter().find(|(id, _)| id == cid).expect("pattern has a badge").1;
assert!(crate::sketch_modify_ui::double_click_pattern(&mut h.app, at));
let d = h.app.dialog.as_mut().unwrap();
match &mut d.kind {
Kind::SketchModify(Mod::PatternCirc { count, .. }) | Kind::SketchModify(Mod::PatternRect { count, .. }) => *count = "5".into(),
_ => panic!("wrong pattern editor"),
}
let d = h.app.dialog.clone().unwrap();
let cmds = apply_commands(&h.app, &d).unwrap();
let result = h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap();
assert_eq!(result["curves"][0], "l2");
assert_eq!(result["curves"].as_array().unwrap().len(), 4);
}
}
/// A sketch on XY holding one 20 by 10 rectangle at the origin.
fn rectangle() -> Harness {
let mut h = Harness::new();
+19 -1
View File
@@ -935,6 +935,13 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
if c.kind.is_dimension() {
continue;
}
if matches!(
c.kind,
solidcraft_engine::sketch::ConstraintKind::PatternPoint { .. } | solidcraft_engine::sketch::ConstraintKind::PatternRadius { .. }
) && !ss.sketch.patterns.iter().any(|p| p.instances.first().and_then(|i| i.constraints.first()) == Some(&c.id))
{
continue;
}
for (at, dir) in glyph_anchors(&ss.sketch, &c.kind) {
let Some(base) = proj.to_screen(ss.plane.to_world(at)) else { continue };
// The entity's direction on screen, and its side.
@@ -1006,7 +1013,14 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
let r = egui::Rect::from_center_size(*sp, egui::vec2(GLYPH, GLYPH));
painter.rect_filled(r, 3.0, if lit { tk.accent_soft } else { tk.glyph_bg });
painter.rect_stroke(r, 3.0, Stroke::new(1.0, if failing || lit { col } else { tk.glyph_edge }), egui::StrokeKind::Inside);
match glyph_icon(c.kind.name()) {
let pattern_icon = ss.sketch.patterns.iter().find(|p| p.instances.first().and_then(|i| i.constraints.first()) == Some(id)).map(|p| {
if matches!(p.kind, solidcraft_engine::sketch::SketchPatternKind::Circular { .. }) {
"sketch_circ_pattern"
} else {
"sketch_rect_pattern"
}
});
match pattern_icon.or_else(|| glyph_icon(c.kind.name())) {
Some(icon) => crate::icons::paint(painter, r.shrink(2.0), icon, col, tk.glyph_bg, col),
None => {
painter.text(*sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(10.5), col);
@@ -1035,6 +1049,8 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec<us
use solidcraft_engine::sketch::ConstraintKind::*;
match *k {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
PatternPoint { p, q, center, .. } => (vec![], vec![p, q].into_iter().chain(center).collect()),
PatternRadius { a, b } => (vec![a, b], vec![]),
Fix { p } => (vec![], vec![p]),
Midpoint { p, l } => (vec![l], vec![p]),
PointOnCurve { p, c } => (vec![c], vec![p]),
@@ -1072,6 +1088,8 @@ fn glyph_anchors(sk: &solidcraft_engine::sketch::Sketch, k: &solidcraft_engine::
let pt = |p: usize| sk.point(p).map(|q| (q, None));
match *k {
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
PatternPoint { p, center, .. } => pt(center.unwrap_or(p)).into_iter().collect(),
PatternRadius { .. } => Vec::new(),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
sk.point(p).zip(sk.point(q)).map(|(a, b)| ((a + b) * 0.5, None)).into_iter().collect()
}
+5 -1
View File
@@ -2214,7 +2214,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
// Double-click a dimension: edit its value in place.
let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
let dim_edit = resp.double_clicked()
&& !held
&& app.tool.is_none()
&& app.dialog.is_none()
&& (crate::sketch_modify_ui::double_click_pattern(app, p) || crate::dim_view::double_click(app, p));
// Double-click a text (an outline or inside a letter; the first click selected it): edit it.
if resp.double_clicked()
&& !held