fix(sketch): apply Project to complete preselection
This commit is contained in:
1 parent
56b499e951
commit
874315db8e
9 files changed
+231
-6
No files matched your search
@@ -240,6 +240,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
|
||||
@@ -90,6 +90,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
|
||||
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
|
||||
Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => skip("edit the source sketch to delete its geometry".into()),
|
||||
}
|
||||
}
|
||||
features.dedup();
|
||||
|
||||
@@ -107,6 +107,15 @@ pub enum Sel {
|
||||
SketchPoint {
|
||||
id: String,
|
||||
},
|
||||
/// Geometry of a different sketch, retained for projection preselection.
|
||||
ExternalSketchCurve {
|
||||
sketch: u64,
|
||||
id: String,
|
||||
},
|
||||
ExternalSketchPoint {
|
||||
sketch: u64,
|
||||
id: String,
|
||||
},
|
||||
/// A constraint or dimension (by id) of the active sketch.
|
||||
SketchConstraint {
|
||||
id: String,
|
||||
|
||||
@@ -290,7 +290,11 @@ fn fits(a: Accept, s: &Sel) -> bool {
|
||||
Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0,
|
||||
Sel::Vertex { .. } => a & selection::VERTICES != 0,
|
||||
Sel::Feature { .. } => a & selection::FEATURES != 0,
|
||||
Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } | Sel::Occurrence { .. } => false,
|
||||
Sel::SketchPoint { .. }
|
||||
| Sel::SketchConstraint { .. }
|
||||
| Sel::Occurrence { .. }
|
||||
| Sel::ExternalSketchCurve { .. }
|
||||
| Sel::ExternalSketchPoint { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -539,6 +539,15 @@ impl SolveApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if id == "sketch.project"
|
||||
&& let Some(refs) = tools::projection_preselection(&self.session.selection)
|
||||
{
|
||||
if self.run(id, json!({"refs":refs})).is_ok() {
|
||||
let _ = self.session.execute("select.clear", &json!({}));
|
||||
self.set_status("Projection applied", false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Curve-pair constraints can consume an exact preselection. Leave ambiguous
|
||||
// selections to the ordinary pick tool; never silently constrain a subset.
|
||||
if matches!(
|
||||
|
||||
@@ -388,6 +388,30 @@ pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_eng
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn projection_preselection(selection: &[solvecraft_engine::Sel]) -> Option<Vec<Value>> {
|
||||
use solvecraft_engine::Sel;
|
||||
// The projection command processes at most 1000 references; never consume a subset.
|
||||
if selection.is_empty() || selection.len() > 1000 {
|
||||
return None;
|
||||
}
|
||||
selection
|
||||
.iter()
|
||||
.map(|item| {
|
||||
Some(match item {
|
||||
Sel::Body { name } => json!({"body":name}),
|
||||
Sel::ExternalSketchCurve { sketch, id } => json!({"sketch":sketch,"curve":id}),
|
||||
Sel::ExternalSketchPoint { sketch, id } => json!({"sketch":sketch,"point":id}),
|
||||
Sel::Vertex { body, point } => json!({"body":body,"vertex":[point.x,point.y,point.z]}),
|
||||
Sel::Edge { body, point, .. } => json!({"body":body,"edge":[point.x,point.y,point.z]}),
|
||||
Sel::Face { body, point, .. } => json!({"body":body,"face":[point.x,point.y,point.z]}),
|
||||
Sel::Axis { name } => json!({"axis":name}),
|
||||
Sel::Plane { name } => json!({"plane":name}),
|
||||
_ => return None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
|
||||
@@ -705,13 +705,14 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
let (o, d) = proj.ray(pos);
|
||||
// Sketch points of the active sketch (of every shown sketch while a command takes points).
|
||||
let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0);
|
||||
let point_sketches: Vec<u64> = if wants_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
|
||||
let external_points = wants_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
|
||||
let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
|
||||
let mut best: Option<(f32, Hit)> = None;
|
||||
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
|
||||
let sid = ss.feature;
|
||||
for p in &ss.sketch.points {
|
||||
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
|
||||
let dd = sp.distance(pos);
|
||||
let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||||
if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }));
|
||||
}
|
||||
@@ -995,6 +996,22 @@ pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3
|
||||
}
|
||||
}
|
||||
Sel::Vertex { point, .. } => b.add(*point),
|
||||
Sel::ExternalSketchPoint { sketch, id } => {
|
||||
if let Some(ss) = st.sketch(*sketch)
|
||||
&& let Some(p) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i))
|
||||
{
|
||||
b.add(ss.plane.to_world(p));
|
||||
}
|
||||
}
|
||||
Sel::ExternalSketchCurve { sketch, id } => {
|
||||
if let Some(ss) = st.sketch(*sketch)
|
||||
&& let Some(i) = ss.sketch.curve_index(id)
|
||||
{
|
||||
for p in ss.sketch.polyline(i) {
|
||||
b.add(ss.plane.to_world(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Sel::SketchCurve { id } => {
|
||||
for ss in &st.sketches {
|
||||
if let Some(i) = ss.sketch.curve_index(id) {
|
||||
@@ -1063,7 +1080,7 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
|
||||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||||
hits.iter()
|
||||
.filter(|h| match h {
|
||||
Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch) && !off("sketch") && !off("sketch_curves"),
|
||||
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
|
||||
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
|
||||
Hit::Profile { .. } => !off("sketch"),
|
||||
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
|
||||
@@ -1072,6 +1089,12 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
|
||||
Hit::Plane { .. } | Hit::Axis { .. } => true,
|
||||
})
|
||||
.find_map(|h| match h {
|
||||
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||||
})
|
||||
@@ -1292,6 +1315,17 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge),
|
||||
Sel::ExternalSketchCurve { sketch, id } => {
|
||||
if let Some(ss) = st.sketch(*sketch)
|
||||
&& let Some(ci) = ss.sketch.curve_index(id)
|
||||
{
|
||||
for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts {
|
||||
for w in pts.windows(2) {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), c4(t.sel_edge), 3.5, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A 3D sketch curve, in whichever sketch has it.
|
||||
Sel::SketchCurve { id } if st.sketches.iter().any(|ss| ss.sketch.wire_index(id).is_some()) => {
|
||||
for ss in st.sketches.iter() {
|
||||
@@ -1447,7 +1481,9 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2,
|
||||
let st = app.session.world_state();
|
||||
let ss = st.sketch(sid)?;
|
||||
for h in pick(app, proj, pos) {
|
||||
if let Hit::SketchPoint { id, at, .. } = h {
|
||||
if let Hit::SketchPoint { sketch, id, at } = h
|
||||
&& sketch == sid
|
||||
{
|
||||
return Some((at, Some(id)));
|
||||
}
|
||||
}
|
||||
@@ -1832,7 +1868,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
{
|
||||
let active = app.session.active_sketch;
|
||||
match pick(app, &proj, a).into_iter().next() {
|
||||
Some(Hit::SketchPoint { id, .. }) => app.viewport.point_drag = Some((id, app.session.undo.len(), None)),
|
||||
Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => {
|
||||
app.viewport.point_drag = Some((id, app.session.undo.len(), None))
|
||||
}
|
||||
Some(Hit::SketchCurve { sketch, id, .. }) if Some(sketch) == active => {
|
||||
app.viewport.point_drag = on_plane(app, a).map(|q| (id, app.session.undo.len(), Some(q)));
|
||||
}
|
||||
@@ -2239,6 +2277,15 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let t = Tokens::get();
|
||||
let st = app.session.world_state();
|
||||
for item in &app.session.selection {
|
||||
if let Sel::ExternalSketchPoint { sketch, id } = item
|
||||
&& let Some(ss) = st.sketch(*sketch)
|
||||
&& let Some(point) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i))
|
||||
&& let Some(pos) = proj.to_screen(ss.plane.to_world(point))
|
||||
{
|
||||
painter.circle_stroke(pos, 6.0, Stroke::new(2.0, t.sel_edge));
|
||||
}
|
||||
}
|
||||
let Some(sid) = app.session.active_sketch else { return };
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let sk = &ss.sketch;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_ui_egui::scenario::Harness;
|
||||
|
||||
fn setup() -> (Harness, u64) {
|
||||
let mut h = Harness::new();
|
||||
let source = h.app.run("sketch.create", json!({"plane":"XY","offset":10})).unwrap()["sketch"].as_u64().unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[20,20],"id":"p"})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[40,40],[50,40]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[45,50],"id":"p"})).unwrap();
|
||||
h.call("ui.set", json!({"shownSketches":[source]}));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
(h, source)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_external_curve_and_point_project_with_source_identity_and_one_undo() {
|
||||
let (mut h, source) = setup();
|
||||
h.step(&json!({"click":{"world":[20,10,10]}})).unwrap();
|
||||
h.step(&json!({"click":{"world":[20,20,10]},"shift":true})).unwrap();
|
||||
assert_eq!(
|
||||
h.app.session.selection,
|
||||
vec![Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }, Sel::ExternalSketchPoint { sketch: source, id: "p".into() }]
|
||||
);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"key":"P"})).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{:?}", h.app.status);
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert!(h.app.tool.is_none());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert_eq!(sk.curves.len(), 2);
|
||||
assert!(sk.curves.iter().any(|curve| curve.link.is_some()));
|
||||
assert!(sk.points.iter().any(|point| point.link.is_some() && (point.pos.x - 20.).abs() < 1e-6 && (point.pos.y - 20.).abs() < 1e-6));
|
||||
let local = sk.point(sk.resolve_point("p").unwrap()).unwrap();
|
||||
assert!((local.x - 45.).abs() < 1e-6 && (local.y - 50.).abs() < 1e-6);
|
||||
let after = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_reference_preserves_selection_and_document_atomically() {
|
||||
let (mut h, source) = setup();
|
||||
h.app.session.selection =
|
||||
vec![Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }, Sel::ExternalSketchPoint { sketch: source, id: "missing".into() }];
|
||||
let selection = h.app.session.selection.clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.project"})).unwrap();
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_point_drag_and_delete_cannot_edit_same_named_local_point() {
|
||||
let (mut h, source) = setup();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"drag":[{"world":[20,20,10]},{"world":[25,25,10]}]})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.app.session.selection =
|
||||
vec![Sel::ExternalSketchPoint { sketch: source, id: "p".into() }, Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }];
|
||||
let error = h.app.run("selection.delete", json!({"dry_run":true})).unwrap_err();
|
||||
assert!(error.contains("nothing to delete"), "{error}");
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_and_plane_preselection_projects_as_one_command() {
|
||||
let (mut h, _) = setup();
|
||||
h.app.session.selection = vec![Sel::Axis { name: "X".into() }, Sel::Plane { name: "YZ".into() }];
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.project"})).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{:?}", h.app.status);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert_eq!(sk.curves.iter().filter(|c| c.link.is_some()).count(), 2);
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_local_and_external_selection_keeps_ordinary_project_picking() {
|
||||
let (mut h, source) = setup();
|
||||
h.app.session.selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }];
|
||||
let before = h.app.session.doc.to_json();
|
||||
let selection = h.app.session.selection.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"key":"P"})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().map(|tool| tool.cmd), Some("sketch.project"));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.step(&json!({"click":{"world":[20,10,10]}})).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let state = h.app.session.world_state();
|
||||
assert!(state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.iter().any(|curve| curve.link.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_preselection_does_not_silently_project_a_subset() {
|
||||
let (mut h, _) = setup();
|
||||
h.app.session.selection = vec![Sel::Axis { name: "X".into() }; 1001];
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.project"})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().map(|tool| tool.cmd), Some("sketch.project"));
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.selection.len(), 1001);
|
||||
}
|
||||
@@ -21,6 +21,11 @@ fillet, chamfer, offset (O), mirror, patterns, move/copy and scale.
|
||||
|
||||
**Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that
|
||||
follows later changes. Sketching on a face projects its edges automatically.
|
||||
For Project, select visible source curves or points from another sketch, model edges,
|
||||
faces, vertices or bodies, or axes and planes, then press P. The entire supported selection
|
||||
is projected in one undo step. A rejected projection keeps the selection for correction;
|
||||
an empty or mixed selection starts the ordinary picking tool. External sketch selections
|
||||
retain their source sketch, even when the active sketch uses the same point or curve names.
|
||||
|
||||
Run from a script or the control channel, every command that adds sketch geometry returns the
|
||||
ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and
|
||||
|
||||
Reference in new issue
Block a user