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

+4
View File
@@ -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.
+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);
}
+5
View File
@@ -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