Joining objects
64-bitOne line, arc, circle or polyline made from pieces that lie on one line, on one circle, or meet end to end.
T = 1e-9 × max(width, height, 1); pieces join when they agree within 1000 × T
The joining tolerance is 1e-6 of the larger side of the selection's bounding box, and at least 1e-6 drawing units. Joined lines are rebuilt from the first line's start point and direction at the smallest and largest projected parameters, so the result's ends are recomputed and lines within the tolerance of the first line are put onto it. Joined arcs take the first arc's center and radius, and where two chain pieces meet one of the two end points is dropped.
/**
* Join objects. → { results: [{ from: [ids], to: entity }], left: [ids] } — `to` replaces the objects in `from`.
* Lines on one line join into one line (gaps closed); arcs on one circle into one arc, or a circle when they close it;
* otherwise lines, arcs and open polylines that meet end to end (within tol) become polylines, closed when the chain
* closes. What joins with nothing is left.
*/
export function joinEntities(ents, tol = null) {
const list = ents.filter((e) => ['line', 'arc', 'poly'].includes(e.type) && !(e.type === 'poly' && e.closed));
const left = ents.filter((e) => !list.includes(e)).map((e) => e.id);
if (list.length < 2) return { results: [], left: ents.map((e) => e.id) };
const b = emptyBounds();
for (const e of list) for (const p of prims(e)) primBounds(p, b);
const T = tol ?? Math.max(b.maxX - b.minX, b.maxY - b.minY, 1) * 1e-9;
// all lines on one line → one line from the extremes
if (list.every((e) => e.type === 'line')) {
const a = list[0], u = { x: a.x2 - a.x1, y: a.y2 - a.y1 }, L = Math.hypot(u.x, u.y);
if (L > EPS) {
const on = (x, y) => Math.abs((x - a.x1) * u.y - (y - a.y1) * u.x) / L <= T * 1e3;
if (list.every((e) => on(e.x1, e.y1) && on(e.x2, e.y2))) {
const all = list.flatMap((e) => [((e.x1 - a.x1) * u.x + (e.y1 - a.y1) * u.y) / (L * L), ((e.x2 - a.x1) * u.x + (e.y2 - a.y1) * u.y) / (L * L)]);
const t0 = Math.min(...all), t1 = Math.max(...all);
return { results: [{ from: list.map((e) => e.id), to: { ...a, x1: a.x1 + u.x * t0, y1: a.y1 + u.y * t0, x2: a.x1 + u.x * t1, y2: a.y1 + u.y * t1 } }], left };
}
}
}
// all arcs on one circle → one arc counter-clockwise from the first, or the circle
if (list.every((e) => e.type === 'arc')) {
const a = list[0];
if (list.every((e) => Math.hypot(e.cx - a.cx, e.cy - a.cy) <= T * 1e3 && Math.abs(e.r - a.r) <= T * 1e3)) {
// merge the arcs' spans round the circle; the largest empty gap stays open, smaller gaps are filled (as AutoCAD)
const spans = list.map((e) => arcRange(e.a0, e.a1)).sort((p, q) => p[0] - q[0]);
const merged = [];
for (const [s0, s1] of spans) { const m = merged[merged.length - 1]; if (m && s0 <= m[1] + 1e-9) m[1] = Math.max(m[1], s1); else merged.push([s0, s1]); }
const last = merged[merged.length - 1];
if (merged.length > 1 && last[1] - TAU >= merged[0][0] - 1e-9) { merged[0] = [last[0] - TAU, Math.max(merged[0][1], last[1] - TAU)]; merged.pop(); }
const whole = merged.length === 1 && merged[0][1] - merged[0][0] >= TAU - 1e-9;
if (whole) return { results: [{ from: list.map((e) => e.id), to: { type: 'circle', layer: a.layer, color: a.color ?? null, cx: a.cx, cy: a.cy, r: a.r } }], left };
let best = null;
merged.forEach((m, k) => {
const nx = merged[(k + 1) % merged.length], gap = (k + 1 < merged.length ? nx[0] : nx[0] + TAU) - m[1];
if (!best || gap > best.gap) best = { gap, a0: nx[0], a1: m[1] };
});
return { results: [{ from: list.map((e) => e.id), to: { ...a, a0: normAngle(best.a0), a1: normAngle(best.a1) } }], left };
}
}
// chains of pieces meeting end to end
const pieces = list.map((e) => ({ e, v: pieceVerts(e) })).filter((p) => p.v);
const near = (p, q) => Math.abs(p.x - q.x) <= T * 1e3 && Math.abs(p.y - q.y) <= T * 1e3;
const used = new Set(), results = [];
for (const start of pieces) {
if (used.has(start)) continue;
used.add(start);
let chain = start.v.map((p) => ({ ...p })), from = [start.e.id];
for (let grew = true; grew;) {
grew = false;
for (const p of pieces) {
if (used.has(p)) continue;
const head = chain[0], tail = chain[chain.length - 1], ps = p.v[0], pe = p.v[p.v.length - 1];
let add = null, atEnd = true;
if (near(tail, ps)) add = p.v; else if (near(tail, pe)) add = reverseVerts(p.v);
else if (near(head, pe)) { add = p.v; atEnd = false; } else if (near(head, ps)) { add = reverseVerts(p.v); atEnd = false; }
if (!add) continue;
used.add(p); from.push(p.e.id); grew = true;
if (atEnd) { chain[chain.length - 1].b = 0; chain = [...chain.slice(0, -1), ...add.map((q) => ({ ...q }))]; }
else chain = [...add.slice(0, -1).map((q) => ({ ...q })), ...chain];
}
}
if (from.length < 2) { left.push(start.e.id); continue; }
const closed = chain.length > 2 && near(chain[0], chain[chain.length - 1]);
if (closed) chain.pop();
const src = start.e;
results.push({ from, to: { type: 'poly', layer: src.layer, color: src.color ?? null, closed, pts: chain.map((q) => ({ x: q.x, y: q.y, b: q.b || 0 })) } });
}
return { results, left };
}