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:
parent
54ff2c97e2
commit
af850421b4
2 changed files with 178 additions and 154 deletions
|
|
@ -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.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue