Guide sketch patterns with live previews and editable offsets (#966)

Sketch Mirror, rectangular and circular Pattern, and Offset now show a green live preview that follows the selection and settings; OK commits, Cancel leaves the sketch unchanged. The shared dialogs show the current selection step and source counts, sources can be toggled with Ctrl/Cmd-click, cleared and reselected.

Offsets keep editable driving distance dimensions (One Side, Two Sides, Symmetric) that update the derived curves without moving the source, with undo/redo and save/reopen. Format 19: older files keep their fixed offsets.

New dialog labels in en, zh, ja, de and ko.

Fixes #696. Fixes #517.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-14 01:58:01 -04:00
1 parent 9c51f9d433
commit 47a7f29485
29 files changed
+1107 -186

No files matched your search

+63 -7
View File
@@ -330,7 +330,7 @@ fn fits(a: Accept, s: &Sel) -> bool {
Sel::Feature { .. } => a & selection::FEATURES != 0,
// Sketch points go into a sketch command's own inputs (Entities, a base point), so a
// box selection and a pre-selection reach them the way a click already does.
Sel::SketchPoint { .. } => a & selection::POINTS != 0,
Sel::SketchPoint { .. } => a & (selection::POINTS | selection::SKETCH_POINTS) != 0,
Sel::SketchConstraint { .. } | Sel::Occurrence { .. } | Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => false,
}
}
@@ -645,6 +645,7 @@ impl Dialog {
Kind::Assembly(k) => return k.previews(),
Kind::Sheet(k) => return k.previews(),
Kind::Surface(_) => return true,
Kind::SketchModify(k) => return k.previews(),
Kind::Plastic(k) => return k.previews(),
Kind::Part(_) => return true,
_ => {}
@@ -775,6 +776,9 @@ impl Dialog {
/// ([`Dialog::face_snap`] of the same hits). A face place picked on or near a sketch point that
/// lies on that face (a circle's centre) is taken at the point, as a points input picks it.
pub(crate) fn candidate_on(&self, s: &Session, h: &Hit, snap: Option<&FaceSnap>) -> Option<Sel> {
if matches!(self.kind, Kind::SketchModify(_)) {
return crate::sketch_modify_ui::candidate(self, s, h);
}
let inp = self.active_input()?;
// Features are also picked on the canvas: a face stands for the feature that made it.
if inp.accept == selection::FEATURES {
@@ -856,7 +860,15 @@ impl Dialog {
}
for x in sels {
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) {
if !fits(inp.accept, &x) {
continue;
}
if !inp.multi {
inp.items.clear();
inp.items.push(x);
break;
}
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
inp.items.push(x);
}
}
@@ -1313,7 +1325,7 @@ fn hint(inp: &SelInput) -> &'static str {
"click an axis or sketch line"
} else if a & CURVES != 0 {
"click sketch curves"
} else if a & selection::POINTS != 0 {
} else if a & (selection::POINTS | selection::SKETCH_POINTS) != 0 {
"click sketch points"
} else if a & BODIES != 0 {
"click bodies"
@@ -1496,9 +1508,26 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
}
}
fn selection_label(s: &Session, item: &Sel) -> String {
match item {
Sel::SketchCurve { id } => id.clone(),
Sel::SketchPoint { id } => crate::sketch_modify_ui::point_id(s, item).unwrap_or(id).to_string(),
Sel::Plane { name } | Sel::Axis { name } | Sel::Body { name } => name.clone(),
Sel::Face { body, index, .. } => format!("{body} · face {index}"),
Sel::Edge { body, index, .. } => format!("{body} · edge {index}"),
Sel::Feature { id } => s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
Sel::Vertex { body, point } if body.is_empty() => {
let state = s.world_state();
let p = s.active_sketch.and_then(|id| state.sketch(id)).map(|sk| sk.plane.to_local(*point)).unwrap_or_default();
format!("({:.2}, {:.2}) mm", p.x, p.y)
}
_ => format!("{item:?}"),
}
}
/// 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) {
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
// Edit Text shows its Path input among its own rows.
if matches!(d.kind, Kind::Text(_)) {
return;
@@ -1516,6 +1545,8 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
let text = if filled {
RichText::new(if custom_axis {
crate::i18n::tr("dialog.custom_axis").into()
} else if !inp.multi {
inp.items.first().map(|item| selection_label(s, item)).unwrap_or_default()
} else {
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
})
@@ -1526,6 +1557,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
// Not in the Tab order: Tab moves between values.
let b = egui::Button::new(text)
.wrap_mode(egui::TextWrapMode::Wrap)
.fill(if filled { t.chip } else { Color32::TRANSPARENT })
.stroke(stroke)
.min_size(vec2(0.0, 22.0))
@@ -1548,8 +1580,12 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
if chip.on_hover_text(tooltip).clicked() {
activate = Some(i);
}
if !inp.items.is_empty() && ui.small_button("×").on_hover_text(tr("Clear the selection")).clicked() {
clear = Some(i);
if !inp.items.is_empty() {
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() {
clear = Some(i);
}
}
});
ui.end_row();
@@ -1845,7 +1881,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
placement_row(&mut d, ui);
input_rows(&mut d, ui);
if matches!(&d.kind, Kind::SketchModify(k) if k.previews())
|| matches!(d.kind, Kind::Mirror | Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::PathPattern { .. })
{
row_label(ui, "dialog.step");
let label = d.active_input().map(|input| tr(input.label)).unwrap_or_else(|| tr("Objects"));
ui.label(crate::i18n::format(
"dialog.selection_step",
&[("step", &(d.active + 1).to_string()), ("total", &d.inputs.len().to_string()), ("input", label)],
));
ui.end_row();
row_label(ui, "");
ui.label(tr("dialog.selection_guidance"));
ui.end_row();
}
input_rows(&mut d, ui, &app.session);
match &mut d.kind {
Kind::Sketch => {
if d.error.is_some()
@@ -2480,6 +2530,12 @@ 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| {
+2
View File
@@ -111,6 +111,8 @@ mod sketch_drag_ui_tests;
#[cfg(test)]
mod sketch_edit_tests;
mod sketch_families;
#[cfg(test)]
mod sketch_guidance_ui_tests;
pub mod sketch_modify_ui;
#[cfg(test)]
mod sketch_modify_ui_tests;
+74 -4
View File
@@ -33,9 +33,16 @@ impl Colors {
}
}
pub struct SketchPreview {
pub sketch: std::sync::Arc<solidcraft_engine::doc::SolvedSketch>,
pub curves: Vec<usize>,
pub points: Vec<usize>,
}
/// A computed preview: the scene to draw and the bodies it stands in for.
pub struct Built {
pub scene: GpuScene,
pub sketches: Vec<SketchPreview>,
pub replaced: Vec<String>,
/// Why the feature failed, when the scene only shows its tool body.
pub error: Option<String>,
@@ -64,6 +71,7 @@ pub struct PreviewState {
pub key: u64,
/// Is a preview scene up?
pub active: bool,
pub sketches: Vec<SketchPreview>,
/// Why the current inputs don't make a feature (the last good preview stays up).
pub error: Option<String>,
/// A preview is being computed.
@@ -87,6 +95,7 @@ impl PreviewState {
self.error = None;
self.applied = None;
self.tapered.clear();
self.sketches.clear();
self.busy = false;
if self.active || !self.replaced.is_empty() {
self.active = false;
@@ -108,6 +117,7 @@ impl PreviewState {
self.ms = crate::now_ms() - self.started;
match job {
Ok(b) => {
self.sketches = b.sketches;
self.replaced = b.replaced;
self.bounds = b.bounds;
self.active = true;
@@ -117,6 +127,7 @@ impl PreviewState {
self.set_scene(b.scene);
}
Err(e) => {
self.sketches.clear();
self.error = Some(e);
self.applied = None;
self.tapered.clear();
@@ -128,6 +139,7 @@ impl PreviewState {
/// computing new inputs, but never let an obsolete result block their Apply.
fn poll(&mut self, key: u64, motion_key: Option<u64>) {
if self.shown_key != Some(key) {
self.sketches.clear();
self.error = None;
}
if let Some((k, rx)) = &self.running {
@@ -241,7 +253,7 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
/// kernel can't do yet), the tool body is still shown, with the error.
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
if cmds.iter().any(|(command, params)| {
command == "sketch.redefine"
matches!(command.as_str(), "sketch.redefine" | "sketch.mirror" | "sketch.pattern.rectangular" | "sketch.pattern.circular" | "sketch.offset")
|| command == "construct.plane.along_path"
|| params.get("command").and_then(Value::as_str) == Some("construct.plane.along_path")
}) {
@@ -315,7 +327,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
}
}
}
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
Built { scene: sc, sketches: Vec::new(), replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
}
/// The same commands making a new body instead of joining, cutting or intersecting, and the
@@ -425,7 +437,34 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
replaced.push(b.name.clone());
}
}
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
let sketches = after
.sketches
.iter()
.filter_map(|sketch| {
let old = before.sketch(sketch.feature);
let old_curves = old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| c.id.as_str())).collect::<std::collections::HashSet<_>>();
let old_points = old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| p.id.as_str())).collect::<std::collections::HashSet<_>>();
let indices = sketch
.sketch
.curves
.iter()
.enumerate()
.filter_map(|(index, curve)| {
let unchanged = old_curves.contains(curve.id.as_str());
if unchanged { None } else { Some(index) }
})
.collect::<Vec<_>>();
let points = sketch
.sketch
.points
.iter()
.enumerate()
.filter_map(|(i, p)| if old_points.contains(p.id.as_str()) { None } else { Some(i) })
.collect::<Vec<_>>();
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
})
.collect();
Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
}
/// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by
@@ -544,6 +583,29 @@ pub fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
}
/// The same computed sketch preview is drawn over both native GPU and CPU viewports.
pub fn paint_sketches(app: &SolveApp, painter: &egui::Painter, proj: &crate::viewport::Proj) {
if !app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_)) && d.previews()) {
return;
}
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
let mut curves = 0;
for preview in &app.preview.sketches {
let sketch = &preview.sketch;
for index in &preview.curves {
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, &mut budget);
curves += 1;
}
for index in &preview.points {
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
painter.circle_filled(point, 3.0, stroke.color);
}
}
}
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -589,7 +651,15 @@ mod tests {
}
fn pose() -> Built {
Built { scene: GpuScene::default(), replaced: vec!["Lid".into()], error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
Built {
scene: GpuScene::default(),
sketches: Vec::new(),
replaced: vec!["Lid".into()],
error: None,
bounds: Aabb3::EMPTY,
applied: None,
tapered: Vec::new(),
}
}
/// The worker completes while the slider is still held or Play keeps advancing.
+3
View File
@@ -36,6 +36,8 @@ pub const CURVES: Accept = 256;
pub const POINTS: Accept = 512;
/// Timeline features (pattern objects), picked in the timeline.
pub const FEATURES: Accept = 1024;
/// Points of the sketch being modified; unlike POINTS these never enable cross-sketch picking.
pub const SKETCH_POINTS: Accept = 2048;
/// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...).
#[derive(Clone, Debug, PartialEq)]
@@ -66,6 +68,7 @@ impl SelInput {
Hit::Axis { name } if a & AXES != 0 => Some(Sel::Axis { name: name.clone() }),
Hit::SketchCurve { id, straight: true, .. } if a & AXES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
Hit::SketchCurve { id, .. } if a & CURVES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
Hit::SketchPoint { id, .. } if a & SKETCH_POINTS != 0 => Some(Sel::SketchPoint { id: id.clone() }),
Hit::SketchPoint { sketch, id, .. } if a & POINTS != 0 => Some(Sel::SketchPoint { id: format!("{sketch}:{id}") }),
Hit::Vertex { body, point } if a & VERTICES != 0 => Some(Sel::Vertex { body: body.clone(), point: *point }),
_ => None,
@@ -0,0 +1,257 @@
#![allow(clippy::unwrap_used)]
use crate::scenario::Harness;
use crate::sketch_modify_ui::{Mod, candidate};
use crate::viewport::Hit;
use serde_json::{Value, json};
use solidcraft_engine::Sel;
fn step(h: &mut Harness, value: Value) {
h.step(&value).unwrap();
}
fn fixture() -> Harness {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
for (command, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.line", json!({"points":[[-30,10],[-15,10]]})),
("sketch.line", json!({"points":[[-15,10],[-15,25]]})),
("sketch.line", json!({"points":[[0,-10],[0,40]],"construction":true})),
] {
step(&mut h, json!({"call":"engine.execute","params":{"command":command,"params":params}}));
}
step(&mut h, json!({"call":"ui.view","params":{"view":"top"}}));
h
}
fn pick(h: &mut Harness, xy: [f64; 2], ctrl: bool) {
step(h, json!({"click":{"sketch":xy},"ctrl":ctrl}));
}
fn preview(h: &mut Harness, curves: usize) {
step(h, json!({"until":{"count":{"name":"sketch_preview_curves","value":curves}}}));
}
#[test]
fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() {
let mut h = fixture();
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let revision = h.app.session.revision;
step(&mut h, json!({"start":"sketch.mirror"}));
assert!(h.app.tool.is_none());
pick(&mut h, [-22.5, 10.0], false);
pick(&mut h, [-15.0, 17.5], false);
pick(&mut h, [-22.5, 10.0], true);
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
pick(&mut h, [-22.5, 10.0], true);
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
pick(&mut h, [0.0, 20.0], false);
preview(&mut h, 2);
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.revision, revision);
step(&mut h, json!({"click":{"handle":"dialog:clear:Mirror line"}}));
assert!(h.app.dialog.as_ref().unwrap().inputs[1].items.is_empty());
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2);
pick(&mut h, [0.0, 20.0], false);
preview(&mut h, 2);
step(&mut h, json!({"key":"Escape"}));
h.frames(3);
assert!(h.app.dialog.is_none());
assert!(!h.app.preview.active);
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
let mut h = fixture();
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 {
*count = "4".into();
*count2 = "2".into();
}
h.frames(3);
preview(&mut h, 7);
step(&mut h, json!({"click":{"handle":"dialog:OK"}}));
assert_eq!(h.app.session.world_state().sketch(1).unwrap().sketch.curves.len(), 10);
step(&mut h, json!({"start":"sketch.pattern.circular"}));
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 {
*count = "4".into();
*angle = "180 deg".into();
}
h.frames(3);
preview(&mut h, 3);
step(&mut h, json!({"click":{"handle":"dialog:OK"}}));
assert_eq!(h.app.session.world_state().sketch(1).unwrap().sketch.curves.len(), 13);
}
#[test]
fn sketch_dialog_offset_precise_distance_flip_and_editable_dimension() {
let mut h = fixture();
step(&mut h, json!({"start":"sketch.offset"}));
pick(&mut h, [-22.5, 10.0], false);
if let crate::dialogs::Kind::SketchModify(Mod::Offset { distance, flip, chain, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
*distance = "2 mm".into();
*flip = true;
*chain = false;
}
h.frames(3);
preview(&mut h, 1);
step(&mut h, json!({"click":{"handle":"dialog:OK"}}));
let param = h.app.session.doc.params.iter().find(|p| p.name.starts_with('d')).unwrap().name.clone();
step(&mut h, json!({"call":"engine.execute","params":{"command":"parameters.change","params":{"name":param,"expression":"3 mm"}}}));
let state = h.app.session.world_state();
let sk = &state.sketch(1).unwrap().sketch;
assert_eq!(sk.offsets[0].distance, -3.0);
assert!(sk.constraints.iter().any(|c| matches!(c.kind, solidcraft_engine::sketch::ConstraintKind::OffsetDistance { .. })));
}
#[test]
fn sketch_dialog_mirror_refuses_external_and_curved_references() {
let mut h = fixture();
h.app.start("sketch.mirror");
let d = h.app.dialog.as_mut().unwrap();
d.active = 1;
assert!(candidate(d, &h.app.session, &Hit::SketchCurve { sketch: 2, id: "l1".into(), straight: true }).is_none());
assert!(candidate(d, &h.app.session, &Hit::SketchCurve { sketch: 1, id: "c1".into(), straight: false }).is_none());
}
#[test]
fn sketch_dialog_box_selection_adds_replaces_and_preserves_reference() {
let mut h = fixture();
step(&mut h, json!({"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-50,-20],[-40,-20]]}}}));
step(&mut h, json!({"start":"sketch.mirror"}));
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
pick(&mut h, [0.0, 20.0], false);
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":0}}));
step(&mut h, json!({"drag":[{"sketch":[-32,8]},{"sketch":[-13,27]}]}));
let sources = |h: &Harness| {
h.app.dialog.as_ref().unwrap().inputs[0]
.items
.iter()
.filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None })
.collect::<Vec<_>>()
};
assert_eq!(sources(&h), vec!["l1", "l2"]);
step(&mut h, json!({"drag":[{"sketch":[-52,-22]},{"sketch":[-38,-18]}],"shift":true}));
assert_eq!(sources(&h), vec!["l1", "l2", "l4"]);
step(&mut h, json!({"drag":[{"sketch":[-32,8]},{"sketch":[-13,12]}]}));
assert_eq!(sources(&h), vec!["l1"]);
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[1].items, vec![Sel::SketchCurve { id: "l3".into() }]);
step(&mut h, json!({"key":"Escape"}));
step(&mut h, json!({"start":"sketch.offset"}));
step(&mut h, json!({"drag":[{"sketch":[-32,8]},{"sketch":[-13,27]}]}));
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2, "Offset accepts curves, not their endpoints or dimensions");
}
#[test]
fn sketch_modify_inputs_refuse_other_sketches_without_changing_hole_points() {
let mut h = fixture();
for command in ["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular", "sketch.offset", "sketch.move", "sketch.scale"] {
h.app.start(command);
let d = h.app.dialog.as_ref().unwrap();
assert!(candidate(d, &h.app.session, &Hit::SketchCurve { sketch: 2, id: "l1".into(), straight: true }).is_none());
assert!(candidate(d, &h.app.session, &Hit::SketchPoint { sketch: 2, id: "p1".into(), at: solidcraft_engine::geom::Vec2::ZERO }).is_none());
if command != "sketch.offset" {
assert!(d.inputs[0].accept & crate::selection::SKETCH_POINTS != 0);
assert_eq!(d.inputs[0].accept & crate::selection::POINTS, 0);
}
}
let input = crate::selection::SelInput::new("Hole points", crate::selection::POINTS, true);
assert_eq!(
input.accepts(&Hit::SketchPoint { sketch: 2, id: "p1".into(), at: solidcraft_engine::geom::Vec2::ZERO }, |_, _| false),
Some(Sel::SketchPoint { id: "2:p1".into() })
);
h.app.start("sketch.pattern.circular");
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::SketchCurve { id: "l1".into() }];
d.inputs[1].items = vec![Sel::SketchPoint { id: "2:p1".into() }];
assert!(crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_err());
}
#[test]
fn sketch_preselected_points_toggle_once_and_empty_shift_box_keeps_reference() {
let mut h = fixture();
let state = h.app.session.world_state();
let point = state.sketch(1).unwrap().sketch.points[1].id.clone();
h.app.session.selection = vec![Sel::SketchPoint { id: point.clone() }];
h.app.start("sketch.mirror");
let d = h.app.dialog.as_mut().unwrap();
assert_eq!(d.inputs[0].items, vec![Sel::SketchPoint { id: point.clone() }]);
d.active = 0;
let selected = candidate(d, &h.app.session, &Hit::SketchPoint { sketch: 1, id: point, at: solidcraft_engine::geom::Vec2::ZERO }).unwrap();
d.pick(&h.app.session, selected);
assert!(d.inputs[0].items.is_empty(), "clicking a preselection removes it instead of duplicating it");
d.active = 1;
let reference = Sel::SketchCurve { id: "l3".into() };
d.pick(&h.app.session, reference.clone());
d.take_box(&h.app.session, Vec::new(), true);
assert_eq!(d.inputs[1].items, vec![reference]);
d.take_box(&h.app.session, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }], true);
assert_eq!(d.inputs[1].items, vec![Sel::SketchCurve { id: "l1".into() }], "a box replaces a single reference with one item");
}
#[test]
fn sketch_modify_previews_use_the_active_occurrences_world_placement() {
let mut h = Harness::new();
let part = h.app.run("component.create", json!({"name":"Part"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
h.app.run("sketch.line", json!({"points":[[-30,0],[-20,0]]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,-10],[0,10]],"construction":true})).unwrap();
h.app
.run("occurrence.move", json!({"occurrence":part["occurrence"],"translate":[100,30,20],"axis":[0,0,1],"angle":"35 deg","capture":false}))
.unwrap();
let original = h.app.session.doc.to_json();
let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get());
for (command, params) in [
("sketch.mirror", json!({"entities":["l1"],"line":"l2"})),
("sketch.pattern.rectangular", json!({"entities":["l1"],"count":3,"spacing":10})),
("sketch.pattern.circular", json!({"entities":["l1"],"center":[0,0],"count":4})),
("sketch.offset", json!({"curves":["l1"],"distance":2,"chain":false})),
] {
let built = crate::preview::compute(&h.app.session, &[(command.into(), params.clone())], colors).unwrap();
let mut scratch = h.app.session.scratch();
scratch.execute(command, &params).unwrap();
let state = scratch.world_state();
let expected = state.sketch(sid).unwrap().plane;
assert!(expected.origin.len() > 50.0, "the fixture must exercise a moved occurrence");
assert_eq!(built.sketches.len(), 1);
assert!(!built.sketches[0].curves.is_empty());
assert_eq!(built.sketches[0].sketch.plane, expected, "{command}");
assert_eq!(h.app.session.doc.to_json(), original);
}
}
#[test]
fn sketch_corner_preselected_point_uses_the_same_identity_as_corner_picking() {
let mut h = fixture();
let state = h.app.session.world_state();
let sk = &state.sketch(1).unwrap().sketch;
let index = sk.resolve_point("l1.end").unwrap();
let point = sk.points[index].id.clone();
h.app.session.selection = vec![Sel::SketchPoint { id: point }];
h.app.start("sketch.fillet");
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
pick(&mut h, [-15.0, 10.0], true);
assert!(h.app.dialog.as_ref().unwrap().inputs[0].items.is_empty());
}
#[test]
fn sketch_modify_keeps_custom_point_ids_that_contain_colons() {
let mut h = fixture();
h.app.run("sketch.point", json!({"point":[-40,0],"id":"2:centre"})).unwrap();
h.app.start("sketch.pattern.circular");
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::SketchCurve { id: "l1".into() }];
d.inputs[1].items = vec![Sel::SketchPoint { id: "2:centre".into() }];
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(commands[0].1["center"], "2:centre");
for (command, params) in commands {
h.app.run(&command, params).unwrap();
}
assert_eq!(h.app.session.world_state().sketch(1).unwrap().sketch.curves.len(), 8);
}
+75 -47
View File
@@ -31,7 +31,7 @@ use solidcraft_engine::geom::{Vec2, Vec3};
use crate::SolveApp;
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
use crate::selection::{AXES, CURVES, POINTS, SelInput};
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
use crate::viewport::{Hit, Proj};
/// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order.
@@ -91,6 +91,10 @@ pub enum Mod {
}
impl Mod {
pub fn previews(&self) -> bool {
matches!(self, Self::Mirror | Self::PatternRect { .. } | Self::PatternCirc { .. } | Self::Offset { .. })
}
pub fn title(&self) -> &'static str {
match self {
Mod::Scale { .. } => "SKETCH SCALE",
@@ -121,20 +125,20 @@ impl Mod {
fn inputs(k: &Mod) -> Vec<SelInput> {
match k {
Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Base point", POINTS, false)],
Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Base point", SKETCH_POINTS, false)],
Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)],
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
Mod::Mirror => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Mirror line", AXES, false)],
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Mirror line", AXES, false)],
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 | POINTS, true), SelInput::new("Direction", AXES, false)],
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Direction", AXES, false)],
// The centre is required: `sketch.pattern.circular` has no default for it.
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
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
// cuts one corner, so its input holds one.
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", POINTS, true)],
Mod::Corner { .. } => vec![SelInput::new("Corner", POINTS, false)],
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS, true)],
Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS, false)],
}
}
@@ -165,6 +169,25 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
Some((Kind::SketchModify(k), i))
}
/// Sketch modify inputs accept only the sketch being edited. Origin axes remain valid
/// rectangular-pattern directions, while Hole continues using its separate POINTS mask.
pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
let active = session.active_sketch?;
match hit {
Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } if *sketch != active => None,
_ => d.active_input()?.accepts(hit, |_, _| false),
}
}
/// Place a circular pattern centre in empty sketch space without creating a document point.
pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool {
if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 {
return false;
}
d.pick(session, Sel::Vertex { body: String::new(), point });
true
}
/// The rows after the inputs; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
let mut enter = false;
@@ -311,7 +334,7 @@ pub(crate) fn corner_hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Hit,
let Some(Kind::SketchModify(Mod::Corner { chamfer, .. })) = app.dialog.as_ref().map(|d| &d.kind) else { return None };
let sketch = app.session.active_sketch?;
let (at, id) = crate::sketch_tools::corner_point(app, proj, pos, !*chamfer)?;
let sel = Sel::SketchPoint { id: format!("{sketch}:{id}") };
let sel = Sel::SketchPoint { id: id.clone() };
Some((Hit::SketchPoint { sketch, id, at }, sel))
}
@@ -320,23 +343,28 @@ fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
/// A picked sketch point as the id its own sketch knows it by. Picking prefixes the sketch
/// ("`<sketch>:<point>`") so points of any visible sketch stay distinct; a sketch command
/// names the point alone.
fn point_id(sel: &Sel) -> Option<&str> {
match sel {
Sel::SketchPoint { id } => Some(id.split_once(':').map(|(_, p)| p).unwrap_or(id)),
_ => None,
/// A local point id. Qualified ids supplied by scripts or older selections are also accepted;
/// commands check that their sketch prefix identifies the sketch being edited.
pub(crate) fn point_id<'a>(session: &solidcraft_engine::Session, sel: &'a Sel) -> Option<&'a str> {
let Sel::SketchPoint { id } = sel else { return None };
let Some((owner, point)) = id.split_once(':') else { return Some(id) };
let active = session.active_sketch?;
// Custom local ids may themselves contain ':'. Preserve them before interpreting a
// legacy qualified selection, whose owner must match the sketch being edited.
if session.world_state().sketch(active).is_some_and(|sk| sk.sketch.point_index(id).is_some()) {
Some(id)
} else {
(owner.parse::<u64>().ok() == Some(active)).then_some(point)
}
}
/// The curves and lone points picked in input `i`, as the engine's `entities` list.
fn entity_ids(inputs: &[SelInput], i: usize) -> Vec<String> {
fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec<String> {
items(inputs, i)
.iter()
.filter_map(|x| match x {
Sel::SketchCurve { id } => Some(id.clone()),
_ => point_id(x).map(str::to_string),
_ => point_id(session, x).map(str::to_string),
})
.collect()
}
@@ -412,20 +440,9 @@ fn length(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
}
Ok(v)
}
/// Corner point ids picked in input `i`, within the active sketch. A `POINTS` pick is stored as
/// "<sketch id>:<point id>"; a point selected without the window carries the bare id.
/// Corner point ids within the active sketch, including qualified script selections.
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
let active = app.session.active_sketch;
items(inputs, i)
.iter()
.filter_map(|x| match x {
Sel::SketchPoint { id } => match id.split_once(':') {
Some((sketch, point)) => (sketch.parse::<u64>().ok() == active).then(|| point.to_string()),
None => Some(id.clone()),
},
_ => None,
})
.collect()
items(inputs, i).iter().filter_map(|sel| point_id(&app.session, sel).map(str::to_string)).collect()
}
/// A corner size evaluated against the document's parameters, which every corner command needs
@@ -472,23 +489,25 @@ fn total_angle(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
/// The bare sketch point id of a single point pick, as a command's `center` or `base` reads it.
/// `point_id` above takes one `Sel`; this one takes the first pick of an input.
fn picked_point(inputs: &[SelInput], i: usize) -> Option<String> {
items(inputs, i).first().and_then(point_id).map(str::to_string)
fn picked_point(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Option<String> {
items(inputs, i).first().and_then(|sel| point_id(session, sel)).map(str::to_string)
}
/// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for
/// its factor, `sketch.offset` one for its distance and `sketch.move` its distances and angle,
/// not expression strings: none has a model parameter to carry one, unlike `sketch.fillet`'s radius.
/// The command a sketch modify dialog makes. Scale, Move, and patterns send evaluated
/// numbers. Offset and Fillet retain expressions in persistent driving dimensions.
pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
if inputs.iter().flat_map(|input| &input.items).any(|sel| matches!(sel, Sel::SketchPoint { .. }) && point_id(&app.session, sel).is_none()) {
return Err("Select points of the sketch being edited".into());
}
let (cmd, mut params) = match k {
Mod::Scale { factor: text } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to scale first".into());
}
let f = factor(app, text)?;
let mut p = json!({"entities": entities, "factor": f});
if let Some(base) = items(inputs, 1).first().and_then(point_id) {
if let Some(base) = items(inputs, 1).first().and_then(|sel| point_id(&app.session, sel)) {
p["base"] = json!(base);
}
("sketch.scale", p)
@@ -499,20 +518,21 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
return Err("Select the curves to offset first".into());
}
// Symmetric hides Flip, so a flip left over from another mode does not travel with it.
let d = signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?;
let mut params = json!({"curves": curves, "distance": d, "chain": chain});
signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?;
let mut params = json!({"curves": curves, "distance": distance.trim(), "flip": *flip && *mode != SYMMETRIC, "chain": chain});
if let Value::Object(m) = &mut params {
if *mode == SYMMETRIC {
m.insert("symmetric".into(), json!(true));
}
if *mode == TWO_SIDES {
m.insert("distance2".into(), json!(signed_length(app, "Distance 2", distance2, false)?.abs()));
let d2 = signed_length(app, "Distance 2", distance2, false)?;
m.insert("distance2".into(), json!(if d2 > 0.0 { distance2.trim().to_string() } else { format!("-({})", distance2.trim()) }));
}
}
("sketch.offset", params)
}
Mod::Mirror => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to mirror first".into());
}
@@ -529,7 +549,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
("sketch.mirror", json!({"entities": entities, "line": line}))
}
Mod::Move { x, y, angle, copy } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to move first".into());
}
@@ -540,13 +560,13 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
return Err("Type a distance or an angle to move by".into());
}
let mut p = json!({"entities": entities, "translate": [dx, dy], "angle": deg, "copy": copy});
if let Some(center) = items(inputs, 1).first().and_then(point_id) {
if let Some(center) = items(inputs, 1).first().and_then(|sel| point_id(&app.session, sel)) {
p["center"] = json!(center);
}
("sketch.move", p)
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
}
@@ -573,11 +593,19 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
("sketch.pattern.rectangular", p)
}
Mod::PatternCirc { count, angle } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
}
let center = picked_point(inputs, 1).ok_or("Select the centre the pattern turns around first")?;
let center = match items(inputs, 1).first() {
Some(Sel::Vertex { body, point }) if body.is_empty() => {
let plane =
app.session.world_state().sketch(app.session.active_sketch.ok_or("open a sketch first")?).ok_or("sketch is missing")?.plane;
let p = plane.to_local(*point);
json!([p.x, p.y])
}
_ => json!(picked_point(&app.session, inputs, 1).ok_or("Select the centre the pattern turns around first")?),
};
let n = whole(app, "Quantity", count)?;
if n < 2 {
return Err("Quantity must be at least 2".into());
+2
View File
@@ -71,6 +71,8 @@ pub struct Tokens {
pub manipulator: Color32,
/// New surface in a feature preview.
pub preview_add: Color32,
/// Proposed sketch geometry, distinct from blue selections.
pub preview_sketch: Color32,
/// Removed material in a feature preview (translucent).
pub preview_cut: Color32,
/// Navigation cube faces, outline and labels.
+25 -4
View File
@@ -2129,6 +2129,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
if let Some((hit, Some(sel))) = cand {
dialog_pick(app, &hit, sel);
} else if let Some(mut dialog) = app.dialog.take() {
let state = app.session.world_state();
if let Some(sketch) = app.session.active_sketch.and_then(|id| state.sketch(id)) {
let (origin, direction) = proj.ray(p);
if let Some(point) = sketch.plane.intersect_ray(origin, direction) {
crate::sketch_modify_ui::pick_position(&mut dialog, &app.session, point);
}
}
app.dialog = Some(dialog);
}
} else {
// Dimensions and constraint glyphs sit on top of the geometry.
@@ -2382,6 +2391,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
} else {
app.viewport.boxsel = None;
}
crate::preview::paint_sketches(app, &painter, &proj);
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
overlays(app, &painter, &proj);
@@ -2543,12 +2553,19 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
let st = app.session.world_state();
let mut out: Vec<Sel> = Vec::new();
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
if app.dialog.is_none()
let sketch_dialog = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_)));
let accepts = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept);
if (app.dialog.is_none() || sketch_dialog)
&& let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
{
// What the selection filter lets through (window: wholly inside; crossing: touching).
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
for (i, c) in ss.sketch.curves.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_curves")) {
if accepts
.is_some_and(|a| a & crate::selection::CURVES == 0 && (a & crate::selection::AXES == 0 || !matches!(c.kind, CurveKind::Line { .. })))
{
continue;
}
if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) {
continue;
}
@@ -2570,18 +2587,22 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) {
if i != 0 && solidcraft_engine::view::sketch_point_visible(&ss.sketch, i) && bx.takes(&to2(&[ss.plane.to_world(p.pos)])) {
if accepts.is_none_or(|a| a & crate::selection::SKETCH_POINTS != 0)
&& i != 0
&& solidcraft_engine::view::sketch_point_visible(&ss.sketch, i)
&& bx.takes(&to2(&[ss.plane.to_world(p.pos)]))
{
out.push(Sel::SketchPoint { id: p.id.clone() });
}
}
if !off("dimensions") {
if !sketch_dialog && !off("dimensions") {
for (id, r) in crate::dim_view::text_rects() {
if bx.takes(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]) {
out.push(Sel::SketchConstraint { id });
}
}
}
if !off("constraints") {
if !sketch_dialog && !off("constraints") {
for (id, c) in crate::sketch_tools::glyph_positions() {
if bx.takes(&[c]) {
out.push(Sel::SketchConstraint { id });