Thanks @jijinbei.
This commit is contained in:
1 parent
487aa64e10
commit
2acc344aa4
14 files changed
+661
-7
No files matched your search
@@ -558,6 +558,8 @@
|
|||||||
"cmd.sketch_isoparametric_curve": "Isoparametric Curve",
|
"cmd.sketch_isoparametric_curve": "Isoparametric Curve",
|
||||||
"cmd.sketch_line3d": "3D Line",
|
"cmd.sketch_line3d": "3D Line",
|
||||||
"cmd.sketch_line_midpoint": "Midpoint 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_move": "Move/Copy Sketch Entities",
|
||||||
"cmd.sketch_move3d": "Move 3D Point",
|
"cmd.sketch_move3d": "Move 3D Point",
|
||||||
"cmd.sketch_move_point": "Drag Sketch Point",
|
"cmd.sketch_move_point": "Drag Sketch Point",
|
||||||
@@ -797,6 +799,8 @@
|
|||||||
"sketch.degrees_of_freedom": "{count} degrees of freedom",
|
"sketch.degrees_of_freedom": "{count} degrees of freedom",
|
||||||
"sketch.draw_curves": "Draw as {linetype}",
|
"sketch.draw_curves": "Draw as {linetype}",
|
||||||
"sketch.make_selection": "Convert the selection to {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_center": "Center",
|
||||||
"sketch_text.align_left": "Left",
|
"sketch_text.align_left": "Left",
|
||||||
"sketch_text.align_right": "Right",
|
"sketch_text.align_right": "Right",
|
||||||
@@ -1301,6 +1305,7 @@
|
|||||||
"ui.one_side": "One Side",
|
"ui.one_side": "One Side",
|
||||||
"ui.open": "Open",
|
"ui.open": "Open",
|
||||||
"ui.open_ellipsis": "Open...",
|
"ui.open_ellipsis": "Open...",
|
||||||
|
"ui.open_ends_tip": "Touches nothing",
|
||||||
"ui.open_preferences": "Open Preferences",
|
"ui.open_preferences": "Open Preferences",
|
||||||
"ui.open_recent": "Open Recent",
|
"ui.open_recent": "Open Recent",
|
||||||
"ui.operation": "Operation",
|
"ui.operation": "Operation",
|
||||||
@@ -1443,6 +1448,8 @@
|
|||||||
"ui.theme": "Theme",
|
"ui.theme": "Theme",
|
||||||
"ui.thickness": "Thickness",
|
"ui.thickness": "Thickness",
|
||||||
"ui.thread": "Thread",
|
"ui.thread": "Thread",
|
||||||
|
"ui.tie_ends": "Tie",
|
||||||
|
"ui.tie_ends_tip": "Constrain all untied ends",
|
||||||
"ui.timeline_bottom": "Bottom timeline",
|
"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_note": "The left layout shows the rollback bar above the parts and scene browser.",
|
||||||
"ui.timeline_left_sidebar": "Left sidebar",
|
"ui.timeline_left_sidebar": "Left sidebar",
|
||||||
@@ -1465,6 +1472,7 @@
|
|||||||
"ui.units_of_new_designs": "Units of new designs",
|
"ui.units_of_new_designs": "Units of new designs",
|
||||||
"ui.unlock": "Unlock",
|
"ui.unlock": "Unlock",
|
||||||
"ui.unsuppress": "Unsuppress",
|
"ui.unsuppress": "Unsuppress",
|
||||||
|
"ui.untied_ends_tip": "Touches by position only",
|
||||||
"ui.untitled": "Untitled",
|
"ui.untitled": "Untitled",
|
||||||
"ui.up_axis_note": "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.",
|
"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",
|
"ui.updateToCurrentView": "Update to Current View",
|
||||||
|
|||||||
@@ -558,6 +558,8 @@
|
|||||||
"cmd.sketch_isoparametric_curve": "アイソパラメトリック曲線",
|
"cmd.sketch_isoparametric_curve": "アイソパラメトリック曲線",
|
||||||
"cmd.sketch_line3d": "3D 線分",
|
"cmd.sketch_line3d": "3D 線分",
|
||||||
"cmd.sketch_line_midpoint": "中点線分",
|
"cmd.sketch_line_midpoint": "中点線分",
|
||||||
|
"cmd.sketch_loose_ends": "端点の診断",
|
||||||
|
"cmd.sketch_loose_ends_tie": "未拘束の接点を拘束",
|
||||||
"cmd.sketch_move": "スケッチ要素を移動/コピー",
|
"cmd.sketch_move": "スケッチ要素を移動/コピー",
|
||||||
"cmd.sketch_move3d": "3D 点を移動",
|
"cmd.sketch_move3d": "3D 点を移動",
|
||||||
"cmd.sketch_move_point": "スケッチ点をドラッグ",
|
"cmd.sketch_move_point": "スケッチ点をドラッグ",
|
||||||
@@ -797,6 +799,8 @@
|
|||||||
"sketch.degrees_of_freedom": "自由度 {count}",
|
"sketch.degrees_of_freedom": "自由度 {count}",
|
||||||
"sketch.draw_curves": "{linetype}として描く",
|
"sketch.draw_curves": "{linetype}として描く",
|
||||||
"sketch.make_selection": "選択を{linetype}にする",
|
"sketch.make_selection": "選択を{linetype}にする",
|
||||||
|
"sketch.open_ends": "開放 {count}",
|
||||||
|
"sketch.untied_ends": "未拘束 {count}",
|
||||||
"sketch_text.align_center": "中央",
|
"sketch_text.align_center": "中央",
|
||||||
"sketch_text.align_left": "左",
|
"sketch_text.align_left": "左",
|
||||||
"sketch_text.align_right": "右",
|
"sketch_text.align_right": "右",
|
||||||
@@ -1301,6 +1305,7 @@
|
|||||||
"ui.one_side": "片側",
|
"ui.one_side": "片側",
|
||||||
"ui.open": "開く",
|
"ui.open": "開く",
|
||||||
"ui.open_ellipsis": "開く…",
|
"ui.open_ellipsis": "開く…",
|
||||||
|
"ui.open_ends_tip": "どこにも接していない",
|
||||||
"ui.open_preferences": "環境設定を開く",
|
"ui.open_preferences": "環境設定を開く",
|
||||||
"ui.open_recent": "最近使った項目を開く",
|
"ui.open_recent": "最近使った項目を開く",
|
||||||
"ui.operation": "操作",
|
"ui.operation": "操作",
|
||||||
@@ -1443,6 +1448,8 @@
|
|||||||
"ui.theme": "テーマ",
|
"ui.theme": "テーマ",
|
||||||
"ui.thickness": "厚さ",
|
"ui.thickness": "厚さ",
|
||||||
"ui.thread": "ねじ",
|
"ui.thread": "ねじ",
|
||||||
|
"ui.tie_ends": "拘束",
|
||||||
|
"ui.tie_ends_tip": "未拘束をすべて拘束",
|
||||||
"ui.timeline_bottom": "下部タイムライン",
|
"ui.timeline_bottom": "下部タイムライン",
|
||||||
"ui.timeline_left_note": "左レイアウトでは、ロールバックバーがパーツとシーンブラウザーの上に表示されます。",
|
"ui.timeline_left_note": "左レイアウトでは、ロールバックバーがパーツとシーンブラウザーの上に表示されます。",
|
||||||
"ui.timeline_left_sidebar": "左サイドバー",
|
"ui.timeline_left_sidebar": "左サイドバー",
|
||||||
@@ -1465,6 +1472,7 @@
|
|||||||
"ui.units_of_new_designs": "新規デザインの単位",
|
"ui.units_of_new_designs": "新規デザインの単位",
|
||||||
"ui.unlock": "ロック解除",
|
"ui.unlock": "ロック解除",
|
||||||
"ui.unsuppress": "抑制を解除",
|
"ui.unsuppress": "抑制を解除",
|
||||||
|
"ui.untied_ends_tip": "位置だけで接している",
|
||||||
"ui.untitled": "無題",
|
"ui.untitled": "無題",
|
||||||
"ui.up_axis_note": "ビュー(軸ビュー、ホーム、正面視)で上向きにする軸を指定します。デザインデータ自体は変更されません。",
|
"ui.up_axis_note": "ビュー(軸ビュー、ホーム、正面視)で上向きにする軸を指定します。デザインデータ自体は変更されません。",
|
||||||
"ui.updateToCurrentView": "現在のビューに更新",
|
"ui.updateToCurrentView": "現在のビューに更新",
|
||||||
|
|||||||
@@ -558,6 +558,8 @@
|
|||||||
"cmd.sketch_isoparametric_curve": "等参数曲线",
|
"cmd.sketch_isoparametric_curve": "等参数曲线",
|
||||||
"cmd.sketch_line3d": "三维直线",
|
"cmd.sketch_line3d": "三维直线",
|
||||||
"cmd.sketch_line_midpoint": "中点线",
|
"cmd.sketch_line_midpoint": "中点线",
|
||||||
|
"cmd.sketch_loose_ends": "松散端点",
|
||||||
|
"cmd.sketch_loose_ends_tie": "约束松散端点",
|
||||||
"cmd.sketch_move": "移动/复制草图图元",
|
"cmd.sketch_move": "移动/复制草图图元",
|
||||||
"cmd.sketch_move3d": "移动三维点",
|
"cmd.sketch_move3d": "移动三维点",
|
||||||
"cmd.sketch_move_point": "拖动草图点",
|
"cmd.sketch_move_point": "拖动草图点",
|
||||||
@@ -797,6 +799,8 @@
|
|||||||
"sketch.degrees_of_freedom": "{count} 个自由度",
|
"sketch.degrees_of_freedom": "{count} 个自由度",
|
||||||
"sketch.draw_curves": "绘制为 {linetype}",
|
"sketch.draw_curves": "绘制为 {linetype}",
|
||||||
"sketch.make_selection": "将选择转换为 {linetype}",
|
"sketch.make_selection": "将选择转换为 {linetype}",
|
||||||
|
"sketch.open_ends": "开放 {count}",
|
||||||
|
"sketch.untied_ends": "未约束 {count}",
|
||||||
"sketch_text.align_center": "居中",
|
"sketch_text.align_center": "居中",
|
||||||
"sketch_text.align_left": "左对齐",
|
"sketch_text.align_left": "左对齐",
|
||||||
"sketch_text.align_right": "右对齐",
|
"sketch_text.align_right": "右对齐",
|
||||||
@@ -1301,6 +1305,7 @@
|
|||||||
"ui.one_side": "单侧",
|
"ui.one_side": "单侧",
|
||||||
"ui.open": "打开",
|
"ui.open": "打开",
|
||||||
"ui.open_ellipsis": "打开...",
|
"ui.open_ellipsis": "打开...",
|
||||||
|
"ui.open_ends_tip": "未接触任何曲线",
|
||||||
"ui.open_preferences": "打开首选项",
|
"ui.open_preferences": "打开首选项",
|
||||||
"ui.open_recent": "打开最近",
|
"ui.open_recent": "打开最近",
|
||||||
"ui.operation": "操作",
|
"ui.operation": "操作",
|
||||||
@@ -1443,6 +1448,8 @@
|
|||||||
"ui.theme": "主题",
|
"ui.theme": "主题",
|
||||||
"ui.thickness": "厚度",
|
"ui.thickness": "厚度",
|
||||||
"ui.thread": "螺纹",
|
"ui.thread": "螺纹",
|
||||||
|
"ui.tie_ends": "约束",
|
||||||
|
"ui.tie_ends_tip": "约束全部未约束端点",
|
||||||
"ui.timeline_bottom": "底部时间轴",
|
"ui.timeline_bottom": "底部时间轴",
|
||||||
"ui.timeline_left_note": "左侧布局下,回退条显示在零件和场景浏览器的上方。",
|
"ui.timeline_left_note": "左侧布局下,回退条显示在零件和场景浏览器的上方。",
|
||||||
"ui.timeline_left_sidebar": "左侧边栏",
|
"ui.timeline_left_sidebar": "左侧边栏",
|
||||||
@@ -1465,6 +1472,7 @@
|
|||||||
"ui.units_of_new_designs": "新设计的单位",
|
"ui.units_of_new_designs": "新设计的单位",
|
||||||
"ui.unlock": "解锁",
|
"ui.unlock": "解锁",
|
||||||
"ui.unsuppress": "取消抑制",
|
"ui.unsuppress": "取消抑制",
|
||||||
|
"ui.untied_ends_tip": "仅位置重合",
|
||||||
"ui.untitled": "无标题",
|
"ui.untitled": "无标题",
|
||||||
"ui.up_axis_note": "视图中朝上的轴(轴视图、主视图、注视)。设计本身不变。",
|
"ui.up_axis_note": "视图中朝上的轴(轴视图、主视图、注视)。设计本身不变。",
|
||||||
"ui.updateToCurrentView": "更新为当前视图",
|
"ui.updateToCurrentView": "更新为当前视图",
|
||||||
|
|||||||
@@ -864,7 +864,15 @@ mod tests {
|
|||||||
|
|
||||||
fn solved(mut sketch: solidcraft_sketch::Sketch) -> SolvedSketch {
|
fn solved(mut sketch: solidcraft_sketch::Sketch) -> SolvedSketch {
|
||||||
let report = solidcraft_sketch::solve(&mut sketch);
|
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]
|
#[test]
|
||||||
|
|||||||
+13
-2
@@ -165,6 +165,8 @@ pub struct SolvedSketch {
|
|||||||
pub sketch: solidcraft_sketch::Sketch,
|
pub sketch: solidcraft_sketch::Sketch,
|
||||||
pub report: SolveReport,
|
pub report: SolveReport,
|
||||||
pub profiles: Vec<Profile>,
|
pub profiles: Vec<Profile>,
|
||||||
|
/// 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.
|
/// 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 {
|
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;
|
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);
|
let shown = self.results.iter().rposition(|r| !r.skipped);
|
||||||
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
|
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
|
||||||
r.stale = true;
|
r.stale = true;
|
||||||
@@ -4735,8 +4745,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
|||||||
}
|
}
|
||||||
sk.validate_periodic_splines()?;
|
sk.validate_periodic_splines()?;
|
||||||
let profiles = find_profiles(&sk);
|
let profiles = find_profiles(&sk);
|
||||||
|
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||||
st.sketches.retain(|s| s.feature != f.id);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
FeatureKind::Extrude { operation, targets, .. }
|
FeatureKind::Extrude { operation, targets, .. }
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ mod tests {
|
|||||||
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
||||||
let report = solve(&mut sketch);
|
let report = solve(&mut sketch);
|
||||||
let profiles = find_profiles(&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);
|
assert_eq!(ss.profiles.len(), 1);
|
||||||
let indices = vec![0; 600];
|
let indices = vec![0; 600];
|
||||||
let selection = ProfileSel::Indices { indices: indices.clone() };
|
let selection = ProfileSel::Indices { indices: indices.clone() };
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ mod sketch_canvas;
|
|||||||
pub mod sketch_clipboard;
|
pub mod sketch_clipboard;
|
||||||
mod sketch_constraints;
|
mod sketch_constraints;
|
||||||
mod sketch_create;
|
mod sketch_create;
|
||||||
|
mod sketch_ends;
|
||||||
mod sketch_freeform;
|
mod sketch_freeform;
|
||||||
mod sketch_import;
|
mod sketch_import;
|
||||||
mod sketch_inspect;
|
mod sketch_inspect;
|
||||||
@@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
|||||||
v.extend(sketch_constraints::COMMANDS.iter());
|
v.extend(sketch_constraints::COMMANDS.iter());
|
||||||
v.extend(sketch_import::COMMANDS.iter());
|
v.extend(sketch_import::COMMANDS.iter());
|
||||||
v.extend(sketch_inspect::COMMANDS.iter());
|
v.extend(sketch_inspect::COMMANDS.iter());
|
||||||
|
v.extend(sketch_ends::COMMANDS.iter());
|
||||||
v.extend(sketch_canvas::COMMANDS.iter());
|
v.extend(sketch_canvas::COMMANDS.iter());
|
||||||
v.extend(sketch_3d::COMMANDS.iter());
|
v.extend(sketch_3d::COMMANDS.iter());
|
||||||
v.extend(surface_analysis::COMMANDS.iter());
|
v.extend(surface_analysis::COMMANDS.iter());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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<usize>,
|
||||||
|
pub untied: Vec<UntiedEnd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> = (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<Option<EndTouch>> = 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<usize, usize> = free.iter().enumerate().map(|(k, f)| (f.0, k)).collect();
|
||||||
|
let mut place: Vec<usize> = (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<usize, usize> = 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
|
||||||
|
}
|
||||||
@@ -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<usize> {
|
||||||
|
let pts: Vec<usize> = [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<usize> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ mod construct;
|
|||||||
mod curves;
|
mod curves;
|
||||||
mod decompose;
|
mod decompose;
|
||||||
mod dims;
|
mod dims;
|
||||||
|
mod ends;
|
||||||
mod fonts;
|
mod fonts;
|
||||||
mod freeform_offset;
|
mod freeform_offset;
|
||||||
pub mod linalg;
|
pub mod linalg;
|
||||||
@@ -44,6 +45,7 @@ pub use curves::{
|
|||||||
};
|
};
|
||||||
pub use decompose::Budget;
|
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 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 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 freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom};
|
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)]
|
#[cfg(test)]
|
||||||
mod continuation_tests;
|
mod continuation_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod ends_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod font_tests;
|
mod font_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -340,10 +340,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
if !visible(app, ctx) {
|
if !visible(app, ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (opts, dof_line, ok) = {
|
let (opts, dof_line, ok, open_ends, untied_ends) = {
|
||||||
let st = app.session.world_state();
|
let st = app.session.world_state();
|
||||||
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
|
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 t = Tokens::get();
|
||||||
let vp = viewport(app, ctx);
|
let vp = viewport(app, ctx);
|
||||||
@@ -369,6 +375,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
let mut linetype: Option<u8> = None;
|
let mut linetype: Option<u8> = None;
|
||||||
let mut look = false;
|
let mut look = false;
|
||||||
let mut finish = false;
|
let mut finish = false;
|
||||||
|
let mut tie = false;
|
||||||
let mut start: Option<&'static str> = None;
|
let mut start: Option<&'static str> = None;
|
||||||
let feature = active_feature(app);
|
let feature = active_feature(app);
|
||||||
let has_curves = !selected_curves(app).is_empty();
|
let has_curves = !selected_curves(app).is_empty();
|
||||||
@@ -429,7 +436,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
if !ofold {
|
if !ofold {
|
||||||
// Keep room for the footer: status heading and line, a line, Finish Sketch, a line.
|
// 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_height = (bottom - ui.cursor().top() - footer).max(24.0);
|
||||||
let options =
|
let options =
|
||||||
egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| {
|
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(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 }));
|
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.
|
// One line between the status and Finish Sketch, and below it to the edge.
|
||||||
ui.add_space((line - gap).max(0.0));
|
ui.add_space((line - gap).max(0.0));
|
||||||
// A row as tall as the button, so the gap below it is just the line.
|
// 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 {
|
if look {
|
||||||
crate::dialogs::look_at_sketch_again(app);
|
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 {
|
if finish {
|
||||||
app.finish_sketch();
|
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}");
|
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]
|
#[test]
|
||||||
fn options_disclosure_triangle_folds_and_unfolds_the_section() {
|
fn options_disclosure_triangle_folds_and_unfolds_the_section() {
|
||||||
OPTIONS_FOLDED.with(|v| v.set(false));
|
OPTIONS_FOLDED.with(|v| v.set(false));
|
||||||
|
|||||||
@@ -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);
|
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.
|
// Sketch status.
|
||||||
if let Some(r) = app.viewport.rect {
|
if let Some(r) = app.viewport.rect {
|
||||||
let msg = if !ss.report.ok() {
|
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
|
/// 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
|
/// 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
|
/// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
|
||||||
|
|||||||
Reference in new issue
Block a user