JOIN with a single arc or elliptical arc offers cLose: the arc becomes its circle, the elliptical arc its full ellipse (fixes #1175) (#1197)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marco Arnulfo
2026-10-19 02:54:39 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 4125b351cd
commit 6576cac1a3
5 changed files with 191 additions and 17 deletions
+64 -15
View File
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Option<Handle>> {
fn run_join(s: &mut Session, p: &Value) -> Result<Value> {
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<f64>,
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<Handle>,
}
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<Step> {
@@ -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<Step> {
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<Step> {
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]);
+1 -1
View File
@@ -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<CommandSpec> {
@@ -316,6 +316,32 @@ pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handl
Ok(Some(keep))
}
/// What JOIN's Close option makes of a single arc (its circle) or elliptical arc (its full
/// ellipse); `None` for anything else.
pub(crate) fn closed_kind(k: &EntityKind) -> Option<EntityKind> {
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<Handle> {
let mut seen = std::collections::HashSet::new();
let hs: Vec<Handle> = 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
+96
View File
@@ -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::<Vec<_>>());
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::<Vec<_>>()), 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);
}
+4 -1
View File
@@ -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 |