diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 324d9b28..c2a5d24d 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { CommandSpec::new("join", "Join", run_join) .menu(&["Modify", "Join"]) .alias(&["j"]) - .params("{handles?}") + .params("{handles?, close?: bool (a single arc becomes its circle, an elliptical arc its full ellipse)}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Join)))), ] } @@ -1641,6 +1641,10 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result> { fn run_join(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; + if p.get("close").and_then(Value::as_bool) == Some(true) { + let h = super::modify2::join_close(s, &hs)?; + return Ok(json!({ "handle": h.hex() })); + } let r = join(s, &hs)?; Ok(json!({ "handle": r.map(|h| h.hex()) })) } @@ -1677,6 +1681,8 @@ struct SelectThen { ref_len: Option, copy_mode: bool, ask_erase: bool, + /// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose. + join_source: Option, } impl SelectThen { @@ -1693,6 +1699,7 @@ impl SelectThen { ref_len: None, copy_mode: false, ask_erase: false, + join_source: None, } } fn apply(&mut self, s: &mut Session) -> Result { @@ -1708,23 +1715,57 @@ impl SelectThen { Ok(Step::Done) } Op::Join => { - match join(s, &self.objs) { - Ok(h) => { - // Objects outside the joined chain are kept, so count only the ones JOIN consumed - // (and the source object an ellipse or spline join keeps). - let d = s.doc()?; - let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count(); - let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) { - Some(EntityKind::Spline(_)) => "spline", - Some(EntityKind::Ellipse(_)) => "ellipse", - _ => "polyline", - }; - s.echo(format!("{n} objects joined into 1 {what}")) - } - Err(e) => s.echo(e.to_string()), + if let [h] = self.objs[..] + && s.doc()?.entity(h).is_some_and(|e| super::modify2::closed_kind(&e.kind).is_some()) + { + self.join_source = Some(h); + return Ok(Step::Continue); } + self.join_now(s) + } + _ => Ok(Step::Continue), + } + } + + /// JOIN the selected objects and report what they became. + fn join_now(&mut self, s: &mut Session) -> Result { + match join(s, &self.objs) { + Ok(h) => { + // Objects outside the joined chain are kept, so count only the ones JOIN consumed + // (and the source object an ellipse or spline join keeps). + let d = s.doc()?; + let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count(); + let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) { + Some(EntityKind::Spline(_)) => "spline", + Some(EntityKind::Ellipse(_)) => "ellipse", + _ => "polyline", + }; + s.echo(format!("{n} objects joined into 1 {what}")) + } + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } + + /// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes + /// it its circle or full ellipse. + fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result { + match i { + Input::Keyword(k) if k == "cLose" => { + let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))); + super::modify2::join_close(s, &[src])?; + s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." }); Ok(Step::Done) } + Input::Point(p) => { + if let Some(h) = super::curves::pick_at(s, p) + && !self.objs.contains(&h) + { + self.objs.push(h); + } + Ok(Step::Continue) + } + Input::Enter => self.join_now(s), _ => Ok(Step::Continue), } } @@ -1909,6 +1950,11 @@ impl Interactive for SelectThen { if self.ask_erase { return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); } + if let Some(src) = self.join_source { + let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)))); + let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" }; + return Prompt::new(msg, Accept::POINT).kw(&["cLose"]); + } let k = self.pts.len(); let base = self.pts.first().copied(); if self.op == Op::Scale && self.reference { @@ -1956,6 +2002,9 @@ impl Interactive for SelectThen { } } } + if let Some(src) = self.join_source { + return self.join_source_input(s, src, i); + } if self.ask_erase { let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y')); let (a, b) = (self.pts[0], self.pts[1]); diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 37a48092..6b718e24 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -16,7 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St mod curve_edits; mod fillet_pline; -pub(crate) use curve_edits::join_curves; +pub(crate) use curve_edits::{closed_kind, join_close, join_curves}; pub(crate) use fillet_pline::{ChamferDistances, fillet_pline}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/modify2/curve_edits.rs b/crates/engine/src/cmd/modify2/curve_edits.rs index b27f9a5e..af7a74f5 100644 --- a/crates/engine/src/cmd/modify2/curve_edits.rs +++ b/crates/engine/src/cmd/modify2/curve_edits.rs @@ -316,6 +316,32 @@ pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result Option { + match k { + EntityKind::Arc(a) => Some(EntityKind::Circle(cadcraft_doc::Circle { center: a.center, radius: a.radius })), + EntityKind::Ellipse(e) if !ellipse_of(e).is_full() => Some(ellipse_kind(&Ellipse { start: 0.0, end: TAU, ..ellipse_of(e) })), + _ => None, + } +} + +/// JOIN Close: the single arc or elliptical arc in `hs` becomes its circle or full ellipse, +/// keeping its handle. +pub(crate) fn join_close(s: &mut Session, hs: &[Handle]) -> Result { + let mut seen = std::collections::HashSet::new(); + let hs: Vec = hs.iter().copied().filter(|h| seen.insert(*h)).collect(); + let refuse = || other("Close works on a single arc or elliptical arc."); + let [h] = hs[..] else { return Err(refuse()) }; + let k = s.doc()?.entity(h).and_then(|e| closed_kind(&e.kind)).ok_or_else(refuse)?; + if curves::is_locked(s, h) { + return Err(other("An object is on a locked layer.")); + } + set_kind(s, h, k)?; + s.set_selection(Vec::new()); + Ok(h) +} + // ---------------- FILLET with elliptical arcs and splines ---------------- /// An entity as a tangency target for FILLET (lines infinite, arcs as their circle, elliptical diff --git a/crates/engine/tests/join_close.rs b/crates/engine/tests/join_close.rs new file mode 100644 index 00000000..6884b226 --- /dev/null +++ b/crates/engine/tests/join_close.rs @@ -0,0 +1,96 @@ +//! JOIN with a single arc or elliptical arc offers cLose, which makes it the full circle or +//! ellipse (#1175). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use serde_json::{Value, json}; + +fn h(r: &Value) -> String { + r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string() +} + +fn kind(s: &Session, h: &str) -> EntityKind { + s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone() +} + +fn count(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +#[test] +fn join_close_turns_a_single_arc_into_its_circle() { + let mut s = Session::new(); + s.script("ARC C 50,50 80,50 50,80\nJOIN 80,50\n\n").unwrap(); + let p = s.current_prompt().expect("JOIN asks what to do with the single arc"); + assert!(p.keywords.iter().any(|k| k == "cLose"), "{p:?}"); + s.cmdline("L").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(count(&s), 1); + match &s.doc().unwrap().model.iter().next().unwrap().kind { + EntityKind::Circle(c) => { + assert!(c.center.xy().near(cadcraft_engine::geom::Vec2::new(50.0, 50.0), 1e-9), "{c:?}"); + assert!((c.radius - 30.0).abs() < 1e-9, "{c:?}"); + } + k => panic!("not a circle: {k:?}"), + } + // Undo brings the arc back. + s.execute("undo", &json!({})).unwrap(); + assert!(matches!(s.doc().unwrap().model.iter().next().unwrap().kind, EntityKind::Arc(_))); +} + +#[test] +fn join_close_turns_a_single_elliptical_arc_into_its_ellipse() { + let mut s = Session::new(); + let a = h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap()); + s.script("JOIN 4,0\n\n").unwrap(); + let p = s.current_prompt().expect("JOIN asks what to do with the single elliptical arc"); + assert_eq!(p.display(), "Select elliptical arcs to join to source or [cLose]:"); + s.cmdline("cLose").unwrap(); + assert_eq!(count(&s), 1); + match kind(&s, &a) { + EntityKind::Ellipse(e) => { + assert!(e.start.abs() < 1e-9 && (e.end - std::f64::consts::TAU).abs() < 1e-9, "{e:?}"); + assert!((e.ratio - 0.5).abs() < 1e-12 && (e.major.x - 4.0).abs() < 1e-12, "{e:?}"); + } + k => panic!("not an ellipse: {k:?}"), + } +} + +#[test] +fn join_close_by_json_and_its_refusals() { + let mut s = Session::new(); + let arc = h(&s.execute("arc", &json!({ "center": [50, 50], "radius": 30, "start": 0, "end": 90 })).unwrap()); + let r = s.execute("join", &json!({ "handles": [arc], "close": true })).unwrap(); + assert_eq!(h(&r), arc, "the arc keeps its handle"); + match kind(&s, &arc) { + EntityKind::Circle(c) => assert!((c.radius - 30.0).abs() < 1e-9 && c.center.xy().near(cadcraft_engine::geom::Vec2::new(50.0, 50.0), 1e-9)), + k => panic!("not a circle: {k:?}"), + } + // Close is for one arc or elliptical arc: a line, a circle or two objects are refused, + // the drawing unchanged. + let line = h(&s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap()); + let other = h(&s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap()); + let before = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()); + for hs in [json!([line]), json!([arc]), json!([other, line])] { + assert!(s.execute("join", &json!({ "handles": hs, "close": true })).is_err(), "{hs}"); + } + assert_eq!(format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()), before); +} + +#[test] +fn join_close_prompt_takes_more_arcs_and_is_only_for_arcs() { + // Arcs picked at the prompt join to the source as they would from the selection. + let mut s = Session::new(); + s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap(); + s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 90, "end": 180 })).unwrap(); + s.script("JOIN 5,0\n\n").unwrap(); + s.cmdline("-5,0").unwrap(); + s.cmdline("").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(count(&s), 1); + // A single line has nothing to close: the command ends with the usual refusal. + let mut s = Session::new(); + s.script("LINE 0,0 10,0\n\nJOIN 5,0\n\n").unwrap(); + assert!(s.current_prompt().is_none()); + assert!(s.log.iter().any(|l| l == "Select at least two objects to join."), "{:?}", s.log); +} diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 8773b4f3..58fe7954 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (property-based and hostile-input test suite for geometry landed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (JOIN Close on a single arc or elliptical arc) · **Target:** Autodesk AutoCAD 2027 CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document @@ -84,6 +84,8 @@ constraints 10%. | EXTEND | every open curve | line, arc, polyline, elliptical arc, open spline (runs on along its end tangent, `modify2/curve_edits.rs`) | | FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode, Trim/No trim, Multiple; elliptical arcs and splines with any of these (tangent circle from sampled offsets refined by Newton's method, `geom/src/fillet.rs`; arcs trimmed or extended round the ellipse, splines split at the touch point); two straight segments of one polyline (adjacent, or with one between that the corner replaces), a polyline's straight end segment with a line (the line joins it); not polyline arc segments, xlines or rays | | CHAMFER | lines, polylines, xlines, rays | lines, Distance/Angle, whole polylines (Polyline option), polyline segments as for FILLET (`modify2/fillet_pline.rs`); refuses arcs, ellipses and splines with a message, as AutoCAD; not xlines or rays | +| BREAK | every curve | line, arc, circle, polyline | +| JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline into a polyline; elliptical arcs on one ellipse into a larger arc or the full ellipse (gaps close); Close turns a single arc into its circle and a single elliptical arc into its full ellipse; splines with lines, arcs, polylines and elliptical arcs into one exact NURBS (degree elevation, rational arcs; `geom/src/nurbs.rs`); no helixes | | BREAK | every curve | line, arc, circle, polyline; a closed polyline loses the stretch from the first to the second point along its vertices and is left open (#1173) | | JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline into a polyline; elliptical arcs on one ellipse into a larger arc or the full ellipse (gaps close); splines with lines, arcs, polylines and elliptical arcs into one exact NURBS (degree elevation, rational arcs; `geom/src/nurbs.rs`); no helixes | | LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline; elliptical arc (arc length along the ellipse); open spline (cut along the curve, or run on along its end tangent) | @@ -155,6 +157,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | | 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) | | 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (`crates/geom/src/property_tests.rs`): intersections, closest points, three-point arcs, bounds, vector identities; hostile f64s never panic; known answers at 1e6–1e7 | | 2026-10-10 | major | First geometry-precision audit: tolerances, curves, intersections, offsets, modify commands, measurement, hatch, solver |