fix(sketch): apply Project to complete preselection

This commit is contained in:
lillith authored and bherbruck committed 2026-10-12 10:05:52 -04:00
1 parent 56b499e951
commit 874315db8e
9 files changed
+231 -6

No files matched your search

+1
View File
@@ -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();
+9
View File
@@ -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,
+5 -1
View File
@@ -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,
}
}
+9
View File
@@ -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!(
+24
View File
@@ -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 {
+52 -5
View File
@@ -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);
}