Feature mirrors and patterns: preserve replay and add Flip/Center placement (#1619)
Thanks @Karl-VonRichter.
This commit is contained in:
1 parent
f57a62deb7
commit
9422853cf9
11 files changed
+456
-41
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")))
|
||||
})
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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!([
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user