From 987f0be21ebcf9fee070dd919a30d75c50fe3610 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:54:16 -0400 Subject: [PATCH] Sketch: infinite construction lines (#1359) (#1547) Thanks @DuBearte. --- assets/icons/ui/source/icons.rs | 19 ++- assets/locales/de.json | 2 + assets/locales/en.json | 2 + assets/locales/fr.json | 2 + assets/locales/ja.json | 2 + assets/locales/ko.json | 2 + assets/locales/ru.json | 2 + assets/locales/uk.json | 2 + assets/locales/zh.json | 2 + .../format/pending/infinite-sketch-lines.rs | 5 + crates/engine/src/cmd/features.rs | 3 +- crates/engine/src/cmd/sketch.rs | 8 +- crates/engine/src/cmd/sketch_clipboard.rs | 4 +- crates/engine/src/cmd/sketch_create.rs | 28 ++++ .../engine/src/cmd/sketch_infinite_tests.rs | 137 ++++++++++++++++++ crates/engine/src/cmd/sketch_modify.rs | 42 +++++- crates/engine/src/cmd/sketch_support.rs | 14 ++ crates/sketch/src/link.rs | 1 + crates/sketch/src/model.rs | 10 +- crates/sketch/src/offset_topology.rs | 12 +- crates/sketch/src/profiles.rs | 2 +- crates/ui-egui/src/context_menu.rs | 14 ++ .../src/infinite_line_display_tests.rs | 61 ++++++++ crates/ui-egui/src/sketch_display.rs | 35 ++++- crates/ui-egui/src/sketch_palette.rs | 45 ++++-- crates/ui-egui/src/tools.rs | 4 +- crates/ui-egui/src/viewport.rs | 3 +- 27 files changed, 435 insertions(+), 28 deletions(-) create mode 100644 crates/doc/src/format/pending/infinite-sketch-lines.rs create mode 100644 crates/engine/src/cmd/sketch_infinite_tests.rs create mode 100644 crates/ui-egui/src/infinite_line_display_tests.rs diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 3a354f68..8f4fb5de 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -490,6 +490,23 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C } p.circle_filled(at(0.5), dot_r, a); } + "infinite" => { + // An infinite line (#1359): Construction's broken line, with an open arrowhead at + // each end saying it runs on. Accent only, like Construction and Centerline. + let w = line_type_stroke(r); + let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); + for (t0, t1) in [(0.0, 0.38), (0.62, 1.0)] { + p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); + } + // Each head: two short strokes back from the tip, either side of the line. + let tip_len = 0.3; + for (tip, back) in [(0.0f32, tip_len), (1.0, 1.0 - tip_len)] { + let (t, b) = (at(tip), at(back)); + let along = b - t; + let side = egui::vec2(-along.y, along.x) * 0.55; + p.add(Shape::line(vec![b + side, t, b - side], Stroke::new(w, a))); + } + } "c_hv" => { pen.line(&[(4.0, 18.0), (20.0, 18.0)], a); pen.line(&[(6.0, 4.0), (6.0, 14.0)], a); @@ -1482,7 +1499,7 @@ mod tests { /// theme's construction orange) and must not change with `ink`. #[test] fn construction_and_centerline_icons_follow_the_callers_accent_only() { - for name in ["construction", "centerline"] { + for name in ["construction", "centerline", "infinite"] { let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3)); let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6)); assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour"); diff --git a/assets/locales/de.json b/assets/locales/de.json index 5ee1040f..bf66c50d 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -305,6 +305,7 @@ "cmd.sketch_fit_curves_to_section": "Kurven an Netzquerschnitt anpassen", "cmd.sketch_glyphs": "Bedingungssymbole", "cmd.sketch_include_3d": "3D-Geometrie einbeziehen", + "cmd.sketch_infinite": "Unendliche Linie", "cmd.sketch_insert_dxf": "DXF einfügen", "cmd.sketch_insert_svg": "SVG einfügen", "cmd.sketch_inspect": "Skizze prüfen", @@ -907,6 +908,7 @@ "ui.home": "Start", "ui.homeView": "Startansicht", "ui.horizontal_vertical": "Horizontal/Vertikal", + "ui.infinite": "Unendlich", "ui.insert": "Einfügen", "ui.inspect": "Prüfen", "ui.intersect": "Schnitt", diff --git a/assets/locales/en.json b/assets/locales/en.json index 768d01a1..c4f790a1 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -507,6 +507,7 @@ "cmd.sketch_fit_curves_to_section": "Fit Curves to Mesh Section", "cmd.sketch_glyphs": "Constraint Glyphs", "cmd.sketch_include_3d": "Include 3D Geometry", + "cmd.sketch_infinite": "Infinite Line", "cmd.sketch_insert_dxf": "Insert DXF", "cmd.sketch_insert_svg": "Insert SVG", "cmd.sketch_inspect": "Inspect Sketch", @@ -1139,6 +1140,7 @@ "ui.homeView": "Home View", "ui.horizontal_vertical": "Horizontal/Vertical", "ui.import_ellipsis": "Import…", + "ui.infinite": "Infinite", "ui.insert": "Insert", "ui.inspect": "Inspect", "ui.intersect": "Intersect", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index f4884104..df64badb 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -487,6 +487,7 @@ "cmd.sketch_fit_curves_to_section": "Ajuster des courbes à la section du maillage", "cmd.sketch_glyphs": "Symboles de contraintes", "cmd.sketch_include_3d": "Inclure une géométrie 3D", + "cmd.sketch_infinite": "Ligne infinie", "cmd.sketch_insert_dxf": "Insérer un DXF", "cmd.sketch_insert_svg": "Insérer un SVG", "cmd.sketch_inspect": "Inspecter l'esquisse", @@ -979,6 +980,7 @@ "ui.homeView": "Vue d'accueil", "ui.horizontal_vertical": "Horizontale/Verticale", "ui.import_ellipsis": "Importer…", + "ui.infinite": "Infinie", "ui.insert": "Insérer", "ui.inspect": "Inspecter", "ui.intersect": "Intersection", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 8afc3832..b1ef7be0 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -507,6 +507,7 @@ "cmd.sketch_fit_curves_to_section": "メッシュ断面に曲線をフィット", "cmd.sketch_glyphs": "拘束記号", "cmd.sketch_include_3d": "3D ジオメトリを含める", + "cmd.sketch_infinite": "無限線", "cmd.sketch_insert_dxf": "DXF を挿入", "cmd.sketch_insert_svg": "SVG を挿入", "cmd.sketch_inspect": "スケッチを検査", @@ -1139,6 +1140,7 @@ "ui.homeView": "ホームビュー", "ui.horizontal_vertical": "水平/垂直", "ui.import_ellipsis": "インポート…", + "ui.infinite": "無限", "ui.insert": "挿入", "ui.inspect": "検査", "ui.intersect": "交差", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index dc99a66a..611179b5 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -305,6 +305,7 @@ "cmd.sketch_fit_curves_to_section": "메시 단면에 곡선 맞추기", "cmd.sketch_glyphs": "구속 기호", "cmd.sketch_include_3d": "3D 기하 포함", + "cmd.sketch_infinite": "무한선", "cmd.sketch_insert_dxf": "DXF 삽입", "cmd.sketch_insert_svg": "SVG 삽입", "cmd.sketch_inspect": "스케치 검사", @@ -907,6 +908,7 @@ "ui.home": "홈", "ui.homeView": "홈 보기", "ui.horizontal_vertical": "수평/수직", + "ui.infinite": "무한", "ui.insert": "삽입", "ui.inspect": "검사", "ui.intersect": "교차", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 0022d10a..e7d0e2b4 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -458,6 +458,7 @@ "cmd.sketch_fit_curves_to_section": "Подобрать кривые по сечению меша", "cmd.sketch_glyphs": "Знаки ограничений", "cmd.sketch_include_3d": "Включить 3D-геометрию", + "cmd.sketch_infinite": "Бесконечная линия", "cmd.sketch_insert_dxf": "Вставить DXF", "cmd.sketch_insert_svg": "Вставить SVG", "cmd.sketch_inspect": "Инспекция эскиза", @@ -931,6 +932,7 @@ "ui.homeView": "Главный вид", "ui.horizontal_vertical": "Горизонталь/Вертикаль", "ui.import_ellipsis": "Импорт…", + "ui.infinite": "Бесконечная", "ui.insert": "Вставить", "ui.inspect": "Инспекция", "ui.intersect": "Пересечение", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index cde0098b..15b0e1b4 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -455,6 +455,7 @@ "cmd.sketch_fit_curves_to_section": "Підігнати криві до перерізу сітки", "cmd.sketch_glyphs": "Позначки обмежень", "cmd.sketch_include_3d": "Включити 3D-геометрію", + "cmd.sketch_infinite": "Нескінченна лінія", "cmd.sketch_insert_dxf": "Вставити DXF", "cmd.sketch_insert_svg": "Вставити SVG", "cmd.sketch_inspect": "Перевірити ескіз", @@ -830,6 +831,7 @@ "ui.homeView": "Головний вид", "ui.horizontal_vertical": "Горизонталь/вертикаль", "ui.import_ellipsis": "Імпорт…", + "ui.infinite": "Нескінченна", "ui.insert": "Вставити", "ui.inspect": "Перевірити", "ui.intersect": "Перетин", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index e32c8632..ed6d0c8d 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -507,6 +507,7 @@ "cmd.sketch_fit_curves_to_section": "将曲线拟合到网格截面", "cmd.sketch_glyphs": "约束符号", "cmd.sketch_include_3d": "包含三维几何", + "cmd.sketch_infinite": "无限长直线", "cmd.sketch_insert_dxf": "插入 DXF", "cmd.sketch_insert_svg": "插入 SVG", "cmd.sketch_inspect": "检查草图", @@ -1139,6 +1140,7 @@ "ui.homeView": "主视图", "ui.horizontal_vertical": "水平/垂直", "ui.import_ellipsis": "导入…", + "ui.infinite": "无限", "ui.insert": "插入", "ui.inspect": "检查", "ui.intersect": "相交", diff --git a/crates/doc/src/format/pending/infinite-sketch-lines.rs b/crates/doc/src/format/pending/infinite-sketch-lines.rs new file mode 100644 index 00000000..1893a5d3 --- /dev/null +++ b/crates/doc/src/format/pending/infinite-sketch-lines.rs @@ -0,0 +1,5 @@ +//! A sketch line can be `infinite` (#1359): construction geometry through its two points, drawn +//! across the whole view, a boundary for trim and extend along its whole line. Saved only when +//! set: older designs need no transformation, and a line without it is the line it always was; +//! 0.13.x (which reads up to 24) asks for an update instead of drawing such a line between its +//! two points. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 8c6944c1..c9055cb4 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -593,7 +593,8 @@ fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result 1000 { return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)")); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index f1e5d9b3..6cc69a3d 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -2244,11 +2244,14 @@ fn construction(s: &mut Session, p: &Value) -> Result { let mut n = 0; for c in cs { if let Some(cu) = sk.curves.get_mut(c) - && (cu.construction != to || to && cu.centerline) + && (cu.construction != to || to && cu.centerline || !to && cu.infinite) { cu.construction = to; if to { cu.centerline = false; + } else { + // A normal line is a finite one. + cu.infinite = false; } n += 1; } @@ -2512,6 +2515,9 @@ mod drag_live_tests; #[path = "sketch_ids_tests.rs"] mod ids_tests; #[cfg(test)] +#[path = "sketch_infinite_tests.rs"] +mod infinite_tests; +#[cfg(test)] #[path = "sketch_redundant_tests.rs"] mod redundant_tests; #[cfg(test)] diff --git a/crates/engine/src/cmd/sketch_clipboard.rs b/crates/engine/src/cmd/sketch_clipboard.rs index b8036f93..c9764455 100644 --- a/crates/engine/src/cmd/sketch_clipboard.rs +++ b/crates/engine/src/cmd/sketch_clipboard.rs @@ -36,6 +36,7 @@ pub struct ClipCurve { pub construction: bool, pub centerline: bool, pub reversed: bool, + pub infinite: bool, } /// A constraint or dimension of the clipboard, over clipboard points and curves. A dimension @@ -133,7 +134,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { kind.map_points(&|q| used.iter().find(|(o, _)| *o == q).map_or(q, |(_, n)| *n)); cmap.insert(c, clip.curves.len()); // Projected geometry comes in as ordinary geometry. - clip.curves.push(ClipCurve { kind, construction: cu.construction, centerline: cu.centerline, reversed: cu.reversed }); + clip.curves.push(ClipCurve { kind, construction: cu.construction, centerline: cu.centerline, reversed: cu.reversed, infinite: cu.infinite }); } for &q in &ps { let known = pmap.contains_key(&q); @@ -276,6 +277,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { cu.construction = c.construction; cu.centerline = c.centerline; cu.reversed = c.reversed; + cu.infinite = c.infinite; } made.push(i); } diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index e6d428d4..9cf50fbb 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -94,6 +94,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("centerline") .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), + CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line) + .enabled(in_sketch) + .params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"), CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!( "text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \ font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ", @@ -436,6 +439,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result { cu.centerline = want.unwrap_or(!cu.centerline); if cu.centerline { cu.construction = false; + cu.infinite = false; } } Ok(ids.len()) @@ -443,6 +447,30 @@ fn centerline(s: &mut Session, p: &Value) -> Result { Ok(json!({"changed": n, "sketch": info})) } +fn infinite_line(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.infinite"; + let ids = string_list(p, "curves"); + if ids.is_empty() { + return Err(bad(cmd, "`curves` must list lines")); + } + let want = crate::params::bool_(p, "value"); + let (n, info) = edit(s, p, cmd, false, |sk, _| { + for id in &ids { + let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; + let cu = sk.curves.get_mut(c).ok_or_else(|| bad(cmd, "curve"))?; + if !matches!(cu.kind, CurveKind::Line { .. }) { + return Err(bad(cmd, format!("`{id}` is not a line"))); + } + // Infinite lines are construction geometry (never in a profile); off, a normal line. + cu.infinite = want.unwrap_or(!cu.infinite); + cu.construction = cu.infinite; + cu.centerline = false; + } + Ok(ids.len()) + })?; + Ok(json!({"changed": n, "sketch": info})) +} + fn ellipse(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.ellipse"; let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?); diff --git a/crates/engine/src/cmd/sketch_infinite_tests.rs b/crates/engine/src/cmd/sketch_infinite_tests.rs new file mode 100644 index 00000000..11fff7ad --- /dev/null +++ b/crates/engine/src/cmd/sketch_infinite_tests.rs @@ -0,0 +1,137 @@ +//! Infinite lines (#1359): a line type through two points, construction geometry that reaches +//! across the whole view, a boundary along its whole line, never trimmed, filleted or in a +//! profile. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec2; +use solidcraft_sketch::{CurveKind, Sketch}; + +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}"), + } +} + +fn sketch(s: &Session) -> Sketch { + s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone() +} + +fn curve<'a>(sk: &'a Sketch, id: &str) -> &'a solidcraft_sketch::Curve { + sk.curves.get(sk.curve_index(id).unwrap()).unwrap() +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s +} + +fn profiles(s: &Session) -> usize { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.len() +} + +#[test] +fn the_line_type_switches_on_and_off_and_stays_out_of_profiles() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + run(&mut s, "sketch.line", json!({"points": [[20, -10], [20, 30]], "ids": ["axis"]})); + assert_eq!(profiles(&s), 2, "a normal line cuts the rectangle in two"); + run(&mut s, "sketch.infinite", json!({"curves": ["axis"]})); + let sk = sketch(&s); + assert!(curve(&sk, "axis").infinite && curve(&sk, "axis").construction && !curve(&sk, "axis").centerline); + assert_eq!(profiles(&s), 1, "an infinite line is construction: no profile"); + // Back to a normal line, by toggling or by Normal/Construction. + run(&mut s, "sketch.infinite", json!({"curves": ["axis"]})); + let sk = sketch(&s); + assert!(!curve(&sk, "axis").infinite && !curve(&sk, "axis").construction); + run(&mut s, "sketch.infinite", json!({"curves": ["axis"], "value": true})); + run(&mut s, "sketch.construction", json!({"curves": ["axis"], "value": false})); + assert!(!curve(&sketch(&s), "axis").infinite, "a normal line is a finite one"); + // A centerline is not an infinite line. + run(&mut s, "sketch.infinite", json!({"curves": ["axis"], "value": true})); + run(&mut s, "sketch.centerline", json!({"curves": ["axis"], "value": true})); + let sk = sketch(&s); + assert!(curve(&sk, "axis").centerline && !curve(&sk, "axis").infinite); + // Only lines. + run(&mut s, "sketch.circle.center", json!({"center": [60, 0], "radius": 5, "id": "c"})); + assert!(s.execute("sketch.infinite", &json!({"curves": ["c"]})).is_err()); +} + +#[test] +fn it_is_saved_and_a_line_without_it_reads_finite() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 5]], "ids": ["a"]})); + run(&mut s, "sketch.line", json!({"points": [[0, 10], [10, 15]], "ids": ["b"]})); + run(&mut s, "sketch.infinite", json!({"curves": ["a"]})); + let text = s.doc.to_json(); + let back = solidcraft_doc::Document::from_json(&text).unwrap(); + let sk = back.sketch(s.active_sketch.unwrap()).unwrap(); + assert!(curve(sk, "a").infinite); + assert!(!curve(sk, "b").infinite); + assert_eq!(text.matches("\"infinite\"").count(), 1, "saved only where it is set"); +} + +#[test] +fn trim_and_extend_use_the_whole_line_but_never_trim_it() { + // An infinite vertical line at x = 25 whose two points lie far above the other line. + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[25, 50], [25, 60]], "ids": ["inf"]})); + run(&mut s, "sketch.infinite", json!({"curves": ["inf"]})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "ids": ["l"]})); + // Trimming the line's right part stops at the infinite line's crossing, x = 25. + run(&mut s, "sketch.trim", json!({"curve": "l", "at": [35, 0]})); + let sk = sketch(&s); + let CurveKind::Line { a, b } = curve(&sk, "l").kind else { panic!("a line") }; + let xs = [sk.point(a).unwrap().x, sk.point(b).unwrap().x]; + assert!(xs.iter().any(|x| (x - 25.0).abs() < 1e-6) && xs.iter().all(|x| *x <= 25.0 + 1e-6), "{xs:?}"); + // The infinite line itself is not trimmed. + let e = s.execute("sketch.trim", &json!({"curve": "inf", "at": [25, 55]})).unwrap_err().to_string(); + assert!(e.contains("infinite"), "{e}"); + // A short line extends to it, beyond its two points. + run(&mut s, "sketch.line", json!({"points": [[0, 10], [10, 10]], "ids": ["short"]})); + run(&mut s, "sketch.extend", json!({"curve": "short", "at": [10, 10]})); + let sk = sketch(&s); + let CurveKind::Line { b, .. } = curve(&sk, "short").kind else { panic!("a line") }; + assert!(sk.point(b).unwrap().dist(Vec2::new(25.0, 10.0)) < 1e-6, "{:?}", sk.point(b)); +} + +#[test] +fn fillets_and_chamfers_refuse_it_and_mirrors_copy_it() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20]], "ids": ["a", "b"]})); + run(&mut s, "sketch.infinite", json!({"curves": ["a"]})); + let before = s.doc.to_json(); + for (cmd, p) in [ + ("sketch.fillet", json!({"point": "a.end", "radius": 2})), + ("sketch.fillet", json!({"a": "a", "b": "b", "radius": 2})), + ("sketch.chamfer.equal_distance", json!({"point": "a.end", "distance": 2})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("infinite"), "{cmd}: {e}"); + } + assert_eq!(s.doc.to_json(), before); + run(&mut s, "sketch.line", json!({"points": [[-10, -5], [-10, 5]], "ids": ["m"]})); + let r = run(&mut s, "sketch.mirror", json!({"entities": ["a"], "line": "m"})); + let made = r["curves"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect::>(); + let sk = sketch(&s); + assert!(made.iter().any(|id| curve(&sk, id).infinite), "{r}"); +} + +#[test] +fn an_offset_of_it_is_infinite_too() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["inf"]})); + run(&mut s, "sketch.infinite", json!({"curves": ["inf"]})); + let r = run(&mut s, "sketch.offset", json!({"curves": ["inf"], "distance": 5, "side": [5, 5]})); + let made = r["curves"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect::>(); + let sk = sketch(&s); + assert!(!made.is_empty() && made.iter().all(|id| curve(&sk, id).infinite), "{r}"); + // It stays infinite when the offset follows a new distance. + let param = r["param"].as_str().unwrap().to_string(); + run(&mut s, "parameters.change", json!({"name": param, "expression": "8 mm"})); + let sk = sketch(&s); + assert!(made.iter().all(|id| curve(&sk, id).infinite)); +} diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 65e6325d..6c6249ee 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -147,8 +147,10 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> { { pts.extend([other.point(0.0), other.point(sweep)]); } + // An infinite line (#1359) cuts wherever its line crosses, beyond its two points too. + let other_infinite = sk.curves.get(oi).is_some_and(|c| c.infinite); for q in pts { - if on_bounded(&other, q).is_some() + if (other_infinite || on_bounded(&other, q).is_some()) && let Some(t) = on_bounded(&sh, q) && !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0) { @@ -197,7 +199,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec, Vec Result<()> { let id = sk.fresh(prefix)?; - sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false }); + sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false, infinite: false }); pieces.push(sk.curves.len() - 1); Ok(()) }; @@ -286,6 +288,9 @@ fn trim(s: &mut Session, p: &Value) -> Result { let (out, info) = edit(s, p, cmd, false, |sk, doc| { let ci = curve_arg(sk, p, cmd)?; let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default(); + if sk.curves.get(ci).is_some_and(|c| c.infinite) { + return Err(bad(cmd, "an infinite line has no ends to trim back to: make it a normal line first")); + } if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) { let r = super::sketch_freeform::trim_or_break(sk, ci, at, true, cmd)?; doc.prune_model_params(); @@ -418,7 +423,11 @@ fn extend(s: &mut Session, p: &Value) -> Result { if oi == ci { continue; } - let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi)); + let mut pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi)); + // An infinite line (#1359) is a boundary along its whole line. + if sk.curves.get(oi).is_some_and(|c| c.infinite) { + pieces = pieces.iter().map(|s| support::endless(s, sh.point(sh.param(at)))).collect(); + } segment_count += pieces.len(); if segment_count > 16_384 { return Err(bad(cmd, "too many exact curve pieces to extend safely")); @@ -528,6 +537,16 @@ fn weld_corner(sk: &mut Sketch, p: &Value) { } } +/// Fillets and chamfers refuse a corner on an infinite line (#1359): it has no end there. +fn refuse_infinite_corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<()> { + let infinite = |c: Option| c.and_then(|c| sk.curves.get(c)).is_some_and(|c| c.infinite); + let at_corner = match str_(p, "point").and_then(|r| sk.resolve_point(r)) { + Some(q) => sk.curves.iter().any(|c| c.infinite && c.kind.uses(q)), + None => ["a", "b"].iter().any(|k| infinite(str_(p, k).and_then(|i| sk.curve_index(i)))), + }; + if at_corner { Err(bad(cmd, "an infinite line has no corner to round or cut: make it a normal line first")) } else { Ok(()) } +} + /// A corner of two lines: (line a, line b, corner point, far end of a, far end of b). fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usize, usize)> { let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) { @@ -745,6 +764,7 @@ fn kept_json(kept: Vec) -> (Vec, Vec) { /// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves: /// the arc, the tangent constraints and what became of the corner point. fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec, CornerKept)> { + refuse_infinite_corner(sk, p, cmd)?; weld_corner(sk, p); let (la, lb, c, fa, fb) = match corner(sk, p, cmd) { Ok(x) => x, @@ -854,6 +874,7 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result Result<(f64, f64)>) -> Result { let (out, info) = edit(s, p, cmd, false, |sk, _| { + refuse_infinite_corner(sk, p, cmd)?; weld_corner(sk, p); let (la, lb, c, fa, fb) = corner(sk, p, cmd)?; let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); @@ -1343,6 +1364,18 @@ fn offset(s: &mut Session, p: &Value) -> Result { .map(|o| o.sources.iter().map(|(id, _)| id.clone()).collect::>()) .or_else(|| sk.freeform_offsets.iter().find(|o| o.output == *output).map(|o| vec![o.source.clone()])) .ok_or_else(|| bad(cmd, "offset association is missing"))?; + // An offset of infinite lines is infinite lines too (#1359). + let infinite = sources.iter().all(|id| sk.curve_index(id).and_then(|c| sk.curves.get(c)).is_some_and(|c| c.infinite)); + if infinite { + for id in group.iter().flat_map(|side| side.curves.iter()) { + if let Some(cu) = sk.curve_index(id).and_then(|c| sk.curves.get_mut(c)) + && matches!(cu.kind, CurveKind::Line { .. }) + { + cu.infinite = true; + cu.construction = true; + } + } + } for (index, side) in group.iter_mut().enumerate() { let name = if index == 1 { param2.as_ref().unwrap_or(¶m) } else { ¶m }; let output = side.curves.first().ok_or_else(|| bad(cmd, "offset produced no curves"))?; @@ -1631,6 +1664,8 @@ fn copy_through( } }; let id = sk.fresh(prefix)?; + // A mirrored or patterned infinite line is an infinite line too (#1359). + let infinite = cu.infinite && matches!(kind, CurveKind::Line { .. }); sk.curves.push(Curve { id, kind, @@ -1639,6 +1674,7 @@ fn copy_through( link: None, centerline: false, fixed: false, + infinite, }); made.push(sk.curves.len() - 1); } diff --git a/crates/engine/src/cmd/sketch_support.rs b/crates/engine/src/cmd/sketch_support.rs index 1a3208ef..1f3b1543 100644 --- a/crates/engine/src/cmd/sketch_support.rs +++ b/crates/engine/src/cmd/sketch_support.rs @@ -154,6 +154,20 @@ pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result Seg2 { + let Seg2::Line { a, b } = *seg else { return *seg }; + let Some(dir) = (b - a).normalized() else { return *seg }; + let mid = a + dir * (near - a).dot(dir); + Seg2::Line { a: mid - dir * ENDLESS, b: mid + dir * ENDLESS } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 4f57b9ba..8a8c274a 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -655,6 +655,7 @@ impl Sketch { link: Some(id.to_string()), centerline: false, fixed: false, + infinite: false, }); } for li in &lay.lone { diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 2a483cc2..042d968b 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -169,6 +169,11 @@ pub struct Curve { /// Fixed (locked): its points and radius are constants for the solver. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub fixed: bool, + /// Infinite line type (#1359), for lines only: construction geometry drawn across the whole + /// view, through its two points, which fix where it lies and which way it runs. Never part of + /// a profile (it is construction too). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub infinite: bool, } /// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or @@ -735,6 +740,7 @@ impl Sketch { link: None, centerline: false, fixed: false, + infinite: false, }); Ok(self.curves.len() - 1) } @@ -772,6 +778,7 @@ impl Sketch { link: None, centerline: false, fixed: false, + infinite: false, }); Ok(self.curves.len() - 1) } @@ -806,6 +813,7 @@ impl Sketch { link: None, centerline: false, fixed: false, + infinite: false, }); Ok(self.curves.len() - 1) } @@ -1376,7 +1384,7 @@ impl Sketch { } }; let id = self.curve_id(id, prefix)?; - self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false, fixed: false }); + self.curves.push(Curve { id, kind, construction: false, reversed: false, link: None, centerline: false, fixed: false, infinite: false }); Ok(self.curves.len() - 1) } diff --git a/crates/sketch/src/offset_topology.rs b/crates/sketch/src/offset_topology.rs index 0427d121..2d04e071 100644 --- a/crates/sketch/src/offset_topology.rs +++ b/crates/sketch/src/offset_topology.rs @@ -27,13 +27,21 @@ pub struct OffsetRoleFlags { pub centerline: bool, pub reversed: bool, pub fixed: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub infinite: bool, } impl OffsetRoleFlags { fn is_default(&self) -> bool { *self == Self::default() } fn of(curve: &Curve) -> Self { - Self { construction: curve.construction, centerline: curve.centerline, reversed: curve.reversed, fixed: curve.fixed } + Self { + construction: curve.construction, + centerline: curve.centerline, + reversed: curve.reversed, + fixed: curve.fixed, + infinite: curve.infinite, + } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -614,6 +622,7 @@ impl Sketch { link: None, centerline: false, fixed: false, + infinite: false, }); outputs.push(output); } @@ -767,6 +776,7 @@ impl Sketch { link: None, centerline: role.flags.centerline, fixed: role.flags.fixed, + infinite: role.flags.infinite, }); } outputs.push(role.output.clone()); diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 1a0be1fb..2b6d4a96 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -558,7 +558,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let mut whole_graph_complete = true; let mut expected = std::collections::HashMap::>::new(); for (ci, c) in sk.curves.iter().enumerate() { - if c.construction || c.centerline { + if c.construction || c.centerline || c.infinite { continue; } if matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }) { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index cad1081e..cc1d18e4 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -781,6 +781,20 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } + // Infinite lines (#1359): the selected lines switch together. + let lines: Vec = curves + .iter() + .filter(|id| { + ss.sketch + .curve_index(id) + .and_then(|i| ss.sketch.curves.get(i)) + .is_some_and(|c| matches!(c.kind, solidcraft_engine::sketch::CurveKind::Line { .. })) + }) + .cloned() + .collect(); + if !lines.is_empty() { + v.push(cmd(app, "sketch.infinite", "Infinite Line").with(json!({ "curves": lines }))); + } if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) { v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]}))); } diff --git a/crates/ui-egui/src/infinite_line_display_tests.rs b/crates/ui-egui/src/infinite_line_display_tests.rs new file mode 100644 index 00000000..bbb3bcc5 --- /dev/null +++ b/crates/ui-egui/src/infinite_line_display_tests.rs @@ -0,0 +1,61 @@ +//! An infinite line (#1359) is drawn across the whole view and picked along its whole line, +//! and the Sketch Palette's Infinite mode draws one. + +use serde_json::json; + +use crate::scenario::Harness; +use crate::viewport::{Hit, pick, projection}; + +/// An XY sketch with a short line from (0, 0) to (2, 0) made infinite, the view zoomed out. +fn short_infinite_line() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.line", json!({"points": [[0, 0], [2, 0]], "ids": ["inf"]})).unwrap(); + h.app.run("sketch.infinite", json!({"curves": ["inf"]})).unwrap(); + h.app.cam.distance = 400.0; + h.settle(); + h +} + +#[test] +fn it_is_drawn_from_edge_to_edge_of_the_view() { + let h = short_infinite_line(); + let rect = h.app.viewport.rect.unwrap(); + let proj = projection(&h.app, rect); + let st = h.app.session.world_state(); + let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let i = ss.sketch.curve_index("inf").unwrap(); + let parts = super::curve_parts(&ss.sketch, i, &ss.plane, &proj, 1.0); + let xs: Vec = parts.iter().flatten().filter_map(|p| proj.to_screen(*p)).map(|p| p.x).collect(); + let (lo, hi) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + // Its two points are a few pixels apart; drawn, it spans the view. + assert!(lo <= rect.left() + 2.0 && hi >= rect.right() - 2.0, "drawn from {lo} to {hi} in {rect:?}"); +} + +#[test] +fn it_is_picked_far_beyond_its_two_points() { + let h = short_infinite_line(); + let rect = h.app.viewport.rect.unwrap(); + let proj = projection(&h.app, rect); + let ss_plane = h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().plane; + let far = proj.to_screen(ss_plane.to_world(solidcraft_engine::geom::Vec2::new(150.0, 0.0))).unwrap(); + assert!(rect.contains(far)); + let hits = pick(&h.app, &proj, far); + assert!(hits.iter().any(|h| matches!(h, Hit::SketchCurve { id, .. } if id == "inf")), "{hits:?}"); +} + +#[test] +fn infinite_mode_draws_infinite_lines() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.settle(); + crate::sketch_palette::set_mode(&mut h.app, 3); + let r = h.app.run("sketch.line", json!({"points": [[0, 0], [10, 5]]})).unwrap(); + crate::sketch_palette::apply_mode(&mut h.app, "sketch.line", &r); + let id = r["curves"][0].as_str().unwrap().to_string(); + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let c = sk.curves.get(sk.curve_index(&id).unwrap()).unwrap(); + assert!(c.infinite && c.construction, "{c:?}"); +} diff --git a/crates/ui-egui/src/sketch_display.rs b/crates/ui-egui/src/sketch_display.rs index fbb20c19..a1178b60 100644 --- a/crates/ui-egui/src/sketch_display.rs +++ b/crates/ui-egui/src/sketch_display.rs @@ -26,8 +26,35 @@ pub fn pixel_view(proj: &Proj, ppp: f32) -> PixelCurveView { PixelCurveView { vp: proj.vp, width_px: f64::from(proj.rect.width() * ppp), height_px: f64::from(proj.rect.height() * ppp) } } +/// The pieces a curve is drawn and picked from. An infinite line (#1359) reaches across the whole +/// view: through the point of its line nearest the view's centre, far past the view each way +/// (the sampler clips it to the view). Only the display changes: the line keeps its two points. +fn display_segs(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj) -> Vec { + use solidcraft_engine::geom::Seg2; + let segs = sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)); + if !sketch.curves.get(index).is_some_and(|c| c.infinite) { + return segs; + } + let target = proj.cam.target - plane.origin; + let centre = Vec2::new(target.dot(plane.x), target.dot(plane.y)); + let reach = 4.0 * (proj.cam.distance.abs() + proj.cam.half_height().abs()) + 1.0; + segs.into_iter() + .map(|s| match s { + Seg2::Line { a, b } => match (b - a).normalized() { + Some(dir) => { + let mid = a + dir * (centre - a).dot(dir); + let reach = reach + mid.dist(centre); + Seg2::Line { a: mid - dir * reach, b: mid + dir * reach } + } + None => s, + }, + other => other, + }) + .collect() +} + pub fn curve_with_budget(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32, budget: &mut SamplingBudget) -> SampledPath { - let segments = sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)); + let segments = display_segs(sketch, index, plane, proj); let mut local = SamplingBudget::new(budget.remaining_vertices.min(4096), budget.remaining_nodes.min(65_536)); let initial = local.clone(); let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 }; @@ -47,7 +74,7 @@ pub fn curve_with_budget(sketch: &Sketch, index: usize, plane: &Plane, proj: &Pr pub fn curve_unclipped(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> SampledPath { let mut budget = SamplingBudget::new(4096, 65_536); let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 }; - for segment in sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)) { + for segment in display_segs(sketch, index, plane, proj) { let sampled = sample_segment_unclipped(&segment, plane, &pixel_view(proj, ppp), &mut budget); if sampled.quality != SamplingQuality::Complete { out.quality = sampled.quality; @@ -244,3 +271,7 @@ pub fn curve_sample(app: &SolveApp, sketch: u64, index: usize, plane: &Plane, pr )), } } + +#[cfg(test)] +#[path = "infinite_line_display_tests.rs"] +mod infinite_line_display_tests; diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index ac376ec0..aa81e55a 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -83,7 +83,7 @@ pub fn dof_text(dof: usize, ok: bool) -> String { } /// The line type new curves get in the sketch being edited (0 normal, 1 construction, -/// 2 centerline); a new sketch starts normal. +/// 2 centerline, 3 infinite); a new sketch starts normal. pub fn mode(app: &SolveApp) -> u8 { let (m, sk) = MODE.with(Cell::get); if sk.is_some() && sk == app.session.active_sketch { m } else { 0 } @@ -95,6 +95,7 @@ pub fn set_mode(app: &mut SolveApp, m: u8) { let hint = match m { 1 => "Construction mode: new curves are construction geometry (X or the palette's Normal to leave)", 2 => "Centerline mode: new lines are centerlines", + 3 => "Infinite mode: new lines are infinite construction lines", _ => "Normal mode", }; app.set_status(hint, false); @@ -124,7 +125,7 @@ pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) { if curves.is_empty() { return; } - let lines: Vec = if m == 2 { + let lines: Vec = if m == 2 || m == 3 { let st = app.session.model.state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; curves @@ -144,7 +145,8 @@ pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) { // Part of the drawing's own undo step. let depth = app.session.undo.len(); if !lines.is_empty() { - let _ = app.session.execute("sketch.centerline", &json!({ "curves": lines, "value": true })); + let line_type = if m == 3 { "sketch.infinite" } else { "sketch.centerline" }; + let _ = app.session.execute(line_type, &json!({ "curves": lines, "value": true })); } if !rest.is_empty() { let _ = app.session.execute("sketch.construction", &json!({ "curves": rest, "value": true })); @@ -158,9 +160,10 @@ fn selected_curves(app: &SolveApp) -> Vec { } /// The line types among the selected curves of the active sketch, indexed like the palette's -/// buttons (0 normal, 1 construction, 2 centerline); a mixed selection marks each of its types. -fn selected_linetypes(app: &SolveApp) -> [bool; 3] { - let mut types = [false; 3]; +/// buttons (0 normal, 1 construction, 2 centerline, 3 infinite); a mixed selection marks each of +/// its types. +fn selected_linetypes(app: &SolveApp) -> [bool; 4] { + let mut types = [false; 4]; let curves = selected_curves(app); if curves.is_empty() { return types; @@ -168,7 +171,9 @@ fn selected_linetypes(app: &SolveApp) -> [bool; 3] { let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return types }; for c in curves.iter().filter_map(|id| ss.sketch.curve_index(id).and_then(|i| ss.sketch.curves.get(i))) { - let k = if c.centerline { + let k = if c.infinite { + 3 + } else if c.centerline { 2 } else if c.construction { 1 @@ -182,7 +187,8 @@ fn selected_linetypes(app: &SolveApp) -> [bool; 3] { types } -/// Set the line type of the selected curves: 0 normal, 1 construction, 2 centerline (lines). +/// Set the line type of the selected curves: 0 normal, 1 construction, 2 centerline, 3 infinite +/// (the last two for lines). pub fn set_linetype(app: &mut SolveApp, kind: u8) { let curves = selected_curves(app); if curves.is_empty() { @@ -210,7 +216,14 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) { } app.run("sketch.construction", json!({ "curves": curves, "value": false })) } - 1 => app.run("sketch.construction", json!({ "curves": curves, "value": true })), + 1 => { + if !lines.is_empty() { + let _ = app.run("sketch.infinite", json!({ "curves": lines, "value": false })); + } + app.run("sketch.construction", json!({ "curves": curves, "value": true })) + } + 3 if lines.is_empty() => Err("an infinite line must be a line".to_string()), + 3 => app.run("sketch.infinite", json!({ "curves": lines, "value": true })), _ if lines.is_empty() => Err("a centerline must be a line".to_string()), _ => app.run("sketch.centerline", json!({ "curves": lines, "value": true })), }; @@ -430,7 +443,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // Centerline need a round dot and a clear gap to read apart. 30 by // 20 px left too little square room for either. let btn_w = 36.0; - let group_w = 3.0 * btn_w + 2.0 * ui.spacing().item_spacing.x; + let group_w = 4.0 * btn_w + 3.0 * ui.spacing().item_spacing.x; crate::frame::right_aligned(ui, width, group_w, |ui| { ui.horizontal(|ui| { // With a selection: its line type, and a click converts it; @@ -442,10 +455,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // construction orange. Normal's icon never changes with the // accent colour. It stays neutral in every case. let current = mode(app); - for (i, (label, icon, tip)) in - [("—", "normal", "Normal"), ("- -", "construction", "Construction"), ("-·-", "centerline", "Centerline")] - .iter() - .enumerate() + for (i, (label, icon, tip)) in [ + ("—", "normal", "Normal"), + ("- -", "construction", "Construction"), + ("-·-", "centerline", "Centerline"), + ("↔", "infinite", "Infinite"), + ] + .iter() + .enumerate() { let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 }; let tr_tip = app.tr_label(tip); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 8a8e22ae..cd23fc7b 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -533,7 +533,9 @@ pub(crate) fn paint_preview_curve( mode: u8, ) { for curve in &mut sketch.curves { - curve.centerline = mode == 2 && matches!(curve.kind, solidcraft_engine::sketch::CurveKind::Line { .. }); + let line = matches!(curve.kind, solidcraft_engine::sketch::CurveKind::Line { .. }); + curve.centerline = mode == 2 && line; + curve.infinite = mode == 3 && line; curve.construction = mode != 0 && !curve.centerline; } paint_curve(&sketch, 0, plane, painter, proj, stroke, &mut solidcraft_engine::render::SamplingBudget::new(4096, 65_536)); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index f4344154..583915d3 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -807,7 +807,8 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &std::sync::Arc Vec<(Vec3, Vec3)> { match mode { - 1 => dashes(pts, &[0.3 * unit, 0.2 * unit]), + // Construction, and infinite lines (#1359), which are construction too. + 1 | 3 => dashes(pts, &[0.3 * unit, 0.2 * unit]), 2 => dashes(pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]), _ => pts.windows(2).map(|w| (w[0], w[1])).collect(), }