Select: resolve faces and edges from a point or index (#10171) (#10191)

* claim: #10171

* fix(select): resolve faces and edges from a point or index
This commit is contained in:
Colin Faulkingham
2026-10-31 19:11:54 +00:00
committed by GitHub
parent c39f44e190
commit d4144ddf9b
3 changed files with 171 additions and 2 deletions
+61 -2
View File
@@ -88,7 +88,7 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("select.set", "Select", select_set).icon("select")
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"),
CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only()
.params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"),
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
@@ -1225,6 +1225,65 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
/// Complete a partial script pick without changing the stored selection type or reinterpreting
/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index:
/// it must not merge a cylinder's patches or a circular rim's arcs into a different item.
fn selection_item(s: &Session, value: Value) -> Result<Sel> {
use solidcraft_geom::Vec3;
let kind = value.get("type").and_then(Value::as_str);
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()));
}
let face = kind == Some("face");
#[derive(serde::Deserialize)]
struct Pick {
body: String,
index: Option<usize>,
point: Option<Vec3>,
}
let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?;
let state = s.world_state();
let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?;
let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" }));
let (index, point) = match (index, point) {
(Some(index), None) => {
let point = if face {
solidcraft_doc::naming::face_spot(b, index).map(|spot| spot.point)
} else {
let mesh = b.mesh();
mesh.edges.get(index).and_then(|edge| match edge.as_slice() {
[a, b] => Some(a.lerp(*b, 0.5)),
// Use a sample on the curve, not the midpoint of a chord inside it.
points if points.len() > 2 => points.get(points.len() / 2).copied(),
_ => None,
})
};
(index, point.filter(|p| p.is_finite()).ok_or_else(missing)?)
}
(None, Some(point)) if point.is_finite() => {
let index = if face {
solidcraft_doc::appearance::face_index_at(b, point)
} else {
let mesh = b.mesh();
mesh.edges
.iter()
.enumerate()
.filter_map(|(index, edge)| {
let distance =
edge.windows(2).filter_map(|w| Some(point.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
(distance <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((index, distance))
})
.min_by(|(_, a), (_, b)| a.total_cmp(b))
.map(|(index, _)| index)
};
(index.ok_or_else(missing)?, point)
}
_ => return Err(bad("select.set", "a face or edge needs an index or a finite point")),
};
Ok(if face { Sel::Face { body, index, point } } else { Sel::Edge { body, index, point } })
}
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
let items = p.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
if items.len() > 10_000 {
@@ -1232,7 +1291,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut sel: Vec<Sel> = Vec::new();
for it in items {
let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?;
let one = selection_item(s, it)?;
match &one {
Sel::Occurrence { path } => {
s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?;
+101
View File
@@ -8,6 +8,107 @@ fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn select_geometry_resolves_points_and_indices_without_changing_history() {
use solidcraft_geom::Vec3;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"body_name":"Block", "corner":[20,30,40], "length":10, "width":10, "height":10}));
let before = (s.doc.to_json(), s.undo.len());
for (kind, point) in [("face", Vec3::new(25.0, 35.0, 50.0)), ("edge", Vec3::new(25.0, 30.0, 50.0))] {
assert_eq!(run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Block","point":point}]}))["selected"], 1);
let index = match &s.selection[0] {
Sel::Face { index, point: actual, .. } | Sel::Edge { index, point: actual, .. } => {
assert_eq!(*actual, point);
*index
}
other => panic!("unexpected selection: {other:?}"),
};
let measured = s.measure_items(&s.selection);
let (field, expected) = if kind == "face" { ("area_mm2", 100.0) } else { ("length_mm", 10.0) };
assert!((measured["items"][0][field].as_f64().unwrap() - expected).abs() < 1e-9, "{measured}");
run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Block","index":index}]}));
let complete = serde_json::to_value(&s.selection[0]).unwrap();
assert_eq!(complete["index"], index);
let mut point_only = complete.clone();
point_only.as_object_mut().unwrap().remove("index");
run(&mut s, "select.set", json!({"items":[point_only]}));
assert_eq!(serde_json::to_value(&s.selection[0]).unwrap(), complete);
}
assert_eq!((s.doc.to_json(), s.undo.len()), before);
}
#[test]
fn select_geometry_curved_picks_round_trip_and_add_without_duplicates() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"body_name":"Round", "radius":5, "height":10}));
for (kind, point) in [("face", [3, 4, 5]), ("edge", [3, 4, 10])] {
run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Round","point":point}]}));
let index = serde_json::to_value(&s.selection[0]).unwrap()["index"].clone();
run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Round","index":index}]}));
let complete = serde_json::to_value(&s.selection[0]).unwrap();
let pick: solidcraft_geom::Vec3 = serde_json::from_value(complete["point"].clone()).unwrap();
assert!((pick.x.hypot(pick.y) - 5.0).abs() < 1e-6, "the inferred point lies on the cylinder: {complete}");
let mut partial = complete.clone();
partial.as_object_mut().unwrap().remove("index");
run(&mut s, "select.set", json!({"items":[partial, complete],"add":true}));
assert_eq!(s.selection.len(), 1);
assert_eq!(serde_json::to_value(&s.selection[0]).unwrap(), complete);
let measured = s.measure_items(&s.selection);
assert!((measured["items"][0]["diameter"].as_f64().unwrap() - 10.0).abs() < 1e-6, "{measured}");
}
}
#[test]
fn select_geometry_uses_the_placed_body_in_world_coordinates() {
let mut s = Session::default();
let created = run(&mut s, "component.create", json!({"name":"Part"}));
run(&mut s, "solid.box", json!({"body_name":"Block", "length":2, "width":3, "height":4}));
let second = run(&mut s, "occurrence.copy", json!({"occurrence":created["occurrence"]}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
let name = s.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
for (kind, point) in [("face", [8.5, 21.0, 4.0]), ("edge", [8.5, 20.0, 4.0])] {
run(&mut s, "select.set", json!({"items":[{"type":kind,"body":name,"point":point}]}));
let complete = serde_json::to_value(&s.selection[0]).unwrap();
assert_eq!(complete["body"], name);
run(&mut s, "select.set", json!({"items":[{"type":kind,"body":name,"index":complete["index"]}]}));
let inferred = serde_json::to_value(&s.selection[0]).unwrap();
let pick: solidcraft_geom::Vec3 = serde_json::from_value(inferred["point"].clone()).unwrap();
assert!((pick.z - 4.0).abs() < 1e-9 && (7.0..=10.0).contains(&pick.x) && (20.0..=22.0).contains(&pick.y), "{inferred}");
assert!(s.execute("select.set", &json!({"items":[{"type":kind,"body":name,"point":[1,1,4]}]})).is_err());
assert_eq!(serde_json::to_value(&s.selection[0]).unwrap(), inferred);
}
}
#[test]
fn select_geometry_invalid_partial_picks_leave_selection_and_revision_unchanged() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"body_name":"Block", "length":10, "width":10, "height":10}));
run(&mut s, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
let before = (s.selection.clone(), s.revision, s.undo.len());
for kind in ["face", "edge"] {
for fields in [
json!({}),
json!({"index":999999}),
json!({"index":-1}),
json!({"index":"0"}),
json!({"point":[500,500,500]}),
json!({"point":[1,2]}),
json!({"point":[1e308,1e308,1e308]}),
] {
let mut item = json!({"type":kind,"body":"Block"});
item.as_object_mut().unwrap().extend(fields.as_object().unwrap().clone());
for add in [false, true] {
let result = s.execute("select.set", &json!({"items":[{"type":"face","body":"Block","point":[5,5,10]},item],"add":add}));
assert!(result.is_err(), "accepted {item}");
assert_eq!((s.selection.clone(), s.revision, s.undo.len()), before, "{item}");
}
}
}
assert!(s.execute("select.set", &json!({"items":[{"type":"face","body":"Missing","point":[5,5,10]}]})).is_err());
assert_eq!((s.selection, s.revision, s.undo.len()), before);
}
#[test]
fn history_playback_is_a_typed_display_request_without_history_edits() {
let mut s = Session::default();
+9
View File
@@ -343,6 +343,15 @@ the top level. With two items:
Earlier fields are unchanged, so older scripts keep working.
To select geometry in the view, use `execute` `select.set` with
`{"items":[{"type":"face","body":"Body1","point":[5,5,10]}]}` (or `"type":"edge"`).
The point is in world millimetres and uses the same proximity tolerance as Measure. An
`index` can replace the point; the command then supplies a point on that face or edge,
including curved faces and placed components. Existing selections with both fields keep
their index and point. Missing geometry or an invalid partial pick leaves the entire
selection unchanged, including with `"add":true`. `inspect_design` reports the completed
selections with both fields.
To change a feature after it is made, read its definition with `execute` `timeline.inspect`
(`{feature}`: id or name) and change fields with `timeline.edit` (`{feature, set}`; e.g.
`{"extent": {"distance": "30"}}`). The fields per feature type are listed in the book's