From 2acc344aa47a441b95d992e8dc7c0f461b19b65c Mon Sep 17 00:00:00 2001 From: JIJINBEI <87472238+jijinbei@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:52:12 +0900 Subject: [PATCH] Sketch: show open and untied profile ends (#2000) (#2001) Thanks @jijinbei. --- assets/locales/en.json | 8 + assets/locales/ja.json | 8 + assets/locales/zh.json | 8 + crates/doc/src/config_certificates.rs | 10 +- crates/doc/src/eval.rs | 15 +- crates/doc/src/profile_refs.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch_ends.rs | 97 +++++++++ crates/engine/src/cmd/sketch_ends_tests.rs | 94 +++++++++ crates/sketch/src/ends.rs | 216 +++++++++++++++++++++ crates/sketch/src/ends_tests.rs | 126 ++++++++++++ crates/sketch/src/lib.rs | 4 + crates/ui-egui/src/sketch_palette.rs | 55 +++++- crates/ui-egui/src/viewport.rs | 23 +++ 14 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_ends.rs create mode 100644 crates/engine/src/cmd/sketch_ends_tests.rs create mode 100644 crates/sketch/src/ends.rs create mode 100644 crates/sketch/src/ends_tests.rs diff --git a/assets/locales/en.json b/assets/locales/en.json index d03d1318..623a0f9e 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -558,6 +558,8 @@ "cmd.sketch_isoparametric_curve": "Isoparametric Curve", "cmd.sketch_line3d": "3D Line", "cmd.sketch_line_midpoint": "Midpoint Line", + "cmd.sketch_loose_ends": "Loose Ends", + "cmd.sketch_loose_ends_tie": "Tie Loose Ends", "cmd.sketch_move": "Move/Copy Sketch Entities", "cmd.sketch_move3d": "Move 3D Point", "cmd.sketch_move_point": "Drag Sketch Point", @@ -797,6 +799,8 @@ "sketch.degrees_of_freedom": "{count} degrees of freedom", "sketch.draw_curves": "Draw as {linetype}", "sketch.make_selection": "Convert the selection to {linetype}", + "sketch.open_ends": "Open {count}", + "sketch.untied_ends": "Untied {count}", "sketch_text.align_center": "Center", "sketch_text.align_left": "Left", "sketch_text.align_right": "Right", @@ -1301,6 +1305,7 @@ "ui.one_side": "One Side", "ui.open": "Open", "ui.open_ellipsis": "Open...", + "ui.open_ends_tip": "Touches nothing", "ui.open_preferences": "Open Preferences", "ui.open_recent": "Open Recent", "ui.operation": "Operation", @@ -1443,6 +1448,8 @@ "ui.theme": "Theme", "ui.thickness": "Thickness", "ui.thread": "Thread", + "ui.tie_ends": "Tie", + "ui.tie_ends_tip": "Constrain all untied ends", "ui.timeline_bottom": "Bottom timeline", "ui.timeline_left_note": "The left layout shows the rollback bar above the parts and scene browser.", "ui.timeline_left_sidebar": "Left sidebar", @@ -1465,6 +1472,7 @@ "ui.units_of_new_designs": "Units of new designs", "ui.unlock": "Unlock", "ui.unsuppress": "Unsuppress", + "ui.untied_ends_tip": "Touches by position only", "ui.untitled": "Untitled", "ui.up_axis_note": "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.", "ui.updateToCurrentView": "Update to Current View", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 1799c46f..34c12b36 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -558,6 +558,8 @@ "cmd.sketch_isoparametric_curve": "アイソパラメトリック曲線", "cmd.sketch_line3d": "3D 線分", "cmd.sketch_line_midpoint": "中点線分", + "cmd.sketch_loose_ends": "端点の診断", + "cmd.sketch_loose_ends_tie": "未拘束の接点を拘束", "cmd.sketch_move": "スケッチ要素を移動/コピー", "cmd.sketch_move3d": "3D 点を移動", "cmd.sketch_move_point": "スケッチ点をドラッグ", @@ -797,6 +799,8 @@ "sketch.degrees_of_freedom": "自由度 {count}", "sketch.draw_curves": "{linetype}として描く", "sketch.make_selection": "選択を{linetype}にする", + "sketch.open_ends": "開放 {count}", + "sketch.untied_ends": "未拘束 {count}", "sketch_text.align_center": "中央", "sketch_text.align_left": "左", "sketch_text.align_right": "右", @@ -1301,6 +1305,7 @@ "ui.one_side": "片側", "ui.open": "開く", "ui.open_ellipsis": "開く…", + "ui.open_ends_tip": "どこにも接していない", "ui.open_preferences": "環境設定を開く", "ui.open_recent": "最近使った項目を開く", "ui.operation": "操作", @@ -1443,6 +1448,8 @@ "ui.theme": "テーマ", "ui.thickness": "厚さ", "ui.thread": "ねじ", + "ui.tie_ends": "拘束", + "ui.tie_ends_tip": "未拘束をすべて拘束", "ui.timeline_bottom": "下部タイムライン", "ui.timeline_left_note": "左レイアウトでは、ロールバックバーがパーツとシーンブラウザーの上に表示されます。", "ui.timeline_left_sidebar": "左サイドバー", @@ -1465,6 +1472,7 @@ "ui.units_of_new_designs": "新規デザインの単位", "ui.unlock": "ロック解除", "ui.unsuppress": "抑制を解除", + "ui.untied_ends_tip": "位置だけで接している", "ui.untitled": "無題", "ui.up_axis_note": "ビュー(軸ビュー、ホーム、正面視)で上向きにする軸を指定します。デザインデータ自体は変更されません。", "ui.updateToCurrentView": "現在のビューに更新", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 37937646..8028d7ef 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -558,6 +558,8 @@ "cmd.sketch_isoparametric_curve": "等参数曲线", "cmd.sketch_line3d": "三维直线", "cmd.sketch_line_midpoint": "中点线", + "cmd.sketch_loose_ends": "松散端点", + "cmd.sketch_loose_ends_tie": "约束松散端点", "cmd.sketch_move": "移动/复制草图图元", "cmd.sketch_move3d": "移动三维点", "cmd.sketch_move_point": "拖动草图点", @@ -797,6 +799,8 @@ "sketch.degrees_of_freedom": "{count} 个自由度", "sketch.draw_curves": "绘制为 {linetype}", "sketch.make_selection": "将选择转换为 {linetype}", + "sketch.open_ends": "开放 {count}", + "sketch.untied_ends": "未约束 {count}", "sketch_text.align_center": "居中", "sketch_text.align_left": "左对齐", "sketch_text.align_right": "右对齐", @@ -1301,6 +1305,7 @@ "ui.one_side": "单侧", "ui.open": "打开", "ui.open_ellipsis": "打开...", + "ui.open_ends_tip": "未接触任何曲线", "ui.open_preferences": "打开首选项", "ui.open_recent": "打开最近", "ui.operation": "操作", @@ -1443,6 +1448,8 @@ "ui.theme": "主题", "ui.thickness": "厚度", "ui.thread": "螺纹", + "ui.tie_ends": "约束", + "ui.tie_ends_tip": "约束全部未约束端点", "ui.timeline_bottom": "底部时间轴", "ui.timeline_left_note": "左侧布局下,回退条显示在零件和场景浏览器的上方。", "ui.timeline_left_sidebar": "左侧边栏", @@ -1465,6 +1472,7 @@ "ui.units_of_new_designs": "新设计的单位", "ui.unlock": "解锁", "ui.unsuppress": "取消抑制", + "ui.untied_ends_tip": "仅位置重合", "ui.untitled": "无标题", "ui.up_axis_note": "视图中朝上的轴(轴视图、主视图、注视)。设计本身不变。", "ui.updateToCurrentView": "更新为当前视图", diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs index a5902e13..de7c51df 100644 --- a/crates/doc/src/config_certificates.rs +++ b/crates/doc/src/config_certificates.rs @@ -864,7 +864,15 @@ mod tests { fn solved(mut sketch: solidcraft_sketch::Sketch) -> SolvedSketch { let report = solidcraft_sketch::solve(&mut sketch); - SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() } + SolvedSketch { + feature: 1, + name: "sources".into(), + plane: solidcraft_geom::Plane::XY, + sketch, + report, + profiles: Vec::new(), + ends: Default::default(), + } } #[test] diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 13101e0b..bae42e3b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -165,6 +165,8 @@ pub struct SolvedSketch { pub sketch: solidcraft_sketch::Sketch, pub report: SolveReport, pub profiles: Vec, + /// Open and untied ends of its profile curves (where a profile can fail). + pub ends: solidcraft_sketch::EndReport, } /// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis. @@ -1104,7 +1106,15 @@ impl Model { let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else { return false; }; - let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report }); + let solved = Arc::new(SolvedSketch { + feature, + name: last.name.clone(), + plane: last.plane, + profiles: find_profiles(&sketch), + ends: solidcraft_sketch::loose_ends(&sketch), + sketch, + report, + }); let shown = self.results.iter().rposition(|r| !r.skipped); for (k, r) in self.results.iter_mut().enumerate().skip(i) { r.stale = true; @@ -4735,8 +4745,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } sk.validate_periodic_splines()?; let profiles = find_profiles(&sk); + let ends = solidcraft_sketch::loose_ends(&sk); st.sketches.retain(|s| s.feature != f.id); - st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles })); + st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends })); Ok(()) } FeatureKind::Extrude { operation, targets, .. } diff --git a/crates/doc/src/profile_refs.rs b/crates/doc/src/profile_refs.rs index c1881d95..60ebe98a 100644 --- a/crates/doc/src/profile_refs.rs +++ b/crates/doc/src/profile_refs.rs @@ -370,7 +370,7 @@ mod tests { sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap(); let report = solve(&mut sketch); let profiles = find_profiles(&sketch); - let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles }; + let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() }; assert_eq!(ss.profiles.len(), 1); let indices = vec![0; 600]; let selection = ProfileSel::Indices { indices: indices.clone() }; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4d8626b5..3b40c6f5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/sketch_ends.rs b/crates/engine/src/cmd/sketch_ends.rs new file mode 100644 index 00000000..02f8f8bd --- /dev/null +++ b/crates/engine/src/cmd/sketch_ends.rs @@ -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 = r.open.iter().map(|&p| json!({"point": point_id(sk, p), "curve": end_curve(sk, p), "at": at(sk, p)})).collect(); + let untied: Vec = 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 { + 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 { + 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::>>()?, + ), + }; + 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; diff --git a/crates/engine/src/cmd/sketch_ends_tests.rs b/crates/engine/src/cmd/sketch_ends_tests.rs new file mode 100644 index 00000000..aaf58901 --- /dev/null +++ b/crates/engine/src/cmd/sketch_ends_tests.rs @@ -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 { + 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()); +} diff --git a/crates/sketch/src/ends.rs b/crates/sketch/src/ends.rs new file mode 100644 index 00000000..f1838b87 --- /dev/null +++ b/crates/sketch/src/ends.rs @@ -0,0 +1,216 @@ +//! Loose ends of a sketch's profile curves, for the sketch to show where a profile can fail. +//! +//! Profiles are read from the geometry: ends that lie together close a loop whether or not a +//! constraint ties them (see `profiles`). Two kinds of end are worth showing: +//! - an **open end** touches no other profile curve, so no profile runs through it; +//! - an **untied end** touches another profile curve only by position: the loop closes now, but +//! nothing keeps it closed when a dimension changes or a point is dragged. +//! +//! An end is tied when it is shared with another curve's end, joined to one by coincident +//! constraints, or held on a curve (point on curve, midpoint). + +use solidcraft_geom::{Seg2, Vec2}; + +use crate::model::{ConstraintKind, CurveKind, Sketch}; +use crate::union_find::{find, union}; + +/// How close an end has to be to another curve to touch it: the distance at which profile +/// finding joins and splits curves. +pub const END_TOL: f64 = 1e-5; + +/// Ends looked at, at most: a larger sketch reports nothing rather than taking long. +const MAX_ENDS: usize = 100_000; + +/// What an untied end touches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EndTouch { + /// Another curve's end (a point index). + Point(usize), + /// Another curve, between its ends (a curve index). + Curve(usize), +} + +/// An end that touches another curve without being tied to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UntiedEnd { + /// The end's point index. + pub point: usize, + /// The curve it is an end of. + pub curve: usize, + pub touches: EndTouch, +} + +/// The open and untied ends of a sketch's drawn profile curves (point indices). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EndReport { + pub open: Vec, + pub untied: Vec, +} + +impl EndReport { + pub fn is_empty(&self) -> bool { + self.open.is_empty() && self.untied.is_empty() + } +} + +/// Curves profiles are made of (the ones `find_profiles` reads). +fn profile_curve(sk: &Sketch, ci: usize) -> bool { + sk.curves.get(ci).is_some_and(|c| !(c.construction || c.centerline || c.infinite)) +} + +/// A box around a segment (conservative for curved ones). +fn seg_box(s: &Seg2) -> (Vec2, Vec2) { + let hull = |pts: &[Vec2]| { + pts.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| { + (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) + }) + }; + match *s { + Seg2::Line { a, b } => hull(&[a, b]), + Seg2::Arc { center, radius, .. } => { + let r = Vec2::new(radius.abs(), radius.abs()); + (center - r, center + r) + } + Seg2::Cubic { p0, p1, p2, p3 } => hull(&[p0, p1, p2, p3]), + Seg2::Conic { a, apex, b, .. } => hull(&[a, apex, b]), + Seg2::Bezier { n, p } => hull(p.get(..=usize::from(n)).unwrap_or(p.as_slice())), + } +} + +/// The open and untied ends of the drawn profile curves (lines, arcs and open free-form curves +/// that are not construction, projected or text). Positions are the sketch's current ones. +pub fn loose_ends(sk: &Sketch) -> EndReport { + let n = sk.points.len(); + // Coincident constraints join points into one place. + let mut group: Vec = (0..n).collect(); + for c in &sk.constraints { + if let ConstraintKind::Coincident { p, q } = c.kind { + union(&mut group, p, q); + } + } + // Per place: how many profile curve ends meet there, and whether a point there is held + // on a profile curve. + let mut ends_at = vec![0usize; n]; + let mut held = vec![false; n]; + let mut ends: Vec<(usize, usize)> = Vec::new(); + for (ci, c) in sk.curves.iter().enumerate() { + if !profile_curve(sk, ci) { + continue; + } + let Some((a, b)) = c.kind.ends() else { continue }; + for p in [a, b] { + let g = find(&mut group, p); + if let Some(k) = ends_at.get_mut(g) { + *k += 1; + } + } + // Lines shorter than profile finding keeps are left out of profiles too. + let degenerate = matches!(c.kind, CurveKind::Line { .. }) && sk.point(a).zip(sk.point(b)).is_some_and(|(pa, pb)| pa.dist(pb) <= END_TOL); + if c.link.is_none() && !degenerate { + ends.push((a, ci)); + if b != a { + ends.push((b, ci)); + } + } + } + for c in &sk.constraints { + let on = match c.kind { + ConstraintKind::PointOnCurve { p, c } | ConstraintKind::Midpoint { p, l: c } if profile_curve(sk, c) => Some(p), + _ => None, + }; + if let Some(p) = on { + let g = find(&mut group, p); + if let Some(h) = held.get_mut(g) { + *h = true; + } + } + } + if ends.len() > MAX_ENDS { + return EndReport::default(); + } + // Ends tied to nothing: the ones to look at by position. + let mut free: Vec<(usize, usize, Vec2)> = Vec::new(); + for &(p, ci) in &ends { + let g = find(&mut group, p); + if ends_at.get(g).copied().unwrap_or(0) >= 2 || held.get(g).copied().unwrap_or(false) { + continue; + } + if let Some(at) = sk.point(p).filter(|v| v.x.is_finite() && v.y.is_finite()) { + free.push((p, ci, at)); + } + } + if free.is_empty() { + return EndReport::default(); + } + free.sort_by(|a, b| a.2.x.total_cmp(&b.2.x)); + // What each free end touches: another curve's end first, else another curve's span. + let mut touch: Vec> = vec![None; free.len()]; + for (ci, c) in sk.curves.iter().enumerate() { + if !profile_curve(sk, ci) { + continue; + } + let curve_ends = c.kind.ends(); + for seg in sk.segs(ci) { + let (lo, hi) = seg_box(&seg); + let first = free.partition_point(|f| f.2.x < lo.x - END_TOL); + for (k, &(p, own, at)) in free.iter().enumerate().skip(first) { + if at.x > hi.x + END_TOL { + break; + } + if at.y < lo.y - END_TOL || at.y > hi.y + END_TOL || matches!(touch.get(k), Some(Some(EndTouch::Point(_)))) { + continue; + } + // Another end of a curve (its own other end too: a loop drawn as one curve). + let at_end = curve_ends.and_then(|(a, b)| [a, b].into_iter().find(|&q| q != p && sk.point(q).is_some_and(|v| v.dist(at) <= END_TOL))); + let found = match at_end { + Some(q) => Some(EndTouch::Point(q)), + None if ci != own && seg.point_at(seg.closest_param(at)).dist(at) <= END_TOL => Some(EndTouch::Curve(ci)), + None => None, + }; + if let (Some(found), Some(slot)) = (found, touch.get_mut(k)) + && (slot.is_none() || matches!(found, EndTouch::Point(_))) + { + *slot = Some(found); + } + } + } + } + // Free ends that touch each other are one place, however many meet there: each is tied to + // the place's first end, which is tied to what it touches outside the place (if anything). + let index: std::collections::HashMap = free.iter().enumerate().map(|(k, f)| (f.0, k)).collect(); + let mut place: Vec = (0..free.len()).collect(); + for (k, t) in touch.iter().enumerate() { + if let Some(EndTouch::Point(q)) = t + && let Some(&j) = index.get(q) + { + union(&mut place, k, j); + } + } + let mut first: std::collections::HashMap = std::collections::HashMap::new(); + for (k, f) in free.iter().enumerate() { + let root = find(&mut place, k); + let e = first.entry(root).or_insert(f.0); + *e = (*e).min(f.0); + } + let mut report = EndReport::default(); + for (k, &(p, curve, _)) in free.iter().enumerate() { + let Some(t) = touch.get(k).copied().flatten() else { + report.open.push(p); + continue; + }; + let root = find(&mut place, k); + let anchor = first.get(&root).copied().unwrap_or(p); + let touches = match t { + _ if anchor != p => EndTouch::Point(anchor), + // The place's first end: tied to what it touches outside the place. + EndTouch::Point(q) if index.contains_key(&q) => continue, + t => t, + }; + report.untied.push(UntiedEnd { point: p, curve, touches }); + } + report.open.sort_unstable(); + report.open.dedup(); + report.untied.sort_by_key(|u| u.point); + report.untied.dedup_by_key(|u| u.point); + report +} diff --git a/crates/sketch/src/ends_tests.rs b/crates/sketch/src/ends_tests.rs new file mode 100644 index 00000000..9cd7606e --- /dev/null +++ b/crates/sketch/src/ends_tests.rs @@ -0,0 +1,126 @@ +//! Open and untied ends of profile curves. + +use solidcraft_geom::Vec2; + +use crate::{ConstraintKind, EndTouch, Sketch, find_profiles, loose_ends}; + +fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +/// A closed polyline whose corners are each one shared point. +fn shared_square(sk: &mut Sketch) -> Vec { + let pts: Vec = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + (0..4).map(|i| sk.add_line_pts(pts[i], pts[(i + 1) % 4], None).unwrap()).collect() +} + +/// A square drawn as four separate lines: every corner is two points at one place. +fn loose_square(sk: &mut Sketch) -> Vec { + let c = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]; + (0..4).map(|i| sk.add_line(c[i], c[(i + 1) % 4], None, None, None).unwrap()).collect() +} + +#[test] +fn shared_corners_are_tied() { + let mut sk = Sketch::new(); + shared_square(&mut sk); + assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk)); +} + +#[test] +fn corners_together_by_position_only_are_untied() { + let mut sk = Sketch::new(); + loose_square(&mut sk); + // The square closes (profiles are read from positions)... + assert_eq!(find_profiles(&sk).len(), 1); + // ...but nothing keeps it closed: four corners, each reported once. + let r = loose_ends(&sk); + assert!(r.open.is_empty(), "{r:?}"); + assert_eq!(r.untied.len(), 4, "{r:?}"); + assert!(r.untied.iter().all(|u| matches!(u.touches, EndTouch::Point(_))), "{r:?}"); +} + +#[test] +fn coincident_constraints_tie_corners() { + let mut sk = Sketch::new(); + let lines = loose_square(&mut sk); + for i in 0..4 { + let end = sk.curves[lines[i]].kind.ends().unwrap().1; + let start = sk.curves[lines[(i + 1) % 4]].kind.ends().unwrap().0; + sk.add_constraint(ConstraintKind::Coincident { p: end, q: start }, None).unwrap(); + } + assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk)); +} + +#[test] +fn a_gap_is_two_open_ends() { + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let b = sk.add_point(v(10.0, 0.0), None).unwrap(); + let c = sk.add_point(v(10.0, 10.0), None).unwrap(); + sk.add_line_pts(a, b, None).unwrap(); + sk.add_line_pts(b, c, None).unwrap(); + // The closing line stops 0.01 mm short of the start. + let d = sk.add_point(v(0.0, 0.01), None).unwrap(); + sk.add_line_pts(c, d, None).unwrap(); + assert!(find_profiles(&sk).is_empty()); + let r = loose_ends(&sk); + assert_eq!(r.open, vec![a, d], "{r:?}"); + assert!(r.untied.is_empty(), "{r:?}"); +} + +#[test] +fn an_end_on_another_curve_is_untied_until_held() { + let mut sk = Sketch::new(); + shared_square(&mut sk); + // A divider from the bottom side's middle to the top side's middle, by position only. + let l = sk.add_line(v(5.0, 0.0), v(5.0, 10.0), None, None, None).unwrap(); + assert_eq!(find_profiles(&sk).len(), 2); + let r = loose_ends(&sk); + assert_eq!(r.untied.len(), 2, "{r:?}"); + assert!(r.untied.iter().all(|u| u.curve == l && matches!(u.touches, EndTouch::Curve(_))), "{r:?}"); + // Held on the sides, they are tied. + for u in r.untied { + if let EndTouch::Curve(c) = u.touches { + sk.add_constraint(ConstraintKind::PointOnCurve { p: u.point, c }, None).unwrap(); + } + } + assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk)); +} + +#[test] +fn construction_lines_and_circles_have_no_loose_ends() { + let mut sk = Sketch::new(); + let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap(); + sk.curves[l].construction = true; + sk.add_circle(v(20.0, 0.0), 3.0, None, None).unwrap(); + assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk)); +} + +#[test] +fn a_lone_line_has_two_open_ends() { + let mut sk = Sketch::new(); + let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap(); + let (a, b) = sk.curves[l].kind.ends().unwrap(); + assert_eq!(loose_ends(&sk).open, vec![a.min(b), a.max(b)]); +} + +#[test] +fn three_ends_meeting_by_position_are_one_place_tied_at_once() { + let mut sk = Sketch::new(); + for to in [v(0.0, 0.0), v(20.0, 0.0), v(10.0, 10.0)] { + sk.add_line(to, v(10.0, 0.0), None, None, None).unwrap(); + } + let r = loose_ends(&sk); + // Two of the three need tying, both to the third. + assert_eq!(r.untied.len(), 2, "{r:?}"); + let anchor = match r.untied[0].touches { + EndTouch::Point(q) => q, + EndTouch::Curve(_) => usize::MAX, + }; + assert!(r.untied.iter().all(|u| u.touches == EndTouch::Point(anchor) && u.point != anchor), "{r:?}"); + for u in &r.untied { + sk.add_constraint(ConstraintKind::Coincident { p: u.point, q: anchor }, None).unwrap(); + } + assert!(loose_ends(&sk).untied.is_empty(), "{:?}", loose_ends(&sk)); +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 75e519b1..44ed6ec2 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -20,6 +20,7 @@ mod construct; mod curves; mod decompose; mod dims; +mod ends; mod fonts; mod freeform_offset; pub mod linalg; @@ -44,6 +45,7 @@ pub use curves::{ }; pub use decompose::Budget; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text}; +pub use ends::{END_TOL, EndReport, EndTouch, UntiedEnd, loose_ends}; pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom}; @@ -74,6 +76,8 @@ mod construct_tests; #[cfg(test)] mod continuation_tests; #[cfg(test)] +mod ends_tests; +#[cfg(test)] mod font_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index cb897822..57fe403e 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -340,10 +340,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if !visible(app, ctx) { return; } - let (opts, dof_line, ok) = { + let (opts, dof_line, ok, open_ends, untied_ends) = { let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; - (ss.sketch.view.clone(), dof_text(ss.report.dof, ss.report.ok()), ss.report.ok() && ss.report.dof == 0) + ( + ss.sketch.view.clone(), + dof_text(ss.report.dof, ss.report.ok()), + ss.report.ok() && ss.report.dof == 0, + ss.ends.open.len(), + ss.ends.untied.len(), + ) }; let t = Tokens::get(); let vp = viewport(app, ctx); @@ -369,6 +375,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut linetype: Option = None; let mut look = false; let mut finish = false; + let mut tie = false; let mut start: Option<&'static str> = None; let feature = active_feature(app); let has_curves = !selected_curves(app).is_empty(); @@ -429,7 +436,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }); if !ofold { // Keep room for the footer: status heading and line, a line, Finish Sketch, a line. - let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0; + let ends_rows = if open_ends > 0 { line + gap } else { 0.0 } + if untied_ends > 0 { ui.spacing().interact_size.y + gap } else { 0.0 }; + let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0 + ends_rows; let options_height = (bottom - ui.cursor().top() - footer).max(24.0); let options = egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| { @@ -537,6 +545,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.label(RichText::new(app.tr("ui.sketch_status")).size(12.0)); }); ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim })); + // Where a profile can fail (the viewport rings and diamonds them). + if open_ends > 0 { + let text = crate::i18n::format("sketch.open_ends", &[("count", &open_ends.to_string())]); + ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.open_ends_tip")); + } + if untied_ends > 0 { + ui.horizontal(|ui| { + let text = crate::i18n::format("sketch.untied_ends", &[("count", &untied_ends.to_string())]); + ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.untied_ends_tip")); + let button = ui.button(app.tr("ui.tie_ends")).on_hover_text(app.tr("ui.tie_ends_tip")); + crate::scenario::publish_handle("sketch_palette:tie_ends", button.rect.center()); + if button.clicked() { + tie = true; + } + }); + } // One line between the status and Finish Sketch, and below it to the edge. ui.add_space((line - gap).max(0.0)); // A row as tall as the button, so the gap below it is just the line. @@ -577,6 +601,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if look { crate::dialogs::look_at_sketch_again(app); } + if tie && let Err(e) = app.run("sketch.loose_ends.tie", json!({})) { + app.set_status(e, true); + } if finish { app.finish_sketch(); } @@ -748,6 +775,28 @@ mod tests { assert!(yaw.abs() < 0.01, "must align with the sketch's own x axis: yaw={yaw}"); } + #[test] + fn status_counts_loose_ends_and_tie_closes_the_untied_ones() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + // A square of separate lines (closed by position only) and one lone line (two open ends). + let c = [[0, 0], [10, 0], [10, 10], [0, 10]]; + for i in 0..4 { + h.app.run("sketch.line", json!({"points": [c[i], c[(i + 1) % 4]], "infer": false, "connect": false})).unwrap(); + } + h.app.run("sketch.line", json!({"points": [[20, 0], [30, 5]], "infer": false, "connect": false})).unwrap(); + h.settle(); + assert!(h.widget("Open 2").is_some(), "the status counts the open ends"); + assert!(h.widget("Untied 4").is_some(), "the status counts the untied ends"); + let at = crate::scenario::handle_at("sketch_palette:tie_ends").unwrap(); + h.call("ui.click", json!({"x": at.x, "y": at.y})); + h.settle(); + assert!(h.widget("Untied 4").is_none(), "Tie constrains every untied end"); + assert!(h.widget("Open 2").is_some(), "open ends have nothing to tie to"); + let r = h.app.run("sketch.loose_ends", json!({})).unwrap(); + assert!(r["untied"].as_array().is_some_and(Vec::is_empty), "{r}"); + } + #[test] fn options_disclosure_triangle_folds_and_unfolds_the_section() { OPTIONS_FOLDED.with(|v| v.set(false)); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index b0ebc8b5..a3099b9c 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -3177,6 +3177,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c); } } + loose_end_markers(sk, &ss.ends, &ss.plane, painter, proj, &t); // Sketch status. if let Some(r) = app.viewport.rect { let msg = if !ss.report.ok() { @@ -3191,6 +3192,28 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } } +/// Where a profile can fail: a ring round each open end (it touches no other curve) and a +/// diamond round each untied one (it touches another curve only by position), told apart by +/// shape. Both are warnings, not errors: an open sketch (a sweep path, an axis) is valid. +fn loose_end_markers( + sk: &solidcraft_engine::sketch::Sketch, + ends: &solidcraft_engine::sketch::EndReport, + plane: &solidcraft_engine::geom::Plane, + painter: &egui::Painter, + proj: &Proj, + t: &Tokens, +) { + let screen = |i: usize| sk.point(i).and_then(|p| proj.to_screen(plane.to_world(p))); + for sp in ends.open.iter().filter_map(|&i| screen(i)) { + painter.circle_stroke(sp, 7.0, Stroke::new(1.8, t.warning)); + } + for sp in ends.untied.iter().filter_map(|u| screen(u.point)) { + let r = 7.5; + let diamond = vec![pos2(sp.x, sp.y - r), pos2(sp.x + r, sp.y), pos2(sp.x, sp.y + r), pos2(sp.x - r, sp.y)]; + painter.add(egui::Shape::closed_line(diamond, Stroke::new(1.8, t.warning))); + } +} + /// The colours of a headless picture of the model (`ui.render`, the agent's view image) in the /// dark or the light theme and a colour vision mode, from the tokens the window draws with, so the /// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light