collective

Browse / github.com/storytold/cadcraft / patch

feat(engine): support joining multiple connected chains in JOIN

feat(engine): support joining multiple connected chains in JOIN ## Summary This PR improves the JOIN command so that multiple disconnected chains or independent closed polygons can be joined simultaneously in a single command execution. ### Key Changes - **Multi-chain support in \crates/engine/src/cmd/modify.rs\:** - Iteratively extracts all connected loops and chains from the pool of selected segments. - Automatically breaks each chain when a closed loop is formed, allowing multiple closed polygons to be created cleanly in a single operation. - Returns \Result<Vec<Handle>>\ with all newly created entity handles. - **JSON API & interactive prompt:**

Status
open
Score
0.00
Adopters
0 other operators
Author
engdennylsonsantos-cloud (imported from pull request #2099, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+224 −68 in 3 files
Review
pending
Shared
2026-10-28
Id
d642c597ca86624863aed6f1

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/engine/src/cmd/modify.rs crates/engine/src/cmd/modify2_tests.rs

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -87,3 +87,8 @@ real_name = "Richard Sauer" display_name = "Richard Sauer" sync_github_name = false +[people.engdennylsonsantos-cloud]+real_name = "Dennylson Santos"+sync_github_name = true++diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs--- a/crates/engine/src/cmd/modify.rs+++ b/crates/engine/src/cmd/modify.rs@@ -1622,96 +1622,151 @@ fn run_breakat(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() })) } -/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines).-pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {+/// Join lines/arcs/polylines that share endpoints into polylines (or merge collinear lines).+pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {     if let Some(h) = super::modify2::join_curves(s, hs)? {-        return Ok(Some(h));+        return Ok(vec![h]);     }     if hs.iter().any(|h| super::curves::is_locked(s, *h)) {         return Err(EngineError::Other("An object is on a locked layer.".into()));     }     let d = s.doc()?;-    let mut segs: Vec<Segment> = Vec::new();-    // Source entity of each segment in `segs`, so only entities that end up in the chain are removed.-    let mut owners: Vec<Handle> = Vec::new();-    let mut common = None;+    let mut pool: Vec<(Segment, Handle)> = Vec::new();+    let mut entity_common = std::collections::HashMap::new();     for h in hs {         let Some(e) = d.entity(*h) else { continue };-        common.get_or_insert_with(|| e.common.clone());+        entity_common.insert(*h, e.common.clone());         match &e.kind {-            EntityKind::Line(l) => segs.push(Segment::Line(Line::new(l.a.xy(), l.b.xy()))),-            EntityKind::Arc(a) => segs.push(Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),-            EntityKind::LwPolyline(p) if !p.closed => segs.extend(Polyline { vertices: p.vertices.clone(), closed: false }.segments()),+            EntityKind::Line(l) => pool.push((Segment::Line(Line::new(l.a.xy(), l.b.xy())), *h)),+            EntityKind::Arc(a) => pool.push((Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }, *h)),+            EntityKind::LwPolyline(p) if !p.closed => {+                for seg in (Polyline { vertices: p.vertices.clone(), closed: false }).segments() {+                    pool.push((seg, *h));+                }+            }             _ => {}         }-        owners.resize(segs.len(), *h);     }-    if segs.len() < 2 {+    if pool.len() < 2 {         return Err(EngineError::Other("Select at least two objects to join.".into()));     }-    // Chain greedily from the first segment.-    let mut chain = vec![segs.remove(0)];-    let mut used = vec![owners.remove(0)];+     let tol = 1e-6;-    loop {-        let start = chain.first().map(Segment::start).unwrap_or_default();-        let end = chain.last().map(Segment::end).unwrap_or_default();-        let Some(i) =-            segs.iter().position(|s| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol))-        else {-            break;-        };-        let s0 = segs.remove(i);-        used.push(owners.remove(i));-        if s0.start().near(end, tol) {-            chain.push(s0);-        } else if s0.end().near(end, tol) {-            chain.push(s0.reversed());-        } else if s0.end().near(start, tol) {-            chain.insert(0, s0);-        } else {-            chain.insert(0, s0.reversed());+    let mut total: std::collections::HashMap<Handle, usize> = std::collections::HashMap::new();+    for (_, owner) in &pool {+        *total.entry(*owner).or_default() += 1;+    }++    let mut chains: Vec<(Vec<Segment>, Vec<Handle>)> = Vec::new();++    while !pool.is_empty() {+        let (first_seg, first_owner) = pool.remove(0);+        let mut chain = vec![first_seg];+        let mut used = vec![first_owner];++        loop {+            let start = chain.first().map(Segment::start).unwrap_or_default();+            let end = chain.last().map(Segment::end).unwrap_or_default();++            // Stop growing this chain once it closes into a loop.+            if chain.len() >= 2 && start.near(end, tol) {+                break;+            }++            let Some(i) = pool+                .iter()+                .position(|(s, _)| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol))+            else {+                break;+            };++            let (s0, owner) = pool.remove(i);+            used.push(owner);+            if s0.start().near(end, tol) {+                chain.push(s0);+            } else if s0.end().near(end, tol) {+                chain.push(s0.reversed());+            } else if s0.end().near(start, tol) {+                chain.insert(0, s0);+            } else {+                chain.insert(0, s0.reversed());+            }+        }++        let closed = chain.len() >= 2 && chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol));+        let mut unique_owners = used.clone();+        unique_owners.sort();+        unique_owners.dedup();++        let mut used_counts: std::collections::HashMap<Handle, usize> = std::collections::HashMap::new();+        for owner in &used {+            *used_counts.entry(*owner).or_default() += 1;+        }+        let fully_consumed = used_counts.iter().all(|(owner, count)| total.get(owner) == Some(count));++        if fully_consumed && (unique_owners.len() >= 2 || (chain.len() >= 2 && closed)) {+            chains.push((chain, used));         }     }-    if chain.len() < 2 {++    if chains.is_empty() {         return Err(EngineError::Other("Objects are not connected.".into()));     }-    let closed = chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol));-    let mut vs: Vec<PolyVertex> = chain-        .iter()-        .map(|s| PolyVertex {-            p: s.start(),-            bulge: match s {-                Segment::Line(_) => 0.0,-                Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }),-            },-            ..Default::default()-        })-        .collect();-    if !closed && let Some(l) = chain.last() {-        vs.push(PolyVertex::new(l.end()));-    }+     let space = s.space();     let doc = s.doc_mut()?;-    for h in hs.iter().filter(|h| used.contains(*h)) {-        doc.remove_entity(*h);-    }-    let nh = doc.new_handle();-    if let Some(st) = doc.space_mut(&space) {-        st.push(Entity { handle: nh, common: common.unwrap_or_default(), kind: lwpoly(vs, closed) });+    let mut out = Vec::new();++    for (chain, used) in chains {+        let closed = chain.len() >= 2 && chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol));+        let mut vs: Vec<PolyVertex> = chain+            .iter()+            .map(|s| PolyVertex {+                p: s.start(),+                bulge: match s {+                    Segment::Line(_) => 0.0,+                    Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }),+                },+                ..Default::default()+            })+            .collect();+        if !closed && let Some(l) = chain.last() {+            vs.push(PolyVertex::new(l.end()));+        }++        let mut unique_owners = used.clone();+        unique_owners.sort();+        unique_owners.dedup();+        for h in &unique_owners {+            doc.remove_entity(*h);+        }++        let first_owner = used.first().copied();+        let common = first_owner.and_then(|h| entity_common.get(&h).cloned()).unwrap_or_default();++        let nh = doc.new_handle();+        if let Some(st) = doc.space_mut(&space) {+            st.push(Entity { handle: nh, common, kind: lwpoly(vs, closed) });+        }+        out.push(nh);     }+     s.set_selection(Vec::new());-    Ok(Some(nh))+    Ok(out) } +/// Run the JOIN command with JSON arguments. 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() }));+        return Ok(json!({ "handle": h.hex(), "handles": [h.hex()] }));     }-    let r = join(s, &hs)?;-    Ok(json!({ "handle": r.map(|h| h.hex()) }))+    let rs = join(s, &hs)?;+    Ok(json!({+        "handle": rs.first().map(|h| h.hex()),+        "handles": rs.iter().map(|h| h.hex()).collect::<Vec<_>>()+    })) }  // ---------------- interactive ----------------@@ -1795,17 +1850,25 @@ impl SelectThen {     /// 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) => {+            Ok(hs) => {                 // 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}"))+                let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || hs.contains(o)).count();+                let m = hs.len();+                if m == 0 {+                    s.echo("0 objects joined".to_string());+                } else if m == 1 {+                    let h = hs.first().copied();+                    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}"));+                } else {+                    s.echo(format!("{n} objects joined into {m} polylines"));+                }             }             Err(e) => s.echo(e.to_string()),         }diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs--- a/crates/engine/src/cmd/modify2_tests.rs+++ b/crates/engine/src/cmd/modify2_tests.rs@@ -111,6 +111,94 @@ fn join_keeps_selected_objects_outside_the_chain() {     assert!(d.entity(Handle::parse_hex(&c).unwrap()).is_some(), "unrelated circle stays"); } +/// Test joining multiple polygons and open polyline strips simultaneously.+#[test]+fn join_multiple_polygons_simultaneously() {+    let mut s = Session::new();+    // Square 1 (4 lines)+    let s1_1 = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());+    let s1_2 = h(&s.execute("line", &json!({"points": [[10, 0], [10, 10]]})).unwrap());+    let s1_3 = h(&s.execute("line", &json!({"points": [[10, 10], [0, 10]]})).unwrap());+    let s1_4 = h(&s.execute("line", &json!({"points": [[0, 10], [0, 0]]})).unwrap());++    // Square 2 (4 lines)+    let s2_1 = h(&s.execute("line", &json!({"points": [[20, 0], [30, 0]]})).unwrap());+    let s2_2 = h(&s.execute("line", &json!({"points": [[30, 0], [30, 10]]})).unwrap());+    let s2_3 = h(&s.execute("line", &json!({"points": [[30, 10], [20, 10]]})).unwrap());+    let s2_4 = h(&s.execute("line", &json!({"points": [[20, 10], [20, 0]]})).unwrap());++    // Open line strip (3 lines)+    let o_1 = h(&s.execute("line", &json!({"points": [[40, 0], [50, 0]]})).unwrap());+    let o_2 = h(&s.execute("line", &json!({"points": [[50, 0], [50, 5]]})).unwrap());+    let o_3 = h(&s.execute("line", &json!({"points": [[50, 5], [60, 5]]})).unwrap());++    // Unconnected line+    let far = h(&s.execute("line", &json!({"points": [[100, 100], [110, 110]]})).unwrap());++    let all_handles = [+        s1_1.clone(),+        s1_2.clone(),+        s1_3.clone(),+        s1_4.clone(),+        s2_1.clone(),+        s2_2.clone(),+        s2_3.clone(),+        s2_4.clone(),+        o_1.clone(),+        o_2.clone(),+        o_3.clone(),+        far.clone(),+    ];++    let r = s.execute("join", &json!({"handles": all_handles})).unwrap();+    let created = r["handles"].as_array().unwrap();+    assert_eq!(created.len(), 3, "expected 3 polylines: square 1, square 2, and open strip");++    let d = s.doc().unwrap();+    // 11 source lines are consumed+    for hex in [&s1_1, &s1_2, &s1_3, &s1_4, &s2_1, &s2_2, &s2_3, &s2_4, &o_1, &o_2, &o_3] {+        assert!(d.entity(Handle::parse_hex(hex).unwrap()).is_none());+    }+    // Unconnected line stays+    assert!(d.entity(Handle::parse_hex(&far).unwrap()).is_some());++    // Check closed squares and open polyline+    let mut closed_count = 0;+    let mut open_count = 0;+    for h_val in created {+        let hex = h_val.as_str().unwrap();+        let p = poly(&kind(&s, hex));+        if p.closed {+            closed_count += 1;+            assert_eq!(p.vertices.len(), 4);+        } else {+            open_count += 1;+            assert_eq!(p.vertices.len(), 4);+        }+    }+    assert_eq!(closed_count, 2);+    assert_eq!(open_count, 1);+}++/// Test that partial-owner chains (e.g. self-touching open polyline with a tail) are rejected+/// to avoid deleting the source entity and losing unconsumed segments.+#[test]+fn join_partial_owner_rejected_to_prevent_loss() {+    let mut s = Session::new();+    // Open polyline with loop (0,0 -> 10,0 -> 10,10 -> 0,0) and tail (0,0 -> 0,-10)+    let p = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 0], [0, -10]]), false);+    let far = h(&s.execute("line", &json!({"points": [[100, 100], [110, 110]]})).unwrap());++    // JOIN should reject because the polyline is not fully consumed into a clean chain+    let r = s.execute("join", &json!({"handles": [p.clone(), far.clone()]}));+    assert!(r.is_err());++    // Both entities remain intact in document+    let d = s.doc().unwrap();+    assert!(d.entity(Handle::parse_hex(&p).unwrap()).is_some());+    assert!(d.entity(Handle::parse_hex(&far).unwrap()).is_some());+}+ #[test] fn pedit_command_line() {     let mut s = Session::new();