Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)
Thanks @Karl-VonRichter.
This commit is contained in:
1 parent
44968dbfe6
commit
371578a06f
36 files changed
+1394
-87
No files matched your search
@@ -1531,12 +1531,17 @@ fn entry_row(
|
||||
acts: &mut Actions,
|
||||
) {
|
||||
app.tree.order.push(e.row_key.clone());
|
||||
let pattern_sketch =
|
||||
(folder == "sketches").then(|| e.key.parse::<u64>().ok()).flatten().filter(|id| crate::sketch_pattern_tree::has_patterns(app, *id));
|
||||
let pattern_key = format!("{}/patterns", e.row_key);
|
||||
let patterns_open = is_open(app, &pattern_key, true);
|
||||
let badges: &[&str] = if e.locked { &["lock"] } else { &[] };
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(("item", &e.row_key)),
|
||||
&Row {
|
||||
depth,
|
||||
fold: pattern_sketch.map(|_| patterns_open),
|
||||
eye: Some(e.visible),
|
||||
icon: e.icon,
|
||||
label: &e.label,
|
||||
@@ -1548,6 +1553,9 @@ fn entry_row(
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold {
|
||||
toggle(app, &pattern_key, true);
|
||||
}
|
||||
if folder == "bodies"
|
||||
&& app.tree.reveal.as_deref() == Some(e.key.as_str())
|
||||
&& let Some(rect) = r.rect
|
||||
@@ -1587,6 +1595,9 @@ fn entry_row(
|
||||
}
|
||||
acts.menu = Some((p, t));
|
||||
}
|
||||
if let Some(sketch) = pattern_sketch.filter(|_| patterns_open) {
|
||||
crate::sketch_pattern_tree::rows(app, ui, sketch, depth + 1, "browser", path.map(|path| (comp, path)));
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started() && !r.on_control {
|
||||
|
||||
@@ -494,7 +494,7 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec
|
||||
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(11.5));
|
||||
let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0));
|
||||
crate::params_dialog::complete(ui, &r, value);
|
||||
if select_all {
|
||||
|
||||
+103
-22
@@ -819,7 +819,8 @@ impl Dialog {
|
||||
|
||||
/// Dialogs holding a table get the wide limit.
|
||||
pub fn is_wide(&self) -> bool {
|
||||
matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide())
|
||||
matches!(self.kind, Kind::Params { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }))
|
||||
|| matches!(&self.kind, Kind::Motion(k) if k.wide())
|
||||
}
|
||||
|
||||
pub fn wants_picks(&self) -> bool {
|
||||
@@ -1609,6 +1610,10 @@ fn selection_label(s: &Session, item: &Sel) -> String {
|
||||
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
|
||||
/// makes it the active input.
|
||||
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
|
||||
input_rows_range(d, ui, s, 0..d.inputs.len());
|
||||
}
|
||||
|
||||
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(_)) {
|
||||
return;
|
||||
@@ -1616,7 +1621,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
|
||||
let t = Tokens::get();
|
||||
let mut clear: Option<usize> = None;
|
||||
let mut activate: Option<usize> = None;
|
||||
for (i, inp) in d.inputs.iter().enumerate() {
|
||||
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
|
||||
row_label(ui, inp.label);
|
||||
ui.horizontal(|ui| {
|
||||
let active = i == d.active;
|
||||
@@ -1685,6 +1690,87 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each live reference sits with the independent values it drives.
|
||||
fn rectangular_pattern_rows(d: &mut Dialog, ui: &mut egui::Ui, session: &Session) -> bool {
|
||||
use crate::sketch_modify_ui::Mod;
|
||||
use solidcraft_engine::sketch::PatternAlignment;
|
||||
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));
|
||||
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);
|
||||
let Kind::SketchModify(Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 }) =
|
||||
&mut d.kind
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (count, spacing, alignment, reversed, count_label, spacing_label, alignment_label) = if index == 0 {
|
||||
(count, spacing, alignment, reversed, "Quantity", "Spacing", "Alignment")
|
||||
} else {
|
||||
(count2, spacing2, alignment2, reversed2, "Quantity 2", "Spacing 2", "Alignment 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();
|
||||
row_label(ui, alignment_label);
|
||||
ui.horizontal(|ui| {
|
||||
pattern_toggle(ui, alignment_label, "Flip direction", "pattern_flip", reversed);
|
||||
let mut centered = *alignment == PatternAlignment::Center;
|
||||
pattern_toggle(ui, alignment_label, "Center", "pattern_center", &mut centered);
|
||||
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.small(tr("Counts include the original. Center balances copies; Flip chooses the extra side for even totals."));
|
||||
});
|
||||
ui.end_row();
|
||||
enter
|
||||
}
|
||||
|
||||
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)));
|
||||
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)));
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center());
|
||||
if response.on_hover_text(tr(label)).clicked() {
|
||||
*selected = !*selected;
|
||||
}
|
||||
}
|
||||
|
||||
fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut egui::Ui, full_width: bool) {
|
||||
if matches!(d.kind, Kind::SketchModify(_))
|
||||
&& d.previews()
|
||||
&& let Err(error) = apply_commands(app, d)
|
||||
{
|
||||
d.refuse(app, error);
|
||||
}
|
||||
if let Some(error) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
|
||||
if !full_width {
|
||||
row_label(ui, "");
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(error.as_str()).color(Tokens::get().error)).wrap());
|
||||
let response = ui.button("Create Issue…");
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(d), Some(&error));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
|
||||
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
|
||||
/// Width of value fields and choice boxes in a dialog.
|
||||
@@ -1998,6 +2084,7 @@ 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 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);
|
||||
@@ -2027,7 +2114,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Long texts (errors, notes) wrap instead of widening the dialog.
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
let margin = horizontal_frame;
|
||||
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
|
||||
let min_w = if pattern_columns {
|
||||
540.0
|
||||
} else if wide {
|
||||
480.0
|
||||
} else {
|
||||
DIALOG_MIN_W
|
||||
} - margin;
|
||||
ui.set_min_width(min_w);
|
||||
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
|
||||
// Rows that end at the right edge line up with last frame's content width.
|
||||
@@ -2081,7 +2174,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
|
||||
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
|
||||
}
|
||||
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
egui::Grid::new("sc_dialog_grid").num_columns(if pattern_columns { 1 } else { 2 }).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
if pattern_columns {
|
||||
enter |= rectangular_pattern_rows(&mut d, ui, &app.session);
|
||||
error_rows(app, ctx, &mut d, ui, true);
|
||||
return;
|
||||
}
|
||||
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
|
||||
return;
|
||||
}
|
||||
@@ -2798,24 +2896,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(d.kind, Kind::SketchModify(_))
|
||||
&& d.previews()
|
||||
&& let Err(error) = apply_commands(app, &d)
|
||||
{
|
||||
d.refuse(app, error);
|
||||
}
|
||||
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
|
||||
row_label(ui, "");
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
|
||||
let response = ui.button("Create Issue…");
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
error_rows(app, ctx, &mut d, ui, false);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -148,6 +148,7 @@ pub mod sketch_modify_ui;
|
||||
#[cfg(test)]
|
||||
mod sketch_modify_ui_tests;
|
||||
pub mod sketch_palette;
|
||||
mod sketch_pattern_tree;
|
||||
pub(crate) mod sketch_text;
|
||||
mod sketch_text_handles;
|
||||
mod sketch_text_move;
|
||||
|
||||
@@ -64,6 +64,8 @@ fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() {
|
||||
#[test]
|
||||
fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
|
||||
let mut h = fixture();
|
||||
// Keep the picked geometry clear of the two-column rectangular-pattern form.
|
||||
step(&mut h, json!({"call":"ui.camera","params":{"target":[-60,0,0],"distance":120}}));
|
||||
step(&mut h, json!({"start":"sketch.pattern.rectangular"}));
|
||||
pick(&mut h, [-22.5, 10.0], false);
|
||||
if let crate::dialogs::Kind::SketchModify(Mod::PatternRect { count, count2, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
@@ -78,7 +80,7 @@ fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
|
||||
pick(&mut h, [-22.5, 10.0], false);
|
||||
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
|
||||
pick(&mut h, [-40.0, -20.0], false);
|
||||
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle }) = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
*count = "4".into();
|
||||
*angle = "180 deg".into();
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::geom::{Vec2, Vec3};
|
||||
use solidcraft_engine::sketch::PatternAlignment;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
|
||||
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, pattern_toggle, row_label};
|
||||
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
|
||||
use crate::viewport::{Hit, Proj};
|
||||
|
||||
@@ -76,11 +77,20 @@ pub enum Mod {
|
||||
/// carries the copy flag, so the Create Copy row needs no engine change.
|
||||
Move { x: String, y: String, angle: String, copy: bool },
|
||||
/// `sketch.pattern.rectangular`: how many instances along the picked direction and how far
|
||||
/// apart, then the same along the perpendicular direction the command derives from it.
|
||||
PatternRect { count: String, spacing: String, count2: String, spacing2: String },
|
||||
/// apart, then the same along a second picked direction (perpendicular by default).
|
||||
PatternRect {
|
||||
count: String,
|
||||
spacing: String,
|
||||
count2: String,
|
||||
spacing2: String,
|
||||
alignment: PatternAlignment,
|
||||
alignment2: PatternAlignment,
|
||||
reversed: bool,
|
||||
reversed2: bool,
|
||||
},
|
||||
/// `sketch.pattern.circular`: how many instances around the picked centre, and the total
|
||||
/// angle they span.
|
||||
PatternCirc { count: String, angle: String },
|
||||
PatternCirc { count: String, angle: String, alignment: PatternAlignment, reversed: bool },
|
||||
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
|
||||
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
|
||||
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
|
||||
@@ -132,7 +142,11 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
// The direction is optional: `sketch.pattern.rectangular` documents sketch x as its
|
||||
// default, the way the engine reads it.
|
||||
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Direction", AXES, false)],
|
||||
Mod::PatternRect { .. } => vec![
|
||||
SelInput::new("Entities", CURVES | SKETCH_POINTS, true),
|
||||
SelInput::new("Direction", AXES, false),
|
||||
SelInput::new("Direction 2", AXES, false),
|
||||
],
|
||||
// The centre is required: `sketch.pattern.circular` has no default for it.
|
||||
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
||||
@@ -157,8 +171,19 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), distance2: "2 mm".into(), mode: 0, chain: true, flip: false },
|
||||
"sketch.mirror" => Mod::Mirror,
|
||||
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
|
||||
"sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
"sketch.pattern.circular" => Mod::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||||
"sketch.pattern.rectangular" => Mod::PatternRect {
|
||||
count: "3".into(),
|
||||
spacing: "20 mm".into(),
|
||||
count2: "1".into(),
|
||||
spacing2: "20 mm".into(),
|
||||
alignment: PatternAlignment::Right,
|
||||
alignment2: PatternAlignment::Right,
|
||||
reversed: false,
|
||||
reversed2: false,
|
||||
},
|
||||
"sketch.pattern.circular" => {
|
||||
Mod::PatternCirc { count: "6".into(), angle: "360 deg".into(), alignment: PatternAlignment::Right, reversed: false }
|
||||
}
|
||||
"sketch.fillet" => corner(false, 0),
|
||||
"sketch.chamfer.equal_distance" => corner(true, 0),
|
||||
"sketch.chamfer.two_distance" => corner(true, 1),
|
||||
@@ -188,12 +213,21 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
|
||||
let Some(sk) = state.sketch(sid).map(|s| &s.sketch) else { return false };
|
||||
let Some(pattern) = sk.patterns.iter().find(|p| p.id == id) else { return false };
|
||||
let kind = match &pattern.kind {
|
||||
SketchPatternKind::Circular { count, angle, .. } => Mod::PatternCirc { count: count.to_string(), angle: format!("{angle} deg") },
|
||||
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => Mod::PatternRect {
|
||||
SketchPatternKind::Circular { count, angle, alignment, reversed, .. } => Mod::PatternCirc {
|
||||
count: count.to_string(),
|
||||
angle: format!("{angle} deg"),
|
||||
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
|
||||
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
|
||||
},
|
||||
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2, .. } => Mod::PatternRect {
|
||||
count: count.to_string(),
|
||||
spacing: format!("{spacing} mm"),
|
||||
count2: count2.to_string(),
|
||||
spacing2: format!("{spacing2} mm"),
|
||||
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
|
||||
alignment2: if *alignment2 == PatternAlignment::Left { PatternAlignment::Right } else { *alignment2 },
|
||||
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
|
||||
reversed2: *reversed2 ^ (*alignment2 == PatternAlignment::Left),
|
||||
},
|
||||
};
|
||||
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
|
||||
@@ -210,8 +244,16 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
|
||||
input.items = vec![Sel::SketchPoint { id: center.clone() }];
|
||||
}
|
||||
}
|
||||
SketchPatternKind::Rectangular { dir, .. } => {
|
||||
SketchPatternKind::Rectangular { dir, dir2, direction, direction2, .. } => {
|
||||
dialog.extra.insert("dir".into(), json!(dir));
|
||||
dialog.extra.insert("dir2".into(), json!(dir2));
|
||||
for (index, id) in [(1, direction), (2, direction2)] {
|
||||
if let Some(id) = id
|
||||
&& let Some(input) = dialog.inputs.get_mut(index)
|
||||
{
|
||||
input.items = vec![Sel::SketchCurve { id: id.clone() }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.extra.insert("pattern".into(), json!(id));
|
||||
@@ -287,19 +329,33 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
||||
check(ui, copy);
|
||||
ui.end_row();
|
||||
}
|
||||
Mod::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
for (label, value) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, value);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mod::PatternCirc { count, angle } => {
|
||||
// Its two direction columns include both the reference picks and values.
|
||||
Mod::PatternRect { .. } => {}
|
||||
Mod::PatternCirc { count, angle, alignment, reversed } => {
|
||||
for (label, value) in [("Quantity", count), ("Total angle", angle)] {
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, value);
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Alignment");
|
||||
ui.horizontal(|ui| {
|
||||
pattern_toggle(ui, "Alignment", "Flip direction", "pattern_flip", reversed);
|
||||
let mut centered = *alignment == PatternAlignment::Center;
|
||||
pattern_toggle(ui, "Alignment", "Center", "pattern_center", &mut centered);
|
||||
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
|
||||
});
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(crate::i18n::tr_label(
|
||||
"Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
|
||||
))
|
||||
.small(),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
|
||||
if *chamfer {
|
||||
@@ -349,13 +405,13 @@ fn sketch_dir(app: &SolveApp, sel: &Sel) -> Option<Vec2> {
|
||||
pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
|
||||
let Kind::SketchModify(k) = &d.kind else { return None };
|
||||
match k {
|
||||
Mod::PatternRect { .. } => {
|
||||
Mod::PatternRect { alignment, reversed, .. } => {
|
||||
let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane;
|
||||
let dir = match d.inputs.get(1).and_then(|i| i.items.first()) {
|
||||
Some(sel) => sketch_dir(app, sel)?,
|
||||
None => Vec2::X,
|
||||
};
|
||||
Some(plane.dir_to_world(dir))
|
||||
Some(plane.dir_to_world(if (*alignment == PatternAlignment::Left) ^ *reversed { -dir } else { dir }))
|
||||
}
|
||||
// A total angle is a rotation. The viewport draws a straight drag arrow and a rotation
|
||||
// handle only for Revolve, through machinery tied to its own extents, so the circular
|
||||
@@ -617,7 +673,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
}
|
||||
("sketch.move", p)
|
||||
}
|
||||
Mod::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
|
||||
let entities = entity_ids(&app.session, inputs, 0);
|
||||
if entities.is_empty() {
|
||||
return Err("Select the sketch curves or points to pattern first".into());
|
||||
@@ -635,16 +691,24 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
if n1.saturating_mul(n2) > MAX_COPIES as u64 {
|
||||
return Err(format!("Quantity and Quantity 2 together must make at most {MAX_COPIES:.0} instances"));
|
||||
}
|
||||
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2});
|
||||
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2, "alignment": alignment, "alignment2": alignment2, "reversed": reversed, "reversed2": reversed2});
|
||||
// A picked direction that has no component in the sketch plane is reported rather
|
||||
// than quietly replaced by the default.
|
||||
if let Some(sel) = inputs.get(1).and_then(|i| i.items.first()) {
|
||||
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
|
||||
p["dir"] = json!([d.x, d.y]);
|
||||
for (index, vector_key, line_key) in [(1, "dir", "direction"), (2, "dir2", "direction2")] {
|
||||
if let Some(sel) = inputs.get(index).and_then(|i| i.items.first()) {
|
||||
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
|
||||
p[vector_key] = json!([d.x, d.y]);
|
||||
p[line_key] = match sel {
|
||||
Sel::SketchCurve { id } => json!(id),
|
||||
_ => Value::Null,
|
||||
};
|
||||
} else {
|
||||
p[line_key] = Value::Null;
|
||||
}
|
||||
}
|
||||
("sketch.pattern.rectangular", p)
|
||||
}
|
||||
Mod::PatternCirc { count, angle } => {
|
||||
Mod::PatternCirc { count, angle, alignment, reversed } => {
|
||||
let entities = entity_ids(&app.session, inputs, 0);
|
||||
if entities.is_empty() {
|
||||
return Err("Select the sketch curves or points to pattern first".into());
|
||||
@@ -663,7 +727,10 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
return Err("Quantity must be at least 2".into());
|
||||
}
|
||||
let a = total_angle(app, "Total angle", angle)?;
|
||||
("sketch.pattern.circular", json!({"entities": entities, "center": center, "count": n, "angle": a}))
|
||||
(
|
||||
"sketch.pattern.circular",
|
||||
json!({"entities": entities, "center": center, "count": n, "angle": a, "alignment": alignment, "reversed": reversed}),
|
||||
)
|
||||
}
|
||||
Mod::Corner { chamfer: false, size, .. } => {
|
||||
let corners = corner_ids(app, inputs, 0);
|
||||
|
||||
@@ -456,7 +456,7 @@ fn open(cmd: &str) -> (crate::scenario::Harness, String) {
|
||||
|
||||
fn rect(h: &mut crate::scenario::Harness, count: &str, spacing: &str, count2: &str, spacing2: &str) {
|
||||
let d = h.app.dialog.as_mut().expect("the window opened");
|
||||
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2 }) = &mut d.kind {
|
||||
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2, .. }) = &mut d.kind {
|
||||
*c1 = count.into();
|
||||
*s1 = spacing.into();
|
||||
*c2 = count2.into();
|
||||
@@ -472,12 +472,58 @@ fn rectangular_pattern_window_opens_instead_of_a_click_tool() {
|
||||
assert!(h.app.tool.is_none(), "the click tool still takes the command");
|
||||
let d = h.app.dialog.as_ref().expect("the window opened");
|
||||
assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternRect { .. })), "{:?}", d.kind);
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction"]);
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction", "Direction 2"]);
|
||||
// Nothing is committed by opening the window.
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_columns_flip_and_center_independently_and_restore_even_counts_when_editing() {
|
||||
use solidcraft_engine::sketch::PatternAlignment;
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
rect(&mut h, "4", "20", "2", "10");
|
||||
h.settle();
|
||||
let first = crate::scenario::handle_at("dialog:Spacing").unwrap();
|
||||
let second = crate::scenario::handle_at("dialog:Spacing 2").unwrap();
|
||||
assert!(second.x - first.x > 180.0, "the directions should occupy separate columns: {first:?}, {second:?}");
|
||||
assert!((second.y - first.y).abs() < 2.0, "the independent spacing fields should share a row");
|
||||
for label in ["Alignment", "Alignment 2"] {
|
||||
let left = crate::scenario::handle_at(&format!("dialog:{label}:Flip direction")).unwrap();
|
||||
let right = crate::scenario::handle_at(&format!("dialog:{label}:Center")).unwrap();
|
||||
assert!((left.y - right.y).abs() < 2.0, "alignment choices should fit on a single line: {left:?}, {right:?}");
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Flip direction"}})).unwrap();
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
let commands = apply_commands(&h.app, d).unwrap();
|
||||
assert_eq!(commands[0].1["alignment"], "center");
|
||||
assert_eq!(commands[0].1["alignment2"], "right");
|
||||
assert_eq!(commands[0].1["reversed2"], true);
|
||||
assert_eq!(commands[0].1["count"], 4);
|
||||
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let pattern = h.app.session.world_state().sketch(sid).unwrap().sketch.patterns[0].id.clone();
|
||||
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &pattern));
|
||||
assert!(matches!(
|
||||
h.app.dialog.as_ref().unwrap().kind,
|
||||
Kind::SketchModify(Mod::PatternRect { alignment: PatternAlignment::Center, alignment2: PatternAlignment::Right, reversed2: true, .. })
|
||||
));
|
||||
rect(&mut h, "4", "20", "3", "10");
|
||||
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_ok());
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Center"}})).unwrap();
|
||||
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(commands[0].1["alignment"], "center");
|
||||
assert_eq!(commands[0].1["reversed"], true);
|
||||
assert_eq!(commands[0].1["alignment2"], "center");
|
||||
assert_eq!(commands[0].1["reversed2"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_counts_and_spacings_reach_the_command_with_the_second_direction() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
@@ -506,6 +552,45 @@ fn a_picked_axis_becomes_an_in_plane_direction() {
|
||||
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]), "sketch y on the XY plane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_picked_lines_are_sent_as_live_references_and_restored_in_the_editor() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.run("sketch.line", json!({"points":[[0,-20],[10,-20]],"ids":["dir1"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,-40],[6,-32]],"ids":["dir2"]})).unwrap();
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "dir1".into() }];
|
||||
d.inputs[2].items = vec![Sel::SketchCurve { id: "dir2".into() }];
|
||||
rect(&mut h, "3", "20", "2", "10");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let cmds = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(cmds[0].1["direction"], "dir1");
|
||||
assert_eq!(cmds[0].1["direction2"], "dir2");
|
||||
let out = h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap();
|
||||
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, out["pattern"].as_str().unwrap()));
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.inputs[1].items, vec![Sel::SketchCurve { id: "dir1".into() }]);
|
||||
assert_eq!(d.inputs[2].items, vec![Sel::SketchCurve { id: "dir2".into() }]);
|
||||
let reopened = apply_commands(&h.app, d).unwrap();
|
||||
assert_eq!(reopened[0].1["direction2"], "dir2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_pattern_tools_are_promoted_together_and_open_from_the_toolbar() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
let panels = crate::toolbar_custom::panel_lists("SKETCH");
|
||||
let (_, cmds, promoted) = panels.iter().find(|(panel, ..)| *panel == "PATTERN").unwrap();
|
||||
let ids: Vec<_> = cmds.iter().take(*promoted).map(|c| c.id).collect();
|
||||
assert_eq!(ids, ["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"]);
|
||||
for id in ids {
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":format!("toolbar:{id}")}})).unwrap();
|
||||
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::SketchModify(_))), "{id}");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_perpendicular_to_the_sketch_is_reported_not_silently_replaced() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
@@ -590,7 +675,7 @@ fn open_circ() -> (crate::scenario::Harness, String) {
|
||||
|
||||
fn circ(h: &mut crate::scenario::Harness, count: &str, angle: &str) {
|
||||
let d = h.app.dialog.as_mut().expect("the window opened");
|
||||
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a }) = &mut d.kind {
|
||||
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a, .. }) = &mut d.kind {
|
||||
*c = count.into();
|
||||
*a = angle.into();
|
||||
} else {
|
||||
@@ -627,6 +712,41 @@ fn the_typed_count_and_total_angle_reach_the_command() {
|
||||
assert_eq!(apply_commands(&h.app, &d).unwrap()[0].1["angle"], json!(90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_flip_and_center_buttons_apply_and_reopen_the_saved_placement() {
|
||||
use solidcraft_engine::sketch::{PatternAlignment, SketchPatternKind};
|
||||
let (mut h, _) = open_circ();
|
||||
circ(&mut h, "6", "90 deg");
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
|
||||
assert_eq!(params["count"], json!(6));
|
||||
assert_eq!(params["angle"], json!(90.));
|
||||
assert_eq!(params["alignment"], json!("center"));
|
||||
assert_eq!(params["reversed"], json!(true));
|
||||
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let id = {
|
||||
let state = h.app.session.world_state();
|
||||
let pattern = &state.sketch(sid).unwrap().sketch.patterns[0];
|
||||
assert!(matches!(pattern.kind, SketchPatternKind::Circular { alignment: PatternAlignment::Center, reversed: true, .. }));
|
||||
pattern.id.clone()
|
||||
};
|
||||
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &id));
|
||||
assert!(matches!(
|
||||
h.app.dialog.as_ref().unwrap().kind,
|
||||
Kind::SketchModify(Mod::PatternCirc { alignment: PatternAlignment::Center, reversed: true, .. })
|
||||
));
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
|
||||
assert_eq!(params["alignment"], json!("right"));
|
||||
assert_eq!(params["reversed"], json!(true));
|
||||
}
|
||||
|
||||
/// The same lesson as upstream #218: `num` reads a JSON number only, and `sketch.pattern.circular`
|
||||
/// falls back to a full turn when `angle` is anything else. An expression has to be evaluated
|
||||
/// here or it disappears in silence.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Sketch patterns are children of their sketch, rather than separate timeline features.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::{Sel, sketch::SketchPatternKind};
|
||||
|
||||
use crate::{
|
||||
SolveApp,
|
||||
browser::{Row, draw_row},
|
||||
};
|
||||
|
||||
pub(crate) fn has_patterns(app: &SolveApp, sketch: u64) -> bool {
|
||||
app.session.doc.sketch(sketch).is_ok_and(|sk| !sk.patterns.is_empty())
|
||||
}
|
||||
|
||||
/// Shared by the parts browser and the vertical feature history. The stored document is the
|
||||
/// source, so rolled-back sketches retain their children without showing preview-only copies.
|
||||
pub(crate) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, sketch: u64, depth: usize, scope: &str, occurrence: Option<(u64, &[u64])>) {
|
||||
let patterns: Vec<_> = app.session.doc.sketch(sketch).map(|sk| sk.patterns.clone()).unwrap_or_default();
|
||||
for pattern in patterns {
|
||||
let (name, icon) = match pattern.kind {
|
||||
SketchPatternKind::Rectangular { .. } => ("Rectangular Pattern", "sketch_rect_pattern"),
|
||||
SketchPatternKind::Circular { .. } => ("Circular Pattern", "sketch_circ_pattern"),
|
||||
};
|
||||
let suffix = pattern.id.strip_prefix("pat").unwrap_or(&pattern.id);
|
||||
let label = format!("{} {suffix}", crate::i18n::tr_label(name));
|
||||
let active = app.session.active_sketch == Some(sketch);
|
||||
let curves: Vec<_> = app
|
||||
.session
|
||||
.doc
|
||||
.sketch(sketch)
|
||||
.map(|sk| {
|
||||
pattern
|
||||
.entities
|
||||
.iter()
|
||||
.chain(pattern.instances.iter().flat_map(|i| &i.curves))
|
||||
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let selected = active && !curves.is_empty() && curves.iter().all(|curve| app.session.selection.contains(curve));
|
||||
let response = draw_row(
|
||||
ui,
|
||||
ui.id().with(("sketch_pattern", sketch, &pattern.id)),
|
||||
&Row { depth, icon, label: &label, name: true, selected, ..Default::default() },
|
||||
);
|
||||
if let Some(rect) = response.rect {
|
||||
crate::scenario::publish_handle(&format!("{scope}:sketch-pattern:{sketch}:{}", pattern.id), rect.center());
|
||||
}
|
||||
if response.double {
|
||||
if let Some((component, path)) = occurrence {
|
||||
let _ = app.run("component.activate", json!({"component":component,"occurrence":path.last(),"path":path}));
|
||||
}
|
||||
app.edit_sketch(sketch);
|
||||
if app.session.active_sketch == Some(sketch) {
|
||||
crate::sketch_modify_ui::edit_pattern(app, &pattern.id);
|
||||
}
|
||||
} else if response.clicked {
|
||||
let items = if active { curves } else { vec![Sel::Feature { id: sketch }] };
|
||||
let _ = app.run("select.set", json!({"items":items}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{dialogs::Kind, scenario::Harness, sketch_modify_ui::Mod};
|
||||
|
||||
fn harness(left_history: bool) -> (Harness, u64, String, String) {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.ui.show_browser = true;
|
||||
h.app.preferences.timeline_left = left_history;
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Patterned Sketch"})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,5],[15,5]],"ids":["source"]})).unwrap();
|
||||
let rectangular = h
|
||||
.app
|
||||
.run("sketch.pattern.rectangular", json!({"entities":["source"],"count":4,"spacing":20,"alignment":"center","reversed":true}))
|
||||
.unwrap();
|
||||
let circular = h.app.run("sketch.pattern.circular", json!({"entities":["source"],"center":"origin","count":3})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
h.frames(6);
|
||||
(h, sketch, rectangular["pattern"].as_str().unwrap().into(), circular["pattern"].as_str().unwrap().into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_patterns_fold_with_the_sketch_and_double_click_opens_the_saved_editor() {
|
||||
let (mut h, sketch, rectangular, circular) = harness(false);
|
||||
let handle = format!("browser:sketch-pattern:{sketch}:{rectangular}");
|
||||
let child = crate::scenario::handle_at(&handle).unwrap();
|
||||
let parent = crate::scenario::handle_at("row:Patterned Sketch").unwrap();
|
||||
assert!(child.y > parent.y, "patterns belong below their sketch");
|
||||
assert!(crate::scenario::handle_at(&format!("browser:sketch-pattern:{sketch}:{circular}")).is_some());
|
||||
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::handle_at(&handle).is_none());
|
||||
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
|
||||
// Let the fold clicks leave egui's triple-click interval before double-clicking a child.
|
||||
h.frames(40);
|
||||
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.session.active_sketch, Some(sketch), "selection={:?}, status={:?}", h.app.session.selection, h.app.status);
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.extra["pattern"], rectangular);
|
||||
assert!(matches!(&dialog.kind, Kind::SketchModify(Mod::PatternRect { count, reversed: true, .. }) if count == "4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_history_lists_both_pattern_kinds_and_tracks_deletion_and_undo() {
|
||||
let (mut h, sketch, rectangular, circular) = harness(true);
|
||||
let handle = format!("timeline:sketch-pattern:{sketch}:{circular}");
|
||||
assert!(crate::scenario::handle_at(&format!("timeline:sketch-pattern:{sketch}:{rectangular}")).is_some());
|
||||
assert!(crate::scenario::handle_at(&handle).is_some());
|
||||
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::handle_at(&handle).is_none());
|
||||
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
|
||||
h.frames(40);
|
||||
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::SketchModify(Mod::PatternCirc { .. })));
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().extra["pattern"], circular);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.app.run("sketch.delete", json!({"entities":["source"]})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::handle_at(&handle).is_none());
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::handle_at(&handle).is_some());
|
||||
}
|
||||
}
|
||||
@@ -1050,7 +1050,9 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec<us
|
||||
use solidcraft_engine::sketch::ConstraintKind::*;
|
||||
match *k {
|
||||
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
|
||||
PatternPoint { p, q, center, .. } => (vec![], vec![p, q].into_iter().chain(center).collect()),
|
||||
PatternPoint { p, q, center, directions, .. } => {
|
||||
(directions.into_iter().flatten().map(|(l, _)| l).collect(), vec![p, q].into_iter().chain(center).collect())
|
||||
}
|
||||
PatternRadius { a, b } => (vec![a, b], vec![]),
|
||||
Fix { p } => (vec![], vec![p]),
|
||||
Midpoint { p, l } => (vec![l], vec![p]),
|
||||
|
||||
@@ -685,7 +685,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_id, indent_group });
|
||||
let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * 14.0).unwrap_or(0.0);
|
||||
let has_patterns = matches!(kind, FeatureKind::Sketch { .. }) && crate::sketch_pattern_tree::has_patterns(app, *id);
|
||||
let pattern_key = format!("timeline/sketch/{id}/patterns");
|
||||
let patterns_open = crate::browser::is_open(app, &pattern_key, true);
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
|
||||
let fold_rect = Rect::from_min_size(rect.left_top() + vec2(indent, 0.0), vec2(16.0, rect.height()));
|
||||
let on_fold = has_patterns && response.interact_pointer_pos().is_some_and(|p| fold_rect.contains(p));
|
||||
crate::scenario::publish_handle(&format!("timeline:{name}"), rect.center());
|
||||
let result = app.session.model.result(*id);
|
||||
let error = result.and_then(|result| result.error.clone());
|
||||
@@ -705,7 +710,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if background != Color32::TRANSPARENT {
|
||||
ui.painter().rect_filled(rect, 3.0, background);
|
||||
}
|
||||
let icon_rect = Rect::from_center_size(pos2(rect.left() + 13.0 + indent, rect.center().y), vec2(18.0, 18.0));
|
||||
if has_patterns {
|
||||
crate::icons::glyphs::disclosure(ui.painter(), fold_rect.shrink(3.0), patterns_open, t.text_dim);
|
||||
crate::scenario::publish_handle(&format!("timeline-sketch-fold:{name}"), fold_rect.center());
|
||||
}
|
||||
let icon_rect =
|
||||
Rect::from_center_size(pos2(rect.left() + 13.0 + indent + if has_patterns { 16.0 } else { 0.0 }, rect.center().y), vec2(18.0, 18.0));
|
||||
let ink = if rolled || *suppressed { t.border } else { t.icon };
|
||||
icons::paint(ui.painter(), icon_rect, icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
|
||||
if *suppressed {
|
||||
@@ -737,7 +747,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if response.hovered() {
|
||||
hovered = Some(*id);
|
||||
}
|
||||
if response.drag_started() {
|
||||
if response.drag_started() && !on_fold {
|
||||
if !selected {
|
||||
select_only(app, TimelineItem::Feature(*id));
|
||||
}
|
||||
@@ -753,10 +763,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
tip.push_str("\nDouble-click to edit · drag to reorder · right-click for more");
|
||||
let response = response.on_hover_text(tip);
|
||||
if response.clicked() {
|
||||
if response.clicked() && on_fold {
|
||||
crate::browser::toggle(app, &pattern_key, true);
|
||||
} else if response.clicked() {
|
||||
select_item(app, ui, TimelineItem::Feature(*id), &order);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
if response.double_clicked() && !on_fold {
|
||||
edit = Some(*id);
|
||||
}
|
||||
let sketch = match kind {
|
||||
@@ -800,6 +812,10 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if has_patterns && patterns_open {
|
||||
let depth = group_id.map_or(1, |group| group_depth(&groups, group) + 2);
|
||||
crate::sketch_pattern_tree::rows(app, ui, *id, depth, "timeline", None);
|
||||
}
|
||||
previous_visible_group = group_id;
|
||||
}
|
||||
let mut trailing: Vec<_> = groups.iter().filter(|group| !shown_groups.contains(&group.id)).collect();
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
|
||||
}
|
||||
match tab {
|
||||
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
|
||||
"SKETCH" => &["CREATE", "MODIFY", "PATTERN", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
|
||||
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
@@ -69,6 +69,7 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
|
||||
("SOLID", "CREATE") => 4,
|
||||
("SOLID", "MODIFY") => 4,
|
||||
("SKETCH", "CONSTRAINTS") => 6,
|
||||
("SKETCH", "PATTERN") => 3,
|
||||
(_, "FINISH SKETCH") => 1,
|
||||
_ => 2,
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(
|
||||
tab,
|
||||
),
|
||||
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
|
||||
("SKETCH", "PATTERN") => (&["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"], 3, tab),
|
||||
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
|
||||
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
|
||||
("SHEET METAL", "MODIFY") => (SHEET_MODIFY, 2, tab),
|
||||
|
||||
Reference in new issue
Block a user