Sketch: show open and untied profile ends (#2000) (#2001)

Thanks @jijinbei.
This commit is contained in:
JIJINBEI authored and GitHub committed 2026-10-17 18:52:12 -04:00
1 parent 487aa64e10
commit 2acc344aa4
14 files changed
+661 -7

No files matched your search

+2
View File
@@ -69,6 +69,7 @@ mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
@@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
+97
View File
@@ -0,0 +1,97 @@
//! Loose ends of a sketch's profile curves: the ones that touch nothing (open) and the ones that
//! touch another curve only by position (untied), and tying the untied ones.
use serde_json::{Value, json};
use solidcraft_sketch::{ConstraintKind, EndReport, EndTouch, Sketch, loose_ends};
use super::sketch::{add_c, edit, target_sketch};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.loose_ends", "Loose Ends", loose_ends_cmd).icon("c_coincident").enabled(in_sketch).noundo().params(
"sketch?: id|name (default active): the ends of profile curves (not construction, projected or text) that can make a profile fail. open: [{point, curve, at}] ends that touch no other profile curve (no profile runs through them); untied: [{point, curve, at, touches: {point}|{curve}}] ends that touch another curve only by position (the profile closes now, but nothing keeps it closed when a dimension changes)",
),
CommandSpec::new("sketch.loose_ends.tie", "Tie Loose Ends", tie_cmd).icon("c_coincident").enabled(in_sketch).params(
"sketch?: id|name (default active); points?: [point ids] (default: every untied end): ties each untied end to what it touches, coincident with another curve's end or on another curve. Returns constraints: [ids added], and the untied ends left",
),
];
fn point_id(sk: &Sketch, i: usize) -> Value {
sk.points.get(i).map_or(Value::Null, |p| json!(p.id))
}
fn curve_id(sk: &Sketch, i: usize) -> Value {
sk.curves.get(i).map_or(Value::Null, |c| json!(c.id))
}
fn at(sk: &Sketch, i: usize) -> Value {
sk.point(i).map_or(Value::Null, |v| json!([v.x, v.y]))
}
/// The point that is an end of curve `c` (for `curve` in the report).
fn end_curve(sk: &Sketch, p: usize) -> Value {
let c = sk.curves.iter().position(|c| c.kind.ends().is_some_and(|(a, b)| a == p || b == p) && !(c.construction || c.centerline || c.infinite));
c.map_or(Value::Null, |c| curve_id(sk, c))
}
fn report_json(sk: &Sketch, r: &EndReport) -> Value {
let open: Vec<Value> = r.open.iter().map(|&p| json!({"point": point_id(sk, p), "curve": end_curve(sk, p), "at": at(sk, p)})).collect();
let untied: Vec<Value> = r
.untied
.iter()
.map(|u| {
let touches = match u.touches {
EndTouch::Point(q) => json!({"point": point_id(sk, q)}),
EndTouch::Curve(c) => json!({"curve": curve_id(sk, c)}),
};
json!({"point": point_id(sk, u.point), "curve": curve_id(sk, u.curve), "at": at(sk, u.point), "touches": touches})
})
.collect();
json!({"open": open, "untied": untied})
}
fn loose_ends_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = target_sketch(s, p, "sketch.loose_ends")?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
Ok(report_json(&ss.sketch, &ss.ends))
}
fn tie_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.loose_ends.tie";
let only = match p.get("points") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.iter().all(Value::is_string) => Some(string_list(p, "points")),
Some(_) => return Err(bad(cmd, "`points` must be a list of point ids")),
};
let (added, info) = edit(s, p, cmd, true, |sk, _| {
let wanted = match &only {
None => None,
Some(ids) => Some(
ids.iter().map(|r| sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))).collect::<Result<Vec<usize>>>()?,
),
};
let report = loose_ends(sk);
let mut added = Vec::new();
for u in &report.untied {
if wanted.as_ref().is_some_and(|w| !w.contains(&u.point)) {
continue;
}
let kind = match u.touches {
EndTouch::Point(q) => ConstraintKind::Coincident { p: u.point, q },
EndTouch::Curve(c) => ConstraintKind::PointOnCurve { p: u.point, c },
};
added.push(add_c(sk, kind)?);
}
let left = report_json(sk, &loose_ends(sk)).get("untied").cloned().unwrap_or(Value::Null);
Ok((added, left))
})?;
let (added, left) = added;
Ok(json!({"constraints": added, "sketch": info, "untied": left}))
}
#[cfg(test)]
#[path = "sketch_ends_tests.rs"]
mod tests;
@@ -0,0 +1,94 @@
//! `sketch.loose_ends` and `sketch.loose_ends.tie`: ends that touch nothing or only touch by
//! position, and tying them so the profile stays closed when the sketch changes.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// A square of four separate lines: closed by position only.
fn loose_square(s: &mut Session) -> Vec<String> {
run(s, "sketch.create", json!({"plane": "XY"}));
let c = [[0, 0], [10, 0], [10, 10], [0, 10]];
(0..4)
.map(|i| {
let r = run(s, "sketch.line", json!({"points": [c[i], c[(i + 1) % 4]], "infer": false, "connect": false}));
r["curves"][0].as_str().unwrap().to_string()
})
.collect()
}
#[test]
fn a_square_closed_by_position_has_four_untied_corners() {
let mut s = Session::default();
loose_square(&mut s);
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], 1);
let r = run(&mut s, "sketch.loose_ends", json!({}));
assert_eq!(r["open"].as_array().unwrap().len(), 0, "{r}");
let untied = r["untied"].as_array().unwrap();
assert_eq!(untied.len(), 4, "{r}");
assert!(untied.iter().all(|u| u["touches"]["point"].is_string() && u["at"].is_array()), "{r}");
}
#[test]
fn tying_keeps_the_profile_closed_when_a_side_moves() {
let mut s = Session::default();
let lines = loose_square(&mut s);
let r = run(&mut s, "sketch.loose_ends.tie", json!({}));
assert_eq!(r["constraints"].as_array().unwrap().len(), 4, "{r}");
assert_eq!(r["untied"].as_array().unwrap().len(), 0, "{r}");
assert!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().is_empty());
// Lengthen the bottom side: the corners follow it and the square stays one profile.
run(&mut s, "sketch.dimension", json!({"entities": [lines[0]], "type": "length", "value": 14}));
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], 1);
// Tying is one undo step.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().len(), 4);
}
#[test]
fn tie_takes_only_the_points_asked_for() {
let mut s = Session::default();
loose_square(&mut s);
let r = run(&mut s, "sketch.loose_ends", json!({}));
let first = r["untied"][0]["point"].clone();
let t = run(&mut s, "sketch.loose_ends.tie", json!({"points": [first]}));
assert_eq!(t["constraints"].as_array().unwrap().len(), 1, "{t}");
assert_eq!(t["untied"].as_array().unwrap().len(), 3, "{t}");
}
#[test]
fn a_gap_shows_two_open_ends() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10], [0, 9.9]], "infer": false}));
let r = run(&mut s, "sketch.loose_ends", json!({}));
assert_eq!(r["open"].as_array().unwrap().len(), 2, "{r}");
assert!(r["untied"].as_array().unwrap().is_empty(), "{r}");
}
#[test]
fn hostile_tie_params_are_errors() {
let mut s = Session::default();
loose_square(&mut s);
for p in [json!({"points": 3}), json!({"points": ["nope"]}), json!({"points": [null, 1e308]}), json!({"sketch": "missing"})] {
assert!(s.execute("sketch.loose_ends.tie", &p).is_err(), "{p}");
}
assert!(s.execute("sketch.loose_ends", &json!({"sketch": "missing"})).is_err());
// Nothing changed.
assert_eq!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().len(), 4);
}
#[test]
fn outside_a_sketch_they_are_refused() {
let mut s = Session::default();
assert!(s.execute("sketch.loose_ends", &json!({})).is_err());
assert!(s.execute("sketch.loose_ends.tie", &json!({})).is_err());
}