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!([