Resources Numerical accuracy Calculation policy

Join and stretch

Joins lines, arcs and polylines that line up or meet end to end into one object, and stretches the parts of objects inside a window.

How the precision is kept

Join decides what belongs together with a tolerance of 1e-6 of the larger side of the selection's bounding box, and rebuilds the result from one of the pieces, so small gaps and offsets within that tolerance are absorbed. Stretch adds the move to the points inside the window and, for arcs with one end moved, keeps the arc's height.

Calculations under this policy

3 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

geom.js CadCraft 0.8.2 64-bit 3 calculations Reviewed

Tests bin/test-cadcraft.mjs

Joining objects

64-bit

One 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.

  • geom.js
  • lines 1371 to 1445
  • CadCraft 0.8.2
/**
 * 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 };
}

Bulge that keeps an arc's height

64-bit

The new bulge of an arc whose chord changes length, so that its height stays the same.

b′ = b·c₀ ÷ c₁

Written as (b·c₀ ÷ 2)·2 ÷ c₁; the two factors of 2 are exact. The bulge is returned unchanged when |b| ≤ 1e-12 or the new chord is 1e-9 or shorter.

  • geom.js
  • lines 1450 to 1454
  • CadCraft 0.8.2
/** The bulge that keeps an arc's height (sagitta) when its chord changes from c0 to c1, as AutoCAD stretches arcs. */
function keepSagitta(b, c0, c1) {
  if (!(Math.abs(b) > 1e-12) || !(c1 > EPS)) return b;
  return (b * c0 / 2) * 2 / c1;   // sagitta h = b·c/2 → new bulge = 2h / c1
}

Stretching an object

64-bit

An object with the points inside the stretch window moved and the rest left in place.

A moved point gets dx and dy added once. An arc with one end moved is rebuilt from its two end points and the height-keeping bulge through bulgeArc, so its center, radius and both angles are recomputed. An arc with both ends or only its center inside is moved whole through txEntity, which also recomputes its angles.

  • geom.js
  • lines 1456 to 1514
  • CadCraft 0.8.2
/**
 * e stretched by (dx, dy): what `inside(point)` says is in the window moves. → a new entity, or null when nothing in it
 * moves. Lines and polylines move their ends / vertices (arc segments keep their height); dimensions their points;
 * circles, ellipses, text, points and block references move whole when their center or insertion point is inside.
 */
export function stretchEntity(e, inside, dx, dy) {
  const mv = (p) => ({ x: p.x + dx, y: p.y + dy });
  switch (e.type) {
    case 'xline': case 'ray': return inside(e) ? { ...e, ...mv(e) } : null;   // its point in the window: the whole line moves (DR.6)
    case 'line': {
      const a = inside({ x: e.x1, y: e.y1 }), b = inside({ x: e.x2, y: e.y2 });
      if (!a && !b) return null;
      return { ...e, ...(a ? { x1: e.x1 + dx, y1: e.y1 + dy } : {}), ...(b ? { x2: e.x2 + dx, y2: e.y2 + dy } : {}) };
    }
    case 'poly': {
      const n = e.pts.length, hit = e.pts.map((p) => inside(p));
      if (!hit.some(Boolean)) return null;
      const pts = e.pts.map((p, i) => (hit[i] ? { ...p, ...mv(p) } : { ...p }));
      const segs = e.closed ? n : n - 1;
      for (let i = 0; i < segs; i++) {
        const j = (i + 1) % n;
        if (hit[i] !== hit[j] && Math.abs(e.pts[i].b || 0) > 1e-12) pts[i].b = keepSagitta(e.pts[i].b, dist(e.pts[i], e.pts[j]), dist(pts[i], pts[j]));
      }
      return { ...e, pts };
    }
    case 'arc': {
      const c = { x: e.cx, y: e.cy }, s = polar(c, e.a0, e.r), t = polar(c, e.a1, e.r);
      const a = inside(s), b = inside(t);
      if (!a && !b) return inside(c) ? txEntity(e, mTranslate(dx, dy)) : null;
      if (a && b) return txEntity(e, mTranslate(dx, dy));
      const sweep = normAngle(e.a1 - e.a0) || TAU;
      const b0 = Math.tan(sweep / 4), s1 = a ? mv(s) : s, t1 = b ? mv(t) : t;
      const nb = keepSagitta(b0, dist(s, t), dist(s1, t1));
      if (dist(s1, t1) <= EPS) return null;
      const g = bulgeArc(s1, t1, nb);
      return { ...e, cx: g.cx, cy: g.cy, r: g.r, a0: normAngle(g.a0), a1: normAngle(g.a1) };
    }
    case 'circle': case 'ellipse': return inside({ x: e.cx, y: e.cy }) ? txEntity(e, mTranslate(dx, dy)) : null;
    case 'text': case 'point': case 'insert': return inside({ x: e.x, y: e.y }) ? txEntity(e, mTranslate(dx, dy)) : null;
    case 'mleader': {
      const hit = e.pts.map((p) => inside(p));
      return hit.some(Boolean) ? { ...e, pts: e.pts.map((p, i) => (hit[i] ? mv(p) : p)) } : null;
    }
    case 'dim': {
      const keys = ['p1', 'p2', 'c', 'loc'].filter((k) => e[k] && inside(e[k]));
      if (!keys.length) return null;
      const out = { ...e };
      delete out.block;   // drawn again from its new points
      for (const k of keys) out[k] = mv(e[k]);
      return out;
    }
    case 'hatch': {
      let any = false;
      const loops = e.loops.map((l) => { const o = l.slice(); for (let i = 0; i + 1 < l.length; i += 2) if (inside({ x: l[i], y: l[i + 1] })) { o[i] += dx; o[i + 1] += dy; any = true; } return o; });
      return any ? { ...e, loops } : null;
    }
    default: return null;
  }
}

In detail

Method
Collinear lines: the extremes of the ends projected on the first line. Arcs on one circle: merged angle spans, the largest gap left open. Chains: ends matched within the tolerance. Stretch of an arc: new bulge = old bulge × old chord ÷ new chord.
Number types
64-bit numbers.
Tolerances
T = 1e-9 × max(width, height, 1) of the selection; every join test uses 1000 × T. Arc spans merge within 1e-9 rad. A bulge of 1e-12 or less is not adjusted by stretch.
Limits
Lines up to the tolerance off the first line are put onto it; arcs take the first arc's center and radius.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach