From 9422853cf9ee761675a9ba8b910323cf158d14bb Mon Sep 17 00:00:00 2001 From: Karl Von Richter Date: Fri, 16 Oct 2026 17:01:00 +1000 Subject: [PATCH] Feature mirrors and patterns: preserve replay and add Flip/Center placement (#1619) Thanks @Karl-VonRichter. --- crates/doc/src/document.rs | 16 ++ crates/doc/src/eval.rs | 8 +- .../pending/feature-pattern-alignment.rs | 4 + crates/doc/src/walk.rs | 1 + crates/engine/src/cmd/features.rs | 26 ++- crates/engine/src/cmd/mod.rs | 12 ++ crates/engine/src/cmd/patterns_tests.rs | 91 +++++++++++ crates/engine/src/cmd/sketch_pattern.rs | 13 +- crates/ui-egui/src/dialogs.rs | 124 +++++++++++--- crates/ui-egui/src/preselect_tests.rs | 153 ++++++++++++++++++ docs/feature-patterns.md | 49 ++++++ 11 files changed, 456 insertions(+), 41 deletions(-) create mode 100644 crates/doc/src/format/pending/feature-pattern-alignment.rs create mode 100644 docs/feature-patterns.md diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index c7c142f9..37062da1 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1721,6 +1721,14 @@ pub enum PatternKind { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(length = "Spacing 2")] spacing2: Option, + #[serde(default, skip_serializing_if = "default_pattern_alignment")] + alignment: solidcraft_sketch::PatternAlignment, + #[serde(default, skip_serializing_if = "default_pattern_alignment")] + alignment2: solidcraft_sketch::PatternAlignment, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reversed: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reversed2: bool, }, Circular { origin: Position, @@ -1730,6 +1738,10 @@ pub enum PatternKind { /// Total angle (360 deg = evenly around). #[walk(angle = "Total angle")] angle: Expr, + #[serde(default, skip_serializing_if = "default_pattern_alignment")] + alignment: solidcraft_sketch::PatternAlignment, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reversed: bool, }, /// Copies along a chain of sketch curves: instance k moves by path(k·spacing) − path(0), /// measured along the path from its start. @@ -1753,6 +1765,10 @@ pub enum PatternKind { }, } +fn default_pattern_alignment(alignment: &solidcraft_sketch::PatternAlignment) -> bool { + *alignment == solidcraft_sketch::PatternAlignment::Right +} + fn z_axis() -> Dir { Dir(Vec3::Z) } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 10fecf97..d2b52319 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3561,7 +3561,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &Model }; let mut out = Vec::new(); match p { - crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => { + crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2, alignment, alignment2, reversed, reversed2 } => { let (n1, s1) = (count(count1)?, val(vals, spacing1, Kind::Length)?); let d1 = dir1.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?; let (n2, s2, d2) = match (dir2, count2, spacing2) { @@ -3578,7 +3578,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &Model if i == 0 && j == 0 { continue; } - out.push(translation(d1 * (s1 * i as f64) + d2 * (s2 * j as f64))); + out.push(translation(d1 * (s1 * alignment.multiplier(i, *reversed)) + d2 * (s2 * alignment2.multiplier(j, *reversed2)))); } } } @@ -3615,7 +3615,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &Model out.push(m); } } - crate::PatternKind::Circular { origin, axis, count: c, angle } => { + crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed } => { let n = count(c)?; let total = val(vals, angle, Kind::Angle)?; let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9; @@ -3627,7 +3627,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &Model 0.0 }; for i in 1..n { - out.push(rotation(origin.0, axis.0, step * i as f64)); + out.push(rotation(origin.0, axis.0, step * alignment.multiplier(i, *reversed))); } } } diff --git a/crates/doc/src/format/pending/feature-pattern-alignment.rs b/crates/doc/src/format/pending/feature-pattern-alignment.rs new file mode 100644 index 00000000..09e3f13d --- /dev/null +++ b/crates/doc/src/format/pending/feature-pattern-alignment.rs @@ -0,0 +1,4 @@ +//! Rectangular and circular feature patterns store alignment and reversal, independently for +//! each rectangular direction. Defaults preserve older positive-direction placements. +//! Center retains the source and alternates copies on either side, as sketch patterns do. +//! The last release refuses this pending format and asks for an update instead of losing it. diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 64726064..64073b31 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -42,6 +42,7 @@ impl Walk for i8 {} impl Walk for f64 {} impl Walk for ProfileSel {} impl Walk for solidcraft_sketch::Sketch {} +impl Walk for solidcraft_sketch::PatternAlignment {} impl Walk for solidcraft_kernel::StepNode {} impl Walk for crate::document::Operation {} impl Walk for solidcraft_kernel::Continuity {} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index a86ad7bd..170f1419 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -41,11 +41,11 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) .at("SOLID", "CREATE") .icon("pattern_rect") - .params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"), + .params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?; alignment?, alignment2?: left|center|right (default right; counts include the original); reversed?, reversed2?: bool (Flip)"), CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ) .at("SOLID", "CREATE") .icon("pattern_circ") - .params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"), + .params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"), CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path) .at("SOLID", "CREATE") .icon("pattern_rect") @@ -1263,7 +1263,18 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result { if let Some(c) = &spacing2 { check_expr(s, c, Kind::Length, cmd, "spacing2")?; } - let pattern = solidcraft_doc::PatternKind::Rectangular { dir1: Dir(dir1), count1, spacing1, dir2: dir2.map(Dir), count2, spacing2 }; + let pattern = solidcraft_doc::PatternKind::Rectangular { + dir1: Dir(dir1), + count1, + spacing1, + dir2: dir2.map(Dir), + count2, + spacing2, + alignment: super::alignment_param(p, "alignment", cmd)?, + alignment2: super::alignment_param(p, "alignment2", cmd)?, + reversed: super::reversed_param(p, "reversed", cmd)?, + reversed2: super::reversed_param(p, "reversed2", cmd)?, + }; finish_pattern(s, p, features, pattern, bodies) } @@ -1275,7 +1286,14 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result { check_expr(s, &count, Kind::Unitless, cmd, "count")?; let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into()); check_expr(s, &angle, Kind::Angle, cmd, "angle")?; - let pattern = solidcraft_doc::PatternKind::Circular { origin: Position(origin), axis: Dir(axis), count, angle }; + let pattern = solidcraft_doc::PatternKind::Circular { + origin: Position(origin), + axis: Dir(axis), + count, + angle, + alignment: super::alignment_param(p, "alignment", cmd)?, + reversed: super::reversed_param(p, "reversed", cmd)?, + }; finish_pattern(s, p, features, pattern, bodies) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7c1353ca..db7fa5c3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -318,3 +318,15 @@ fn edit_distance(a: &str, b: &str) -> usize { } row[b.len()] } + +pub(super) fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).map_or(Ok(solidcraft_sketch::PatternAlignment::default()), |value| { + serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be left, center or right"))) + }) +} + +pub(super) fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).map_or(Ok(false), |value| { + serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be true or false"))) + }) +} diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index 94083c1d..0444c02a 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -34,6 +34,97 @@ fn plate_with_hole() -> Session { const PLATE: f64 = 100.0 * 50.0 * 5.0; const HOLE: f64 = PI * 4.0 * 5.0; +fn feature_pattern_plate() -> Session { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":120,"width":120,"height":5,"corner":[0,0,0],"name":"Plate"})); + run(&mut s, "solid.hole", json!({"position":[60,60,5],"diameter":2,"name":"Hole"})); + s +} + +#[test] +fn feature_rectangular_center_flip_and_oblique_direction_keep_source_and_recompute() { + use solidcraft_geom::Vec3; + for flipped in [false, true] { + let mut s = feature_pattern_plate(); + let before = s.doc.to_json(); + run( + &mut s, + "solid.pattern.rectangular", + json!({ + "features":["Hole"],"dir1":[1,0,0],"count1":4,"spacing1":15, + "dir2":[0.6,0.8,0],"count2":2,"spacing2":20, + "alignment":"center","alignment2":"center","reversed":flipped,"reversed2":flipped,"name":"Pattern" + }), + ); + let sign = if flipped { -1.0 } else { 1.0 }; + let st = s.world_state(); + let body = &st.bodies[0].body; + // Analytic slots: the source, +1, -1, +2. The second direction is independent. + for i in [0.0, 1.0, -1.0, 2.0] { + for j in [0.0, 1.0] { + let p = Vec3::new(60.0 + sign * (15.0 * i + 12.0 * j), 60.0 + sign * 16.0 * j, 2.5); + assert!(!body.contains(p), "hole at {p:?}"); + assert!(body.contains(p + Vec3::new(0.0, 2.0, 0.0))); + } + } + assert!(rel(volume(&mut s), 120.0 * 120.0 * 5.0 - 8.0 * PI * 5.0) < 1e-5); + let saved = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), saved); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); + assert!(rel(volume(&mut reopened), volume(&mut s)) < 1e-8); + // The copies replay the edited feature rather than freezing its original tool. + run(&mut reopened, "timeline.redefine", json!({"feature":"Hole","command":"solid.hole","params":{"position":[60,60,5],"diameter":3}})); + assert!(rel(volume(&mut reopened), 120.0 * 120.0 * 5.0 - 8.0 * PI * 2.25 * 5.0) < 1e-5); + } +} + +#[test] +fn feature_circular_center_flip_uses_partial_arc_endpoints_and_full_circle_spacing() { + use solidcraft_geom::Vec3; + for total in [90.0_f64, 360.0] { + for flipped in [false, true] { + let mut s = feature_pattern_plate(); + run( + &mut s, + "solid.pattern.circular", + json!({ + "features":["Hole"],"axis":{"origin":[40,60,0],"dir":[0,0,1]}, + "count":6,"angle":total,"alignment":"center","reversed":flipped + }), + ); + let step = total / if total == 360.0 { 6.0 } else { 5.0 }; + let st = s.world_state(); + for slot in [0.0, 1.0, -1.0, 2.0, -2.0, 3.0] { + let a = (step * slot * if flipped { -1.0 } else { 1.0 }).to_radians(); + let p = Vec3::new(40.0 + 20.0 * a.cos(), 60.0 + 20.0 * a.sin(), 2.5); + assert!(!st.bodies[0].body.contains(p), "hole at {p:?}"); + assert!(st.bodies[0].body.contains(p + Vec3::new(2.0 * a.cos(), 2.0 * a.sin(), 0.0))); + } + assert!(rel(volume(&mut s), 120.0 * 120.0 * 5.0 - 6.0 * PI * 5.0) < 1e-5); + let saved = s.doc.to_json(); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); + assert!(rel(volume(&mut reopened), volume(&mut s)) < 1e-8); + } + } +} + +#[test] +fn feature_pattern_rejects_malformed_alignment_and_flip_atomically() { + let mut s = feature_pattern_plate(); + let before = s.doc.to_json(); + let undo = s.undo.len(); + for (key, value) in [("alignment", json!("diagonal")), ("alignment2", json!(null)), ("reversed", json!(1)), ("reversed2", json!("true"))] { + let mut p = json!({"features":["Hole"],"dir1":[1,0,0],"count1":4,"spacing1":10}); + p[key] = value; + assert!(s.execute("solid.pattern.rectangular", &p).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + } +} + #[test] fn circular_axis_origin_rejects_invalid_values_atomically() { let mut s = plate_with_hole(); diff --git a/crates/engine/src/cmd/sketch_pattern.rs b/crates/engine/src/cmd/sketch_pattern.rs index d0b445f5..bb928279 100644 --- a/crates/engine/src/cmd/sketch_pattern.rs +++ b/crates/engine/src/cmd/sketch_pattern.rs @@ -1,8 +1,9 @@ //! Associative sketch patterns. Editing retains ids at surviving instance indices. -use solidcraft_sketch::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind}; +use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind}; use super::*; +use crate::cmd::{alignment_param, reversed_param}; pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result { let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" }; @@ -257,16 +258,6 @@ fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result Result { - p.get(key).map_or(Ok(PatternAlignment::default()), |value| { - serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be left, center or right"))) - }) -} - -fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).map_or(Ok(false), |value| serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be true or false")))) -} - fn line_direction(sk: &Sketch, id: Option<&str>) -> Option { let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None }; (b - a).normalized() diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d4ab062..dbe7edf6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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) { // 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 = None; let mut activate: Option = 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, 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::(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, 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) -> Option { let mut d = start("solid.mirror")?; let ids: Vec = 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) -> Option { + 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) -> Option { + 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) -> Option 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; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index adebe32a..f9433aec 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -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, ¶ms).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!([ diff --git a/docs/feature-patterns.md b/docs/feature-patterns.md new file mode 100644 index 00000000..a1ac067b --- /dev/null +++ b/docs/feature-patterns.md @@ -0,0 +1,49 @@ +# Feature mirrors and patterns + +Choose **Mirror**, **Rectangular Pattern** or **Circular Pattern** from the Solid +Create panel and set **Object Type** to **Features**. Select the source features +in the timeline or pick their faces in the viewport. Selecting features before +starting the command fills the Features input automatically. + +Feature copies replay the source operations: a patterned hole cuts the target +solid, and an additive feature adds material. The mirror's New Body / Join +selector belongs to body copying and is hidden for feature replays. + +Select the mirror plane, rectangular directions or circular axis using the +selection inputs. Click an input to pick into it; click a selected item again to +remove it, or use its clear button. The preview uses the same commands as OK. +Cancel leaves the design and undo history unchanged. + +Rectangular feature patterns have two direction columns, each with Quantity, +Spacing, Flip and Center controls. Directions can meet at any angle. A missing +second direction produces a single row. Direction vectors are saved with the +feature; editing a picked guide later does not change these saved vectors. + +Circular feature patterns have Quantity, Total angle, Flip and Center controls. +Six instances over 90 degrees use 18-degree steps, including both ends of the +arc. A full circle divides 360 degrees by the quantity without duplicating the +source at 360 degrees. + +Quantities include the original. Center keeps the original fixed and alternates +copies on its two sides. For four instances the slots are `0, +1, -1, +2`; +Flip changes them to `0, -1, +1, -2`. This also chooses the extra side for an even +centered circular pattern. Rectangular directions have independent controls. + +Double-click a saved feature in the timeline to edit it. Mirrors and patterns +retain their feature sources, including features that created bodies, and +patterns retain their placement controls and arbitrary direction vectors after +save/reopen. Source edits recompute the copies. Undo and redo cover the whole edit. + +The command forms share sketch patterns' alignment names: + +```json +{"command":"solid.pattern.rectangular","params":{"features":["Hole1"],"dir1":[1,0,0],"count1":4,"spacing1":15,"dir2":[0.6,0.8,0],"count2":2,"spacing2":20,"alignment":"center","alignment2":"center","reversed":true,"reversed2":false}} +``` + +```json +{"command":"solid.pattern.circular","params":{"features":["Hole1"],"axis":{"origin":[40,60,0],"dir":[0,0,1]},"count":6,"angle":"90 deg","alignment":"center","reversed":true}} +``` + +Omitted alignments default to `right`, and omitted reversals to `false`, preserving +older designs. `left` is also accepted by the command API. New placement values +are stored through the pending document-format migration.