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

+16
View File
@@ -1721,6 +1721,14 @@ pub enum PatternKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Spacing 2")]
spacing2: Option<Expr>,
#[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)
}
+4 -4
View File
@@ -3561,7 +3561,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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)));
}
}
}
@@ -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.
+1
View File
@@ -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 {}
+22 -4
View File
@@ -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<Value> {
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<Value> {
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)
}
+12
View File
@@ -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<solidcraft_sketch::PatternAlignment> {
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<bool> {
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")))
})
}
+91
View File
@@ -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();
+2 -11
View File
@@ -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<Value> {
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<Option<
}
}
fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<PatternAlignment> {
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<bool> {
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<Vec2> {
let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None };
(b - a).normalized()
+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!([
+49
View File
@@ -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.