Feature mirrors and patterns: preserve replay and add Flip/Center placement (#1619)

Thanks @Karl-VonRichter.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-16 03:01:00 -04:00
1 parent f57a62deb7
commit 9422853cf9
11 files changed
+456 -41

No files matched your search

+102 -22
View File
@@ -821,6 +821,7 @@ impl Dialog {
/// Dialogs holding a table get the wide limit.
pub fn is_wide(&self) -> bool {
matches!(self.kind, Kind::Params { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }))
|| (matches!(self.kind, Kind::PatternRect { .. }) && self.inputs.first().is_some_and(|i| i.accept == selection::FEATURES))
|| matches!(&self.kind, Kind::Motion(k) if k.wide())
}
@@ -898,6 +899,9 @@ impl Dialog {
/// A pick in the viewport (already accepted by the active input).
pub fn pick(&mut self, s: &Session, sel: Sel) {
if let Some(key) = feature_pattern_reference_key(self, self.active) {
self.extra.remove(key);
}
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
*sketch = None;
self.extra.remove("path");
@@ -1614,6 +1618,19 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
input_rows_range(d, ui, s, 0..d.inputs.len());
}
/// Saved vectors/axes are real inputs even when they have no origin-axis selection chip.
fn feature_pattern_reference_key(d: &Dialog, index: usize) -> Option<&'static str> {
if !d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES) {
return None;
}
match (&d.kind, index) {
(Kind::PatternRect { .. }, 1) => Some("dir1"),
(Kind::PatternRect { .. }, 2) => Some("dir2"),
(Kind::PatternCirc { .. }, 1) => Some("axis"),
_ => None,
}
}
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(_)) {
@@ -1623,14 +1640,15 @@ fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::
let mut clear: Option<usize> = None;
let mut activate: Option<usize> = None;
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
let stored_reference = feature_pattern_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty());
row_label(ui, inp.label);
ui.horizontal(|ui| {
let active = i == d.active;
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
let filled = !inp.items.is_empty() || custom_axis;
let filled = !inp.items.is_empty() || custom_axis || stored_reference.is_some();
let text = if filled {
RichText::new(if custom_axis {
RichText::new(if custom_axis || stored_reference.is_some() {
crate::i18n::tr("dialog.custom_axis").into()
} else if !inp.multi {
inp.items.first().map(|item| selection_label(s, item)).unwrap_or_default()
@@ -1651,7 +1669,9 @@ fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::
.sense(egui::Sense::CLICK);
let chip = crate::frame::add_outlined(ui, b, stroke.width);
publish_row(&chip);
let tooltip = if custom_axis {
let tooltip = if let Some(reference) = stored_reference {
reference.to_string()
} else if custom_axis {
if let Kind::Revolve { axis: Some(axis), .. } = &d.kind {
format!(
"Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis",
@@ -1667,7 +1687,7 @@ fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::
if chip.on_hover_text(tooltip).clicked() {
activate = Some(i);
}
if !inp.items.is_empty() {
if !inp.items.is_empty() || stored_reference.is_some() {
let button = ui.small_button("×").on_hover_text(tr("Clear the selection"));
crate::scenario::publish_handle(&format!("dialog:clear:{}", inp.label), button.rect.center());
if button.clicked() {
@@ -1680,9 +1700,11 @@ fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::
if let Some(i) = activate {
d.active = i;
}
if let Some(i) = clear
&& let Some(inp) = d.inputs.get_mut(i)
{
if let Some(i) = clear {
if let Some(key) = feature_pattern_reference_key(d, i) {
d.extra.remove(key);
}
let Some(inp) = d.inputs.get_mut(i) else { return };
inp.items.clear();
if i == 0 {
d.profile_selection_changed = true;
@@ -1698,13 +1720,30 @@ fn rectangular_pattern_rows(d: &mut Dialog, ui: &mut egui::Ui, session: &Session
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));
egui::Grid::new("pattern_source").spacing(vec2(10.0, 8.0)).show(ui, |ui| {
if matches!(d.kind, Kind::PatternRect { .. }) {
placement_row(d, 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);
if let Kind::PatternRect { count, spacing, count2, spacing2 } = &mut d.kind {
let (count, spacing, count_label, spacing_label) =
if index == 0 { (count, spacing, "Quantity", "Spacing") } else { (count2, spacing2, "Quantity 2", "Spacing 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();
feature_pattern_alignment_row(ui, &mut d.extra, index);
return;
}
let Kind::SketchModify(Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 }) =
&mut d.kind
else {
@@ -1738,8 +1777,31 @@ fn rectangular_pattern_rows(d: &mut Dialog, ui: &mut egui::Ui, session: &Session
enter
}
/// Feature patterns use the same source-fixed slot ordering and controls as sketch patterns.
fn feature_pattern_alignment_row(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, index: usize) {
use solidcraft_engine::sketch::PatternAlignment;
let (label, alignment_key, reversed_key) =
if index == 0 { ("Alignment", "alignment", "reversed") } else { ("Alignment 2", "alignment2", "reversed2") };
let alignment = extra.get(alignment_key).cloned().and_then(|v| serde_json::from_value::<PatternAlignment>(v).ok()).unwrap_or_default();
let mut reversed = extra.get(reversed_key).and_then(Value::as_bool).unwrap_or(false);
// Retain programmatic Left placement until the user changes its alignment.
let mut centered = alignment == PatternAlignment::Center;
let was_centered = centered;
row_label(ui, label);
ui.horizontal(|ui| {
pattern_toggle(ui, label, "Flip direction", "pattern_flip", &mut reversed);
pattern_toggle(ui, label, "Center", "pattern_center", &mut centered);
});
extra.insert(reversed_key.into(), json!(reversed));
if centered != was_centered {
extra.insert(alignment_key.into(), json!(if centered { PatternAlignment::Center } else { PatternAlignment::Right }));
}
ui.end_row();
}
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)));
// Tab moves between value fields; Enter after a value edit applies the dialog.
let response = ui.add(egui::Button::new("").selected(*selected).min_size(vec2(28.0, 24.0)).sense(egui::Sense::CLICK));
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)));
@@ -2085,7 +2147,8 @@ 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 pattern_columns = matches!(d.kind, Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }))
|| (matches!(d.kind, Kind::PatternRect { .. }) && d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES));
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);
@@ -2565,11 +2628,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
}
Kind::Mirror => {
row_label(ui, "Operation");
let mut op = usize::from(d.extra.get("combine").and_then(Value::as_bool).unwrap_or(false));
combo(ui, "mi_op", &["New Body", "Join"], &mut op);
d.extra.insert("combine".into(), json!(op == 1));
ui.end_row();
// Feature replay retains the source operation; Join is for body copies.
if !d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES) {
row_label(ui, "Operation");
let mut op = usize::from(d.extra.get("combine").and_then(Value::as_bool).unwrap_or(false));
combo(ui, "mi_op", &["New Body", "Join"], &mut op);
d.extra.insert("combine".into(), json!(op == 1));
ui.end_row();
}
}
Kind::Measure { result, show, .. } => measure_rows(app, ui, result.as_ref(), show),
Kind::OffsetPlane { offset } => {
@@ -2730,6 +2796,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
enter |= field(ui, v);
ui.end_row();
}
if d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES) {
feature_pattern_alignment_row(ui, &mut d.extra, 0);
}
}
Kind::Loft { operation } => {
row_label(ui, "Operation");
@@ -3737,7 +3806,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
};
let mut p = json!({"dir1": pt(d1), "count1": count, "spacing1": spacing});
pattern_objects(s, sels(d, 0), &mut p);
if let Some((_, d2)) = sels(d, 2).first().and_then(|x| axis_of(app, x)) {
let kept2 = d.extra.get("dir2").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()).map(|a| Vec3::new(a[0], a[1], a[2]));
let d2 = match sels(d, 2).first() {
Some(x) => Some(axis_of(app, x).ok_or("the second direction must be an axis, a sketch line or a straight edge")?.1),
None => kept2,
};
if let Some(d2) = d2 {
p["dir2"] = pt(d2);
p["count2"] = json!(count2);
p["spacing2"] = json!(spacing2);
@@ -4759,8 +4833,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
FeatureKind::Mirror { features, plane, bodies, .. } => {
let mut d = start("solid.mirror")?;
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
// Features that made no body of their own (a hole) were picked as features.
let as_features = ids.iter().any(|id| !st.bodies.iter().any(|b| b.feature == *id));
// Replay references stay features, including features that created a body.
let as_features = !features.is_empty();
if let Some(inp) = d.inputs.get_mut(0) {
if !bodies.is_empty() {
inp.items = bodies.iter().map(|name| Sel::Body { name: name.to_string() }).collect();
@@ -4785,8 +4859,12 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
copied.iter().map(|name| Sel::Body { name: name.to_string() }).collect()
};
let mut d = match pattern {
PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
let mut d = start("solid.pattern.rectangular")?;
d.extra.insert("alignment".into(), json!(alignment));
d.extra.insert("alignment2".into(), json!(alignment2));
d.extra.insert("reversed".into(), json!(reversed));
d.extra.insert("reversed2".into(), json!(reversed2));
d.kind = Kind::PatternRect {
count: count1.to_string(),
spacing: spacing1.to_string(),
@@ -4809,8 +4887,10 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
PatternKind::Circular { origin, axis, count, angle } => {
PatternKind::Circular { origin, axis, count, angle, alignment, reversed } => {
let mut d = start("solid.pattern.circular")?;
d.extra.insert("alignment".into(), json!(alignment));
d.extra.insert("reversed".into(), json!(reversed));
d.kind = Kind::PatternCirc { count: count.to_string(), angle: angle.to_string() };
match world_axis(axis.0).filter(|_| origin.len() < 1e-9) {
Some(name) => {
@@ -4827,8 +4907,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
// No dialog for a path pattern yet: edit it with timeline.redefine.
PatternKind::Path { .. } => return None,
};
// Features that made no body of their own (a hole, a fillet) were picked as features.
let as_features = copied.is_empty() && ids.iter().any(|id| !st.bodies.iter().any(|b| b.feature == *id));
// Replay references stay features, including features that created a body.
let as_features = !features.is_empty();
if let Some(inp) = d.inputs.first_mut() {
if as_features {
inp.accept = selection::FEATURES;
+153
View File
@@ -9,6 +9,159 @@ use solidcraft_engine::{Sel, Session};
use crate::dialogs::{Dialog, Kind, apply_commands};
use crate::{Services, SolveApp};
#[test]
fn feature_mirror_and_patterns_reopen_as_feature_replays_even_for_body_creators() {
for (cmd, params) in [
("solid.mirror", json!({"features":["Base"],"plane":"YZ"})),
(
"solid.pattern.rectangular",
json!({"features":["Base"],"dir1":[1,0,0],"count1":4,"spacing1":20,
"dir2":[0.6,0.8,0],"count2":2,"spacing2":20,"alignment":"center","alignment2":"center","reversed":true,"reversed2":true}),
),
("solid.pattern.circular", json!({"features":["Base"],"axis":"Z","count":6,"angle":90,"alignment":"center","reversed":true})),
] {
let mut app = app_with(json!([
{"command":"solid.box","params":{"corner":[40,0,0],"length":2,"width":2,"height":2,"name":"Base"}}
]));
app.session.execute(cmd, &params).unwrap();
let feature = app.session.doc.features.last().unwrap().clone();
// Exercise the save/reopen boundary as well as reopening the feature editor.
app.session = Session::new(solidcraft_engine::doc::Document::from_json(&app.session.doc.to_json()).unwrap());
let d = crate::dialogs::for_feature(&app, feature.id, None).unwrap();
assert_eq!(d.inputs[0].accept, crate::selection::FEATURES, "{cmd}");
assert_eq!(d.inputs[0].items, vec![Sel::Feature { id: app.session.doc.features[0].id }]);
let (edit, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(edit, "timeline.redefine");
assert_eq!(p["params"]["features"], json!(["Base"]));
assert!(p["params"].get("bodies").is_none());
app.session.execute(&edit, &p).unwrap();
assert_eq!(app.session.doc.feature(feature.id).unwrap().kind, feature.kind, "{cmd}: accepting the reopened editor preserves its definition");
}
}
#[test]
fn feature_pattern_controls_preview_without_committing_and_cancel_cleanly() {
for command in ["solid.pattern.rectangular", "solid.pattern.circular"] {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"corner":[40,0,0],"length":2,"width":2,"height":2,"name":"Base"})).unwrap();
let source = h.app.session.doc.features[0].id;
h.app.session.selection = vec![Sel::Feature { id: source }];
h.step(&json!({"start":command})).unwrap();
let d = h.app.dialog.as_mut().unwrap();
d.inputs[1].items = vec![Sel::Axis { name: if command.ends_with("rectangular") { "X".into() } else { "Z".into() } }];
if command.ends_with("rectangular") {
d.inputs[2].items = vec![Sel::Axis { name: "Y".into() }];
}
let saved = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.frames(3);
for handle in ["dialog:Alignment:Center", "dialog:Alignment:Flip direction"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
if command.ends_with("rectangular") {
for handle in ["dialog:Alignment 2:Center", "dialog:Alignment 2:Flip direction"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
}
let d = h.app.dialog.as_ref().unwrap();
let (_, p) = apply_commands(&h.app, d).unwrap().remove(0);
assert_eq!(p["alignment"], json!("center"));
assert_eq!(p["reversed"], json!(true));
if command.ends_with("rectangular") {
assert_eq!(p["alignment2"], json!("center"));
assert_eq!(p["reversed2"], json!(true));
}
assert_eq!(h.app.session.doc.to_json(), saved);
assert_eq!(h.app.session.undo.len(), undo);
wait_feature_preview(&mut h);
h.step(&json!({"key":"Escape"})).unwrap();
h.frames(3);
assert!(h.app.dialog.is_none());
assert!(!h.app.preview.active);
assert_eq!(h.app.session.doc.to_json(), saved);
}
}
fn wait_feature_preview(h: &mut crate::scenario::Harness) {
for _ in 0..300 {
h.frames(2);
if h.app.preview.active && !h.app.preview.busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(h.app.preview.active && !h.app.preview.busy, "preview failed: {:?}", h.app.preview.error);
assert!(h.app.preview.error.is_none());
}
#[test]
fn saved_feature_pattern_vectors_are_visible_clearable_and_replaceable() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"name":"Base"})).unwrap();
h.app
.run(
"solid.pattern.rectangular",
json!({"features":["Base"],"dir1":[1,1,0],"count1":2,"spacing1":30,
"dir2":[0.6,0.8,0],"count2":2,"spacing2":50}),
)
.unwrap();
let id = h.app.session.doc.features.last().unwrap().id;
h.step(&json!({"call":"ui.editFeature","params":{"feature":id}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:clear:Direction 2"}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
let (_, p) = apply_commands(&h.app, d).unwrap().remove(0);
assert!(p["params"].get("dir2").is_none(), "clearing the saved second vector makes a single row: {d:?} -> {p}");
assert_eq!(p["params"]["dir1"], json!([1.0, 1.0, 0.0]));
h.step(&json!({"click":{"handle":"dialog:clear:Direction"}})).unwrap();
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_err());
let d = h.app.dialog.as_mut().unwrap();
d.pick(&h.app.session, Sel::Axis { name: "X".into() });
let (_, p) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!(p["params"]["dir1"], json!([1.0, 0.0, 0.0]));
}
#[test]
fn feature_mirror_picks_clear_preview_and_replay_the_source_cut() {
let mut h = crate::scenario::Harness::new();
for (cmd, p) in [
("solid.box", json!({"length":100,"width":50,"height":5,"corner":[0,0,0]})),
("solid.hole", json!({"position":[30,25,5],"diameter":4,"name":"Hole"})),
("construct.plane.offset", json!({"base":"YZ","offset":50,"name":"Mid"})),
] {
h.app.run(cmd, p).unwrap();
}
let source = Sel::Feature { id: h.app.session.doc.find_feature("Hole").unwrap().id };
h.app.session.selection = vec![source.clone()];
h.step(&json!({"start":"solid.mirror"})).unwrap();
let saved = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let d = h.app.dialog.as_mut().unwrap();
d.active = 0;
d.pick(&h.app.session, source.clone());
assert!(d.inputs[0].items.is_empty());
d.pick(&h.app.session, source);
d.active = 1;
d.pick(&h.app.session, Sel::Plane { name: "Mid".into() });
wait_feature_preview(&mut h);
assert_eq!(h.app.session.doc.to_json(), saved);
assert_eq!(h.app.session.undo.len(), undo);
h.step(&json!({"click":{"handle":"dialog:clear:Mirror Plane"}})).unwrap();
assert!(h.app.dialog.as_ref().unwrap().inputs[1].items.is_empty());
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_err());
let d = h.app.dialog.as_mut().unwrap();
d.pick(&h.app.session, Sel::Plane { name: "Mid".into() });
wait_feature_preview(&mut h);
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.step(&json!({"until":{"dialog":null,"features":4}})).unwrap();
let state = h.app.session.world_state();
assert_eq!(state.bodies.len(), 1, "a mirrored hole cuts the plate instead of copying it");
assert!(!state.bodies[0].body.contains(Vec3::new(70.0, 25.0, 2.5)));
let mirror = h.app.session.doc.features.last().unwrap().id;
let reopened = crate::dialogs::for_feature(&h.app, mirror, None).unwrap();
assert_eq!(reopened.inputs[0].accept, crate::selection::FEATURES);
assert_eq!(reopened.inputs[1].items, vec![Sel::Plane { name: "Mid".into() }]);
}
#[test]
fn a_face_extrude_keeps_the_picked_body_when_faces_coincide() {
let mut app = app_with(json!([