fix(join): stitch touching segments into a polyline, any angle

JOIN previously only merged collinear lines or co-circular arcs into a
single Line/Arc and rejected everything else as "not co-linear" — so the
segments of a broken polyline, which meet at vertices but run at angles,
could never rejoin. It also flattened endpoints to the first segment's Z,
silently discarding height.

Rewrite the geometry pass to chain segments wherever their endpoints
touch, regardless of angle: a collinear straight run still collapses to
one Line, a planar chain becomes an LwPolyline (arcs carried as bulges),
and a chain with varying Z becomes a Polyline3D, preserving height.
Open/closed is detected from the endpoints; a selection that isn't a
single connected chain, or holds an unsupported entity / tilted arc, is
refused with a clearer message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-20 21:08:23 +03:00
commit af850421b4
2 changed files with 178 additions and 154 deletions

View file

@ -958,7 +958,7 @@ impl OpenCADStudio {
self.tabs[i].scene.clear_preview_wire(); self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent(); self.restore_pre_cmd_tangent();
self.command_line.push_error( self.command_line.push_error(
"JOIN: objects are not collinear/co-circular or have gaps.", "JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.",
); );
} }
} }

View file

@ -1,15 +1,17 @@
// JOIN command — merge collinear Lines or co-circular Arcs into one entity. // JOIN command — stitch Lines and Arcs that touch end-to-end into one
// entity. Segments join wherever their endpoints meet; the angle between
// them is irrelevant (a broken polyline rejoins fine).
// //
// Supports: // Result:
// Line + Line → merged Line (if collinear and end-to-end) // collinear straight run → single Line
// Arc + Arc → merged Arc (if same center/radius and contiguous) // planar chain → LwPolyline (arcs carried as bulges)
// Arc + Arc → Circle (if the merged arc spans 360°) // chain with varying Z → Polyline3D (straight segments only)
// //
// Workflow: select objects then press Enter to join. // Workflow: select objects then press Enter to join.
use acadrust::types::Vector3; use acadrust::types::{Vector2, Vector3};
use acadrust::{EntityType, Handle}; use acadrust::{EntityType, Handle};
use glam::Vec3; use glam::{DVec3, Vec3};
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdResult};
@ -65,177 +67,199 @@ impl CadCommand for JoinCommand {
// ── Geometry ─────────────────────────────────────────────────────────────── // ── Geometry ───────────────────────────────────────────────────────────────
/// Try to join all `entities` in the slice into a minimal set. /// Endpoint-match tolerance (model units). Segments split from a shared
/// Returns `(kept, removed)` handle lists and the merged entity vec. /// vertex meet exactly, so this only absorbs float noise.
const JOIN_EPS: f64 = 1e-6;
/// One directed segment of the join chain. `bulge` is the LwPolyline bulge
/// for the arc from `a` to `b` (0 for a straight line); it is only
/// meaningful when the whole chain turns out planar in XY.
#[derive(Clone)]
struct Seg {
a: DVec3,
b: DVec3,
bulge: f64,
}
impl Seg {
fn flip(&mut self) {
std::mem::swap(&mut self.a, &mut self.b);
self.bulge = -self.bulge;
}
}
fn v3(p: DVec3) -> Vector3 {
Vector3::new(p.x, p.y, p.z)
}
/// Build the chain segment for one entity, or `None` for an entity type
/// JOIN can't carry (which aborts the whole join).
fn seg_of(e: &EntityType) -> Option<Seg> {
match e {
EntityType::Line(l) => Some(Seg {
a: DVec3::new(l.start.x, l.start.y, l.start.z),
b: DVec3::new(l.end.x, l.end.y, l.end.z),
bulge: 0.0,
}),
EntityType::Arc(arc) => {
// The bulge below assumes the arc lies in a +Z plane; a tilted
// or flipped normal would invert the CCW sweep, so reject it.
if arc.normal.x.abs() > 1e-6 || arc.normal.y.abs() > 1e-6 || arc.normal.z <= 0.0 {
return None;
}
let (cx, cy, cz) = (arc.center.x, arc.center.y, arc.center.z);
let r = arc.radius;
let (sa, ea) = (arc.start_angle, arc.end_angle);
let swept = (ea - sa).rem_euclid(std::f64::consts::TAU);
Some(Seg {
a: DVec3::new(cx + r * sa.cos(), cy + r * sa.sin(), cz),
b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz),
bulge: (swept / 4.0).tan(),
})
}
_ => None,
}
}
/// Join all `entities` end-to-end into a single entity. Segments join
/// wherever their endpoints touch — the angle between them is irrelevant.
/// A collinear straight run collapses to one `Line`; a planar chain
/// becomes an `LwPolyline` (arcs kept as bulges); a chain with varying Z
/// becomes a `Polyline3D` (straight segments only). Returns
/// `(removed_handles, new_entities)`, or `None` when the selection isn't a
/// single connected chain or holds an unsupported entity.
pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> { pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> {
if entities.len() < 2 { if entities.len() < 2 {
return None; return None;
} }
// Split into lines and arcs let mut segs = Vec::with_capacity(entities.len());
let lines: Vec<_> = entities for (_, e) in entities {
.iter() segs.push(seg_of(e)?);
.filter(|(_, e)| matches!(e, EntityType::Line(_))) }
.collect(); let handles: Vec<Handle> = entities.iter().map(|(h, _)| *h).collect();
let arcs: Vec<_> = entities let common = entities[0].1.common().clone();
.iter()
.filter(|(_, e)| matches!(e, EntityType::Arc(_)))
.collect();
if !lines.is_empty() && arcs.is_empty() { let (chain, closed) = stitch(segs)?;
return try_join_lines(&lines);
} // Ordered vertices, each tagged with the bulge of the segment that
if !arcs.is_empty() && lines.is_empty() { // starts there. A closed chain reuses the first vertex as the wrap
return try_join_arcs(&arcs); // point, so it gets exactly one vertex per segment.
} let mut verts: Vec<(DVec3, f64)> = chain.iter().map(|s| (s.a, s.bulge)).collect();
None if !closed {
verts.push((chain.last().unwrap().b, 0.0));
} }
fn try_join_lines(lines: &[&(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> { let has_arc = chain.iter().any(|s| s.bulge.abs() > 1e-12);
// Collect endpoints in the world XY plane let z0 = verts[0].0.z;
let segs: Vec<_> = lines let planar = verts.iter().all(|(p, _)| (p.z - z0).abs() <= JOIN_EPS);
.iter()
.map(|(h, e)| { // An open run of collinear straight segments collapses back to one Line.
if let EntityType::Line(l) = e { if !closed && !has_arc && is_collinear(&verts) {
let s = Vec3::new(l.start.x as f32, l.start.y as f32, 0.0); let mut line = acadrust::entities::Line::new();
let e2 = Vec3::new(l.end.x as f32, l.end.y as f32, 0.0); line.common = common;
(*h, l, s, e2) line.common.handle = Handle::NULL;
} else { line.start = v3(verts.first().unwrap().0);
unreachable!() line.end = v3(verts.last().unwrap().0);
return Some((handles, vec![EntityType::Line(line)]));
} }
if planar {
let lw_verts: Vec<acadrust::entities::LwVertex> = verts
.iter()
.map(|(p, bulge)| {
let mut v = acadrust::entities::LwVertex::new(Vector2::new(p.x, p.y));
v.bulge = *bulge;
v
}) })
.collect(); .collect();
let mut pl = acadrust::entities::LwPolyline::new();
// Check collinearity: all lines must be parallel and on the same infinite line. pl.common = common;
let (_, first_line, s0, e0) = segs[0]; pl.common.handle = Handle::NULL;
let dir0 = (e0 - s0).normalize_or_zero(); pl.vertices = lw_verts;
if dir0.length_squared() < 1e-12 { pl.is_closed = closed;
return None; pl.elevation = z0;
return Some((handles, vec![EntityType::LwPolyline(pl)]));
} }
for (_, _, si, ei) in &segs[1..] { // Non-planar: a 3D polyline carries no bulge, so a curved segment can't
// Check parallel // be represented — refuse rather than silently flatten it.
let diri = (*ei - *si).normalize_or_zero(); if has_arc {
let cross = (dir0.x * diri.y - dir0.y * diri.x).abs();
if cross > 1e-4 {
return None; return None;
} }
// Check co-linear (point on same line) let mut pl = acadrust::entities::Polyline3D::new();
let off = *si - s0; pl.common = common;
let perp = (off - dir0 * off.dot(dir0)).length(); pl.common.handle = Handle::NULL;
if perp > 1e-3 { pl.vertices = verts
return None;
}
}
// Project all endpoints onto the direction axis
let params: Vec<f32> = segs
.iter() .iter()
.flat_map(|(_, _, si, ei)| [si.dot(dir0), ei.dot(dir0)]) .map(|(p, _)| acadrust::entities::Vertex3DPolyline::new(v3(*p)))
.collect(); .collect();
let t_min = params.iter().cloned().fold(f32::INFINITY, f32::min); if closed {
let t_max = params.iter().cloned().fold(f32::NEG_INFINITY, f32::max); pl.close();
}
Some((handles, vec![EntityType::Polyline3D(pl)]))
}
// Check connectivity: no gaps larger than tolerance /// Stitch directed segments into a single chain by matching endpoints,
let mut sorted: Vec<[f32; 2]> = segs /// flipping each segment so the chain runs head-to-tail. Returns the
/// ordered chain and whether it closes on itself, or `None` when the
/// segments don't form one connected path (a gap or a branch).
fn stitch(mut segs: Vec<Seg>) -> Option<(Vec<Seg>, bool)> {
let mut chain = vec![segs.remove(0)];
// Grow off the tail.
loop {
let end = chain.last().unwrap().b;
let Some(idx) = segs
.iter() .iter()
.map(|(_, _, si, ei)| { .position(|s| s.a.distance(end) <= JOIN_EPS || s.b.distance(end) <= JOIN_EPS)
let ta = si.dot(dir0); else {
let tb = ei.dot(dir0); break;
[ta.min(tb), ta.max(tb)] };
}) let mut s = segs.remove(idx);
.collect(); if s.a.distance(end) > JOIN_EPS {
sorted.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap()); s.flip();
for w in sorted.windows(2) { }
if w[1][0] > w[0][1] + 1e-3 { chain.push(s);
return None;
} // gap
} }
let new_start = s0 + dir0 * t_min; // Grow off the head.
let new_end = s0 + dir0 * t_max; loop {
let start = chain.first().unwrap().a;
let mut merged = first_line.clone(); let Some(idx) = segs
merged.common.handle = Handle::NULL;
merged.start = vec3_xz_to_v3(new_start, first_line.start.z);
merged.end = vec3_xz_to_v3(new_end, first_line.start.z);
let handles: Vec<Handle> = lines.iter().map(|(h, _)| *h).collect();
Some((handles, vec![EntityType::Line(merged)]))
}
fn try_join_arcs(arcs: &[&(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> {
let segs: Vec<_> = arcs
.iter() .iter()
.map(|(h, e)| { .position(|s| s.a.distance(start) <= JOIN_EPS || s.b.distance(start) <= JOIN_EPS)
if let EntityType::Arc(a) = e { else {
(*h, a) break;
} else { };
unreachable!() let mut s = segs.remove(idx);
} if s.b.distance(start) > JOIN_EPS {
}) s.flip();
.collect();
// All arcs must share the same center and radius
let (_, first) = segs[0];
let cx = first.center.x;
let cy = first.center.y;
let r = first.radius;
for (_, a) in &segs[1..] {
if (a.center.x - cx).abs() > 1e-3 || (a.center.y - cy).abs() > 1e-3 {
return None;
}
if (a.radius - r).abs() > 1e-3 {
return None;
} }
chain.insert(0, s);
} }
// Collect (start_angle, end_angle) in radians. if !segs.is_empty() {
let mut intervals: Vec<[f32; 2]> = segs return None; // disconnected or branched selection
.iter() }
.map(|(_, a)| [a.start_angle as f32, a.end_angle as f32]) let closed = chain.len() >= 2
.collect(); && chain.first().unwrap().a.distance(chain.last().unwrap().b) <= JOIN_EPS;
Some((chain, closed))
// Sort by start angle
intervals.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
// Try to merge into one contiguous arc
let merged_start = intervals[0][0];
let mut merged_end = intervals[0][1];
for &[s, e] in &intervals[1..] {
let span = (e - merged_end).rem_euclid(std::f32::consts::TAU);
let gap = (s - merged_end).rem_euclid(std::f32::consts::TAU);
let _ = span;
if gap > 1e-3 {
return None;
} // discontinuous
merged_end = e;
} }
let span = merged_end - merged_start; /// True when every vertex lies on one straight line (within tolerance).
let handles: Vec<Handle> = arcs.iter().map(|(h, _)| *h).collect(); fn is_collinear(verts: &[(DVec3, f64)]) -> bool {
if verts.len() < 3 {
if (span - std::f32::consts::TAU).abs() < 0.01 { return true;
// Full circle
let mut circle = acadrust::entities::Circle::new();
circle.common = first.common.clone();
circle.common.handle = Handle::NULL;
circle.center = first.center.clone();
circle.radius = r;
circle.normal = first.normal.clone();
return Some((handles, vec![EntityType::Circle(circle)]));
} }
let dir = verts[1].0 - verts[0].0;
let mut merged_arc = first.clone(); if dir.length() < JOIN_EPS {
merged_arc.common.handle = Handle::NULL; return false;
merged_arc.start_angle = merged_start as f64;
merged_arc.end_angle = merged_end as f64;
Some((handles, vec![EntityType::Arc(merged_arc)]))
} }
let dir = dir.normalize();
fn vec3_xz_to_v3(v: Vec3, z: f64) -> Vector3 { verts
Vector3::new(v.x as f64, v.y as f64, z) .windows(2)
.all(|w| (w[1].0 - w[0].0).cross(dir).length() <= 1e-6)
} }