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 arcs: Vec<_> = entities
.iter()
.filter(|(_, e)| matches!(e, EntityType::Arc(_)))
.collect();
if !lines.is_empty() && arcs.is_empty() {
return try_join_lines(&lines);
} }
if !arcs.is_empty() && lines.is_empty() { let handles: Vec<Handle> = entities.iter().map(|(h, _)| *h).collect();
return try_join_arcs(&arcs); let common = entities[0].1.common().clone();
let (chain, closed) = stitch(segs)?;
// Ordered vertices, each tagged with the bulge of the segment that
// starts there. A closed chain reuses the first vertex as the wrap
// point, so it gets exactly one vertex per segment.
let mut verts: Vec<(DVec3, f64)> = chain.iter().map(|s| (s.a, s.bulge)).collect();
if !closed {
verts.push((chain.last().unwrap().b, 0.0));
} }
None
}
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)| {
if let EntityType::Line(l) = e {
let s = Vec3::new(l.start.x as f32, l.start.y as f32, 0.0);
let e2 = Vec3::new(l.end.x as f32, l.end.y as f32, 0.0);
(*h, l, s, e2)
} else {
unreachable!()
}
})
.collect();
// Check collinearity: all lines must be parallel and on the same infinite line. // An open run of collinear straight segments collapses back to one Line.
let (_, first_line, s0, e0) = segs[0]; if !closed && !has_arc && is_collinear(&verts) {
let dir0 = (e0 - s0).normalize_or_zero(); let mut line = acadrust::entities::Line::new();
if dir0.length_squared() < 1e-12 { line.common = common;
line.common.handle = Handle::NULL;
line.start = v3(verts.first().unwrap().0);
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();
let mut pl = acadrust::entities::LwPolyline::new();
pl.common = common;
pl.common.handle = Handle::NULL;
pl.vertices = lw_verts;
pl.is_closed = closed;
pl.elevation = z0;
return Some((handles, vec![EntityType::LwPolyline(pl)]));
}
// Non-planar: a 3D polyline carries no bulge, so a curved segment can't
// be represented — refuse rather than silently flatten it.
if has_arc {
return None; return None;
} }
let mut pl = acadrust::entities::Polyline3D::new();
for (_, _, si, ei) in &segs[1..] { pl.common = common;
// Check parallel pl.common.handle = Handle::NULL;
let diri = (*ei - *si).normalize_or_zero(); pl.vertices = verts
let cross = (dir0.x * diri.y - dir0.y * diri.x).abs();
if cross > 1e-4 {
return None;
}
// Check co-linear (point on same line)
let off = *si - s0;
let perp = (off - dir0 * off.dot(dir0)).length();
if perp > 1e-3 {
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();
// Check connectivity: no gaps larger than tolerance
let mut sorted: Vec<[f32; 2]> = segs
.iter()
.map(|(_, _, si, ei)| {
let ta = si.dot(dir0);
let tb = ei.dot(dir0);
[ta.min(tb), ta.max(tb)]
})
.collect();
sorted.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
for w in sorted.windows(2) {
if w[1][0] > w[0][1] + 1e-3 {
return None;
} // gap
} }
Some((handles, vec![EntityType::Polyline3D(pl)]))
let new_start = s0 + dir0 * t_min;
let new_end = s0 + dir0 * t_max;
let mut merged = first_line.clone();
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>)> { /// Stitch directed segments into a single chain by matching endpoints,
let segs: Vec<_> = arcs /// flipping each segment so the chain runs head-to-tail. Returns the
.iter() /// ordered chain and whether it closes on itself, or `None` when the
.map(|(h, e)| { /// segments don't form one connected path (a gap or a branch).
if let EntityType::Arc(a) = e { fn stitch(mut segs: Vec<Seg>) -> Option<(Vec<Seg>, bool)> {
(*h, a) let mut chain = vec![segs.remove(0)];
} else {
unreachable!()
}
})
.collect();
// All arcs must share the same center and radius // Grow off the tail.
let (_, first) = segs[0]; loop {
let cx = first.center.x; let end = chain.last().unwrap().b;
let cy = first.center.y; let Some(idx) = segs
let r = first.radius; .iter()
.position(|s| s.a.distance(end) <= JOIN_EPS || s.b.distance(end) <= JOIN_EPS)
for (_, a) in &segs[1..] { else {
if (a.center.x - cx).abs() > 1e-3 || (a.center.y - cy).abs() > 1e-3 { break;
return None; };
let mut s = segs.remove(idx);
if s.a.distance(end) > JOIN_EPS {
s.flip();
} }
if (a.radius - r).abs() > 1e-3 { chain.push(s);
return None; }
// Grow off the head.
loop {
let start = chain.first().unwrap().a;
let Some(idx) = segs
.iter()
.position(|s| s.a.distance(start) <= JOIN_EPS || s.b.distance(start) <= JOIN_EPS)
else {
break;
};
let mut s = segs.remove(idx);
if s.b.distance(start) > JOIN_EPS {
s.flip();
} }
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])
.collect();
// 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 closed = chain.len() >= 2
let span = merged_end - merged_start; && chain.first().unwrap().a.distance(chain.last().unwrap().b) <= JOIN_EPS;
let handles: Vec<Handle> = arcs.iter().map(|(h, _)| *h).collect(); Some((chain, closed))
if (span - std::f32::consts::TAU).abs() < 0.01 {
// 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 mut merged_arc = first.clone();
merged_arc.common.handle = Handle::NULL;
merged_arc.start_angle = merged_start as f64;
merged_arc.end_angle = merged_end as f64;
Some((handles, vec![EntityType::Arc(merged_arc)]))
} }
fn vec3_xz_to_v3(v: Vec3, z: f64) -> Vector3 { /// True when every vertex lies on one straight line (within tolerance).
Vector3::new(v.x as f64, v.y as f64, z) fn is_collinear(verts: &[(DVec3, f64)]) -> bool {
if verts.len() < 3 {
return true;
}
let dir = verts[1].0 - verts[0].0;
if dir.length() < JOIN_EPS {
return false;
}
let dir = dir.normalize();
verts
.windows(2)
.all(|w| (w[1].0 - w[0].0).cross(dir).length() <= 1e-6)
} }