Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)

Thanks @Karl-VonRichter.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-16 00:32:32 -04:00
1 parent 44968dbfe6
commit 371578a06f
36 files changed
+1394 -87

No files matched your search

+11
View File
@@ -1531,12 +1531,17 @@ fn entry_row(
acts: &mut Actions,
) {
app.tree.order.push(e.row_key.clone());
let pattern_sketch =
(folder == "sketches").then(|| e.key.parse::<u64>().ok()).flatten().filter(|id| crate::sketch_pattern_tree::has_patterns(app, *id));
let pattern_key = format!("{}/patterns", e.row_key);
let patterns_open = is_open(app, &pattern_key, true);
let badges: &[&str] = if e.locked { &["lock"] } else { &[] };
let r = draw_row(
ui,
ui.id().with(("item", &e.row_key)),
&Row {
depth,
fold: pattern_sketch.map(|_| patterns_open),
eye: Some(e.visible),
icon: e.icon,
label: &e.label,
@@ -1548,6 +1553,9 @@ fn entry_row(
..Default::default()
},
);
if r.fold {
toggle(app, &pattern_key, true);
}
if folder == "bodies"
&& app.tree.reveal.as_deref() == Some(e.key.as_str())
&& let Some(rect) = r.rect
@@ -1587,6 +1595,9 @@ fn entry_row(
}
acts.menu = Some((p, t));
}
if let Some(sketch) = pattern_sketch.filter(|_| patterns_open) {
crate::sketch_pattern_tree::rows(app, ui, sketch, depth + 1, "browser", path.map(|path| (comp, path)));
}
let Some(resp) = &r.resp else { return };
// Drag: the selected items of this folder when this one is selected, else just this one.
if resp.drag_started() && !r.on_control {
+1 -1
View File
@@ -494,7 +494,7 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(11.5));
let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0));
crate::params_dialog::complete(ui, &r, value);
if select_all {
+103 -22
View File
@@ -819,7 +819,8 @@ impl Dialog {
/// Dialogs holding a table get the wide limit.
pub fn is_wide(&self) -> bool {
matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide())
matches!(self.kind, Kind::Params { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }))
|| matches!(&self.kind, Kind::Motion(k) if k.wide())
}
pub fn wants_picks(&self) -> bool {
@@ -1609,6 +1610,10 @@ fn selection_label(s: &Session, item: &Sel) -> String {
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
/// makes it the active input.
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
input_rows_range(d, ui, s, 0..d.inputs.len());
}
fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::ops::Range<usize>) {
// Edit Text shows its Path input among its own rows.
if matches!(d.kind, Kind::Text(_)) {
return;
@@ -1616,7 +1621,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
let t = Tokens::get();
let mut clear: Option<usize> = None;
let mut activate: Option<usize> = None;
for (i, inp) in d.inputs.iter().enumerate() {
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
row_label(ui, inp.label);
ui.horizontal(|ui| {
let active = i == d.active;
@@ -1685,6 +1690,87 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
}
}
/// Each live reference sits with the independent values it drives.
fn rectangular_pattern_rows(d: &mut Dialog, ui: &mut egui::Ui, session: &Session) -> bool {
use crate::sketch_modify_ui::Mod;
use solidcraft_engine::sketch::PatternAlignment;
let mut enter = false;
ui.vertical(|ui| {
ui.label(tr("dialog.selection_guidance"));
egui::Grid::new("pattern_source").spacing(vec2(10.0, 8.0)).show(ui, |ui| input_rows_range(d, ui, session, 0..1));
ui.separator();
ui.columns(2, |columns| {
for (index, ui) in columns.iter_mut().enumerate() {
ui.strong(tr(if index == 0 { "Direction 1" } else { "Direction 2" }));
egui::Grid::new(("pattern_direction", index)).spacing(vec2(8.0, 8.0)).show(ui, |ui| {
input_rows_range(d, ui, session, index + 1..index + 2);
let Kind::SketchModify(Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 }) =
&mut d.kind
else {
return;
};
let (count, spacing, alignment, reversed, count_label, spacing_label, alignment_label) = if index == 0 {
(count, spacing, alignment, reversed, "Quantity", "Spacing", "Alignment")
} else {
(count2, spacing2, alignment2, reversed2, "Quantity 2", "Spacing 2", "Alignment 2")
};
row_label(ui, count_label);
enter |= field(ui, count);
ui.end_row();
row_label(ui, spacing_label);
enter |= field(ui, spacing);
ui.end_row();
row_label(ui, alignment_label);
ui.horizontal(|ui| {
pattern_toggle(ui, alignment_label, "Flip direction", "pattern_flip", reversed);
let mut centered = *alignment == PatternAlignment::Center;
pattern_toggle(ui, alignment_label, "Center", "pattern_center", &mut centered);
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
});
ui.end_row();
});
}
});
ui.small(tr("Counts include the original. Center balances copies; Flip chooses the extra side for even totals."));
});
ui.end_row();
enter
}
pub(crate) fn pattern_toggle(ui: &mut egui::Ui, row: &str, label: &str, icon: &str, selected: &mut bool) {
let response = ui.add(egui::Button::new("").selected(*selected).min_size(vec2(28.0, 24.0)));
let t = Tokens::get();
crate::icons::paint(ui.painter(), response.rect.shrink(3.0), icon, t.icon, t.icon_fill, t.accent);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, *selected, tr(label)));
crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center());
if response.on_hover_text(tr(label)).clicked() {
*selected = !*selected;
}
}
fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut egui::Ui, full_width: bool) {
if matches!(d.kind, Kind::SketchModify(_))
&& d.previews()
&& let Err(error) = apply_commands(app, d)
{
d.refuse(app, error);
}
if let Some(error) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
if !full_width {
row_label(ui, "");
}
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(error.as_str()).color(Tokens::get().error)).wrap());
let response = ui.button("Create Issue…");
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
if response.clicked() {
crate::issue_report::begin(app, ctx, Some(d), Some(&error));
}
});
ui.end_row();
}
}
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
/// Width of value fields and choice boxes in a dialog.
@@ -1998,6 +2084,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut cancel = false;
let mut enter = false;
let wide = d.is_wide();
let pattern_columns = matches!(d.kind, Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }));
let raw_title = title(&d.kind);
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
let frame = crate::frame::dock_frame(ctx, side);
@@ -2027,7 +2114,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// Long texts (errors, notes) wrap instead of widening the dialog.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
let margin = horizontal_frame;
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
let min_w = if pattern_columns {
540.0
} else if wide {
480.0
} else {
DIALOG_MIN_W
} - margin;
ui.set_min_width(min_w);
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
// Rows that end at the right edge line up with last frame's content width.
@@ -2081,7 +2174,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
}
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
egui::Grid::new("sc_dialog_grid").num_columns(if pattern_columns { 1 } else { 2 }).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
if pattern_columns {
enter |= rectangular_pattern_rows(&mut d, ui, &app.session);
error_rows(app, ctx, &mut d, ui, true);
return;
}
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
return;
}
@@ -2798,24 +2896,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
}
if matches!(d.kind, Kind::SketchModify(_))
&& d.previews()
&& let Err(error) = apply_commands(app, &d)
{
d.refuse(app, error);
}
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
row_label(ui, "");
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
let response = ui.button("Create Issue…");
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
if response.clicked() {
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
}
});
ui.end_row();
}
error_rows(app, ctx, &mut d, ui, false);
});
});
ui.add_space(6.0);
+1
View File
@@ -148,6 +148,7 @@ pub mod sketch_modify_ui;
#[cfg(test)]
mod sketch_modify_ui_tests;
pub mod sketch_palette;
mod sketch_pattern_tree;
pub(crate) mod sketch_text;
mod sketch_text_handles;
mod sketch_text_move;
@@ -64,6 +64,8 @@ fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() {
#[test]
fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
let mut h = fixture();
// Keep the picked geometry clear of the two-column rectangular-pattern form.
step(&mut h, json!({"call":"ui.camera","params":{"target":[-60,0,0],"distance":120}}));
step(&mut h, json!({"start":"sketch.pattern.rectangular"}));
pick(&mut h, [-22.5, 10.0], false);
if let crate::dialogs::Kind::SketchModify(Mod::PatternRect { count, count2, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
@@ -78,7 +80,7 @@ fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
pick(&mut h, [-22.5, 10.0], false);
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
pick(&mut h, [-40.0, -20.0], false);
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle }) = &mut h.app.dialog.as_mut().unwrap().kind {
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
*count = "4".into();
*angle = "180 deg".into();
}
+94 -27
View File
@@ -28,9 +28,10 @@ use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::{Vec2, Vec3};
use solidcraft_engine::sketch::PatternAlignment;
use crate::SolveApp;
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, pattern_toggle, row_label};
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
use crate::viewport::{Hit, Proj};
@@ -76,11 +77,20 @@ pub enum Mod {
/// carries the copy flag, so the Create Copy row needs no engine change.
Move { x: String, y: String, angle: String, copy: bool },
/// `sketch.pattern.rectangular`: how many instances along the picked direction and how far
/// apart, then the same along the perpendicular direction the command derives from it.
PatternRect { count: String, spacing: String, count2: String, spacing2: String },
/// apart, then the same along a second picked direction (perpendicular by default).
PatternRect {
count: String,
spacing: String,
count2: String,
spacing2: String,
alignment: PatternAlignment,
alignment2: PatternAlignment,
reversed: bool,
reversed2: bool,
},
/// `sketch.pattern.circular`: how many instances around the picked centre, and the total
/// angle they span.
PatternCirc { count: String, angle: String },
PatternCirc { count: String, angle: String, alignment: PatternAlignment, reversed: bool },
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
@@ -132,7 +142,11 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
// The direction is optional: `sketch.pattern.rectangular` documents sketch x as its
// default, the way the engine reads it.
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Direction", AXES, false)],
Mod::PatternRect { .. } => vec![
SelInput::new("Entities", CURVES | SKETCH_POINTS, true),
SelInput::new("Direction", AXES, false),
SelInput::new("Direction 2", AXES, false),
],
// The centre is required: `sketch.pattern.circular` has no default for it.
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
@@ -157,8 +171,19 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), distance2: "2 mm".into(), mode: 0, chain: true, flip: false },
"sketch.mirror" => Mod::Mirror,
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
"sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
"sketch.pattern.circular" => Mod::PatternCirc { count: "6".into(), angle: "360 deg".into() },
"sketch.pattern.rectangular" => Mod::PatternRect {
count: "3".into(),
spacing: "20 mm".into(),
count2: "1".into(),
spacing2: "20 mm".into(),
alignment: PatternAlignment::Right,
alignment2: PatternAlignment::Right,
reversed: false,
reversed2: false,
},
"sketch.pattern.circular" => {
Mod::PatternCirc { count: "6".into(), angle: "360 deg".into(), alignment: PatternAlignment::Right, reversed: false }
}
"sketch.fillet" => corner(false, 0),
"sketch.chamfer.equal_distance" => corner(true, 0),
"sketch.chamfer.two_distance" => corner(true, 1),
@@ -188,12 +213,21 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
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 {
SketchPatternKind::Circular { count, angle, alignment, reversed, .. } => Mod::PatternCirc {
count: count.to_string(),
angle: format!("{angle} deg"),
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
},
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2, .. } => Mod::PatternRect {
count: count.to_string(),
spacing: format!("{spacing} mm"),
count2: count2.to_string(),
spacing2: format!("{spacing2} mm"),
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
alignment2: if *alignment2 == PatternAlignment::Left { PatternAlignment::Right } else { *alignment2 },
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
reversed2: *reversed2 ^ (*alignment2 == PatternAlignment::Left),
},
};
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
@@ -210,8 +244,16 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
input.items = vec![Sel::SketchPoint { id: center.clone() }];
}
}
SketchPatternKind::Rectangular { dir, .. } => {
SketchPatternKind::Rectangular { dir, dir2, direction, direction2, .. } => {
dialog.extra.insert("dir".into(), json!(dir));
dialog.extra.insert("dir2".into(), json!(dir2));
for (index, id) in [(1, direction), (2, direction2)] {
if let Some(id) = id
&& let Some(input) = dialog.inputs.get_mut(index)
{
input.items = vec![Sel::SketchCurve { id: id.clone() }];
}
}
}
}
dialog.extra.insert("pattern".into(), json!(id));
@@ -287,19 +329,33 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
check(ui, copy);
ui.end_row();
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
for (label, value) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
row_label(ui, label);
enter |= field(ui, value);
ui.end_row();
}
}
Mod::PatternCirc { count, angle } => {
// Its two direction columns include both the reference picks and values.
Mod::PatternRect { .. } => {}
Mod::PatternCirc { count, angle, alignment, reversed } => {
for (label, value) in [("Quantity", count), ("Total angle", angle)] {
row_label(ui, label);
enter |= field(ui, value);
ui.end_row();
}
row_label(ui, "Alignment");
ui.horizontal(|ui| {
pattern_toggle(ui, "Alignment", "Flip direction", "pattern_flip", reversed);
let mut centered = *alignment == PatternAlignment::Center;
pattern_toggle(ui, "Alignment", "Center", "pattern_center", &mut centered);
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
});
ui.end_row();
row_label(ui, "");
ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::tr_label(
"Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
))
.small(),
)
.wrap(),
);
ui.end_row();
}
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
if *chamfer {
@@ -349,13 +405,13 @@ fn sketch_dir(app: &SolveApp, sel: &Sel) -> Option<Vec2> {
pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
let Kind::SketchModify(k) = &d.kind else { return None };
match k {
Mod::PatternRect { .. } => {
Mod::PatternRect { alignment, reversed, .. } => {
let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane;
let dir = match d.inputs.get(1).and_then(|i| i.items.first()) {
Some(sel) => sketch_dir(app, sel)?,
None => Vec2::X,
};
Some(plane.dir_to_world(dir))
Some(plane.dir_to_world(if (*alignment == PatternAlignment::Left) ^ *reversed { -dir } else { dir }))
}
// A total angle is a rotation. The viewport draws a straight drag arrow and a rotation
// handle only for Revolve, through machinery tied to its own extents, so the circular
@@ -617,7 +673,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
}
("sketch.move", p)
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
@@ -635,16 +691,24 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
if n1.saturating_mul(n2) > MAX_COPIES as u64 {
return Err(format!("Quantity and Quantity 2 together must make at most {MAX_COPIES:.0} instances"));
}
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2});
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2, "alignment": alignment, "alignment2": alignment2, "reversed": reversed, "reversed2": reversed2});
// A picked direction that has no component in the sketch plane is reported rather
// than quietly replaced by the default.
if let Some(sel) = inputs.get(1).and_then(|i| i.items.first()) {
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
p["dir"] = json!([d.x, d.y]);
for (index, vector_key, line_key) in [(1, "dir", "direction"), (2, "dir2", "direction2")] {
if let Some(sel) = inputs.get(index).and_then(|i| i.items.first()) {
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
p[vector_key] = json!([d.x, d.y]);
p[line_key] = match sel {
Sel::SketchCurve { id } => json!(id),
_ => Value::Null,
};
} else {
p[line_key] = Value::Null;
}
}
("sketch.pattern.rectangular", p)
}
Mod::PatternCirc { count, angle } => {
Mod::PatternCirc { count, angle, alignment, reversed } => {
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
@@ -663,7 +727,10 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
return Err("Quantity must be at least 2".into());
}
let a = total_angle(app, "Total angle", angle)?;
("sketch.pattern.circular", json!({"entities": entities, "center": center, "count": n, "angle": a}))
(
"sketch.pattern.circular",
json!({"entities": entities, "center": center, "count": n, "angle": a, "alignment": alignment, "reversed": reversed}),
)
}
Mod::Corner { chamfer: false, size, .. } => {
let corners = corner_ids(app, inputs, 0);
+123 -3
View File
@@ -456,7 +456,7 @@ fn open(cmd: &str) -> (crate::scenario::Harness, String) {
fn rect(h: &mut crate::scenario::Harness, count: &str, spacing: &str, count2: &str, spacing2: &str) {
let d = h.app.dialog.as_mut().expect("the window opened");
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2 }) = &mut d.kind {
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2, .. }) = &mut d.kind {
*c1 = count.into();
*s1 = spacing.into();
*c2 = count2.into();
@@ -472,12 +472,58 @@ fn rectangular_pattern_window_opens_instead_of_a_click_tool() {
assert!(h.app.tool.is_none(), "the click tool still takes the command");
let d = h.app.dialog.as_ref().expect("the window opened");
assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternRect { .. })), "{:?}", d.kind);
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction"]);
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction", "Direction 2"]);
// Nothing is committed by opening the window.
let sid = h.app.session.active_sketch.unwrap();
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(), 1);
}
#[test]
fn rectangular_columns_flip_and_center_independently_and_restore_even_counts_when_editing() {
use solidcraft_engine::sketch::PatternAlignment;
let (mut h, id) = open("sketch.pattern.rectangular");
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
rect(&mut h, "4", "20", "2", "10");
h.settle();
let first = crate::scenario::handle_at("dialog:Spacing").unwrap();
let second = crate::scenario::handle_at("dialog:Spacing 2").unwrap();
assert!(second.x - first.x > 180.0, "the directions should occupy separate columns: {first:?}, {second:?}");
assert!((second.y - first.y).abs() < 2.0, "the independent spacing fields should share a row");
for label in ["Alignment", "Alignment 2"] {
let left = crate::scenario::handle_at(&format!("dialog:{label}:Flip direction")).unwrap();
let right = crate::scenario::handle_at(&format!("dialog:{label}:Center")).unwrap();
assert!((left.y - right.y).abs() < 2.0, "alignment choices should fit on a single line: {left:?}, {right:?}");
}
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Flip direction"}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
let commands = apply_commands(&h.app, d).unwrap();
assert_eq!(commands[0].1["alignment"], "center");
assert_eq!(commands[0].1["alignment2"], "right");
assert_eq!(commands[0].1["reversed2"], true);
assert_eq!(commands[0].1["count"], 4);
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.settle();
assert!(h.app.dialog.is_none());
let sid = h.app.session.active_sketch.unwrap();
let pattern = h.app.session.world_state().sketch(sid).unwrap().sketch.patterns[0].id.clone();
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &pattern));
assert!(matches!(
h.app.dialog.as_ref().unwrap().kind,
Kind::SketchModify(Mod::PatternRect { alignment: PatternAlignment::Center, alignment2: PatternAlignment::Right, reversed2: true, .. })
));
rect(&mut h, "4", "20", "3", "10");
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_ok());
h.settle();
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Center"}})).unwrap();
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(commands[0].1["alignment"], "center");
assert_eq!(commands[0].1["reversed"], true);
assert_eq!(commands[0].1["alignment2"], "center");
assert_eq!(commands[0].1["reversed2"], true);
}
#[test]
fn typed_counts_and_spacings_reach_the_command_with_the_second_direction() {
let (mut h, id) = open("sketch.pattern.rectangular");
@@ -506,6 +552,45 @@ fn a_picked_axis_becomes_an_in_plane_direction() {
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]), "sketch y on the XY plane");
}
#[test]
fn two_picked_lines_are_sent_as_live_references_and_restored_in_the_editor() {
let (mut h, id) = open("sketch.pattern.rectangular");
h.app.run("sketch.line", json!({"points":[[0,-20],[10,-20]],"ids":["dir1"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,-40],[6,-32]],"ids":["dir2"]})).unwrap();
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::SketchCurve { id }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "dir1".into() }];
d.inputs[2].items = vec![Sel::SketchCurve { id: "dir2".into() }];
rect(&mut h, "3", "20", "2", "10");
let d = h.app.dialog.clone().unwrap();
let cmds = apply_commands(&h.app, &d).unwrap();
assert_eq!(cmds[0].1["direction"], "dir1");
assert_eq!(cmds[0].1["direction2"], "dir2");
let out = h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap();
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, out["pattern"].as_str().unwrap()));
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.inputs[1].items, vec![Sel::SketchCurve { id: "dir1".into() }]);
assert_eq!(d.inputs[2].items, vec![Sel::SketchCurve { id: "dir2".into() }]);
let reopened = apply_commands(&h.app, d).unwrap();
assert_eq!(reopened[0].1["direction2"], "dir2");
}
#[test]
fn sketch_pattern_tools_are_promoted_together_and_open_from_the_toolbar() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let panels = crate::toolbar_custom::panel_lists("SKETCH");
let (_, cmds, promoted) = panels.iter().find(|(panel, ..)| *panel == "PATTERN").unwrap();
let ids: Vec<_> = cmds.iter().take(*promoted).map(|c| c.id).collect();
assert_eq!(ids, ["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"]);
for id in ids {
h.settle();
h.step(&json!({"click":{"handle":format!("toolbar:{id}")}})).unwrap();
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::SketchModify(_))), "{id}");
h.step(&json!({"key":"Escape"})).unwrap();
}
}
#[test]
fn an_axis_perpendicular_to_the_sketch_is_reported_not_silently_replaced() {
let (mut h, id) = open("sketch.pattern.rectangular");
@@ -590,7 +675,7 @@ fn open_circ() -> (crate::scenario::Harness, String) {
fn circ(h: &mut crate::scenario::Harness, count: &str, angle: &str) {
let d = h.app.dialog.as_mut().expect("the window opened");
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a }) = &mut d.kind {
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a, .. }) = &mut d.kind {
*c = count.into();
*a = angle.into();
} else {
@@ -627,6 +712,41 @@ fn the_typed_count_and_total_angle_reach_the_command() {
assert_eq!(apply_commands(&h.app, &d).unwrap()[0].1["angle"], json!(90.0));
}
#[test]
fn circular_flip_and_center_buttons_apply_and_reopen_the_saved_placement() {
use solidcraft_engine::sketch::{PatternAlignment, SketchPatternKind};
let (mut h, _) = open_circ();
circ(&mut h, "6", "90 deg");
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
let d = h.app.dialog.clone().unwrap();
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
assert_eq!(params["count"], json!(6));
assert_eq!(params["angle"], json!(90.));
assert_eq!(params["alignment"], json!("center"));
assert_eq!(params["reversed"], json!(true));
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
let id = {
let state = h.app.session.world_state();
let pattern = &state.sketch(sid).unwrap().sketch.patterns[0];
assert!(matches!(pattern.kind, SketchPatternKind::Circular { alignment: PatternAlignment::Center, reversed: true, .. }));
pattern.id.clone()
};
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &id));
assert!(matches!(
h.app.dialog.as_ref().unwrap().kind,
Kind::SketchModify(Mod::PatternCirc { alignment: PatternAlignment::Center, reversed: true, .. })
));
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
let d = h.app.dialog.clone().unwrap();
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
assert_eq!(params["alignment"], json!("right"));
assert_eq!(params["reversed"], json!(true));
}
/// The same lesson as upstream #218: `num` reads a JSON number only, and `sketch.pattern.circular`
/// falls back to a full turn when `angle` is anything else. An expression has to be evaluated
/// here or it disappears in silence.
+132
View File
@@ -0,0 +1,132 @@
//! Sketch patterns are children of their sketch, rather than separate timeline features.
use serde_json::json;
use solidcraft_engine::{Sel, sketch::SketchPatternKind};
use crate::{
SolveApp,
browser::{Row, draw_row},
};
pub(crate) fn has_patterns(app: &SolveApp, sketch: u64) -> bool {
app.session.doc.sketch(sketch).is_ok_and(|sk| !sk.patterns.is_empty())
}
/// Shared by the parts browser and the vertical feature history. The stored document is the
/// source, so rolled-back sketches retain their children without showing preview-only copies.
pub(crate) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, sketch: u64, depth: usize, scope: &str, occurrence: Option<(u64, &[u64])>) {
let patterns: Vec<_> = app.session.doc.sketch(sketch).map(|sk| sk.patterns.clone()).unwrap_or_default();
for pattern in patterns {
let (name, icon) = match pattern.kind {
SketchPatternKind::Rectangular { .. } => ("Rectangular Pattern", "sketch_rect_pattern"),
SketchPatternKind::Circular { .. } => ("Circular Pattern", "sketch_circ_pattern"),
};
let suffix = pattern.id.strip_prefix("pat").unwrap_or(&pattern.id);
let label = format!("{} {suffix}", crate::i18n::tr_label(name));
let active = app.session.active_sketch == Some(sketch);
let curves: Vec<_> = app
.session
.doc
.sketch(sketch)
.map(|sk| {
pattern
.entities
.iter()
.chain(pattern.instances.iter().flat_map(|i| &i.curves))
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
.collect()
})
.unwrap_or_default();
let selected = active && !curves.is_empty() && curves.iter().all(|curve| app.session.selection.contains(curve));
let response = draw_row(
ui,
ui.id().with(("sketch_pattern", sketch, &pattern.id)),
&Row { depth, icon, label: &label, name: true, selected, ..Default::default() },
);
if let Some(rect) = response.rect {
crate::scenario::publish_handle(&format!("{scope}:sketch-pattern:{sketch}:{}", pattern.id), rect.center());
}
if response.double {
if let Some((component, path)) = occurrence {
let _ = app.run("component.activate", json!({"component":component,"occurrence":path.last(),"path":path}));
}
app.edit_sketch(sketch);
if app.session.active_sketch == Some(sketch) {
crate::sketch_modify_ui::edit_pattern(app, &pattern.id);
}
} else if response.clicked {
let items = if active { curves } else { vec![Sel::Feature { id: sketch }] };
let _ = app.run("select.set", json!({"items":items}));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{dialogs::Kind, scenario::Harness, sketch_modify_ui::Mod};
fn harness(left_history: bool) -> (Harness, u64, String, String) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.show_browser = true;
h.app.preferences.timeline_left = left_history;
h.app.run("sketch.create", json!({"plane":"XY","name":"Patterned Sketch"})).unwrap();
let sketch = h.app.session.active_sketch.unwrap();
h.app.run("sketch.line", json!({"points":[[10,5],[15,5]],"ids":["source"]})).unwrap();
let rectangular = h
.app
.run("sketch.pattern.rectangular", json!({"entities":["source"],"count":4,"spacing":20,"alignment":"center","reversed":true}))
.unwrap();
let circular = h.app.run("sketch.pattern.circular", json!({"entities":["source"],"center":"origin","count":3})).unwrap();
h.app.finish_sketch();
h.frames(6);
(h, sketch, rectangular["pattern"].as_str().unwrap().into(), circular["pattern"].as_str().unwrap().into())
}
#[test]
fn browser_patterns_fold_with_the_sketch_and_double_click_opens_the_saved_editor() {
let (mut h, sketch, rectangular, circular) = harness(false);
let handle = format!("browser:sketch-pattern:{sketch}:{rectangular}");
let child = crate::scenario::handle_at(&handle).unwrap();
let parent = crate::scenario::handle_at("row:Patterned Sketch").unwrap();
assert!(child.y > parent.y, "patterns belong below their sketch");
assert!(crate::scenario::handle_at(&format!("browser:sketch-pattern:{sketch}:{circular}")).is_some());
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
// Let the fold clicks leave egui's triple-click interval before double-clicking a child.
h.frames(40);
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
h.frames(3);
assert_eq!(h.app.session.active_sketch, Some(sketch), "selection={:?}, status={:?}", h.app.session.selection, h.app.status);
let dialog = h.app.dialog.as_ref().unwrap();
assert_eq!(dialog.extra["pattern"], rectangular);
assert!(matches!(&dialog.kind, Kind::SketchModify(Mod::PatternRect { count, reversed: true, .. }) if count == "4"));
}
#[test]
fn left_history_lists_both_pattern_kinds_and_tracks_deletion_and_undo() {
let (mut h, sketch, rectangular, circular) = harness(true);
let handle = format!("timeline:sketch-pattern:{sketch}:{circular}");
assert!(crate::scenario::handle_at(&format!("timeline:sketch-pattern:{sketch}:{rectangular}")).is_some());
assert!(crate::scenario::handle_at(&handle).is_some());
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
h.frames(40);
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
h.frames(3);
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::SketchModify(Mod::PatternCirc { .. })));
assert_eq!(h.app.dialog.as_ref().unwrap().extra["pattern"], circular);
h.step(&json!({"key":"Escape"})).unwrap();
h.app.run("sketch.delete", json!({"entities":["source"]})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.app.run("edit.undo", json!({})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_some());
}
}
+3 -1
View File
@@ -1050,7 +1050,9 @@ 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()),
PatternPoint { p, q, center, directions, .. } => {
(directions.into_iter().flatten().map(|(l, _)| l).collect(), 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]),
+20 -4
View File
@@ -685,7 +685,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
}
slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_id, indent_group });
let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * 14.0).unwrap_or(0.0);
let has_patterns = matches!(kind, FeatureKind::Sketch { .. }) && crate::sketch_pattern_tree::has_patterns(app, *id);
let pattern_key = format!("timeline/sketch/{id}/patterns");
let patterns_open = crate::browser::is_open(app, &pattern_key, true);
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
let fold_rect = Rect::from_min_size(rect.left_top() + vec2(indent, 0.0), vec2(16.0, rect.height()));
let on_fold = has_patterns && response.interact_pointer_pos().is_some_and(|p| fold_rect.contains(p));
crate::scenario::publish_handle(&format!("timeline:{name}"), rect.center());
let result = app.session.model.result(*id);
let error = result.and_then(|result| result.error.clone());
@@ -705,7 +710,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
if background != Color32::TRANSPARENT {
ui.painter().rect_filled(rect, 3.0, background);
}
let icon_rect = Rect::from_center_size(pos2(rect.left() + 13.0 + indent, rect.center().y), vec2(18.0, 18.0));
if has_patterns {
crate::icons::glyphs::disclosure(ui.painter(), fold_rect.shrink(3.0), patterns_open, t.text_dim);
crate::scenario::publish_handle(&format!("timeline-sketch-fold:{name}"), fold_rect.center());
}
let icon_rect =
Rect::from_center_size(pos2(rect.left() + 13.0 + indent + if has_patterns { 16.0 } else { 0.0 }, rect.center().y), vec2(18.0, 18.0));
let ink = if rolled || *suppressed { t.border } else { t.icon };
icons::paint(ui.painter(), icon_rect, icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
if *suppressed {
@@ -737,7 +747,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
if response.hovered() {
hovered = Some(*id);
}
if response.drag_started() {
if response.drag_started() && !on_fold {
if !selected {
select_only(app, TimelineItem::Feature(*id));
}
@@ -753,10 +763,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
}
tip.push_str("\nDouble-click to edit · drag to reorder · right-click for more");
let response = response.on_hover_text(tip);
if response.clicked() {
if response.clicked() && on_fold {
crate::browser::toggle(app, &pattern_key, true);
} else if response.clicked() {
select_item(app, ui, TimelineItem::Feature(*id), &order);
}
if response.double_clicked() {
if response.double_clicked() && !on_fold {
edit = Some(*id);
}
let sketch = match kind {
@@ -800,6 +812,10 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.close();
}
});
if has_patterns && patterns_open {
let depth = group_id.map_or(1, |group| group_depth(&groups, group) + 2);
crate::sketch_pattern_tree::rows(app, ui, *id, depth, "timeline", None);
}
previous_visible_group = group_id;
}
let mut trailing: Vec<_> = groups.iter().filter(|group| !shown_groups.contains(&group.id)).collect();
+2 -1
View File
@@ -52,7 +52,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
}
match tab {
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
"SKETCH" => &["CREATE", "MODIFY", "PATTERN", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
@@ -69,6 +69,7 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
("SOLID", "CREATE") => 4,
("SOLID", "MODIFY") => 4,
("SKETCH", "CONSTRAINTS") => 6,
("SKETCH", "PATTERN") => 3,
(_, "FINISH SKETCH") => 1,
_ => 2,
}
+1
View File
@@ -119,6 +119,7 @@ pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(
tab,
),
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
("SKETCH", "PATTERN") => (&["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"], 3, tab),
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
("SHEET METAL", "MODIFY") => (SHEET_MODIFY, 2, tab),