Resources Numerical accuracy Calculation policy

Offset, extend, break and lengthen

Offsets an object by a distance, extends an end to a boundary, breaks an object between two points and lengthens or shortens it by an amount.

How the precision is kept

Each result is computed in 64-bit numbers from the object's stored geometry. Offsets move along a unit normal obtained by dividing by the length from Math.hypot. An extended end is the intersection point itself. Limits are absolute at 1e-9, in drawing units, radians or parameter.

Open item. Guard the division by the length in lengthenEntity for a zero-length line.

Calculations under this policy

5 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 5 calculations Reviewed

Tests bin/test-cadcraft.mjs

Offset of a line, circle or arc

64-bit

A copy of the object a set distance to one side.

line: p ± d·n, n = (−Δy, Δx) ÷ L; circle, arc: r ± d

The normal is the direction divided by the length from Math.hypot; a line shorter than 1e-9 is refused. A circle or arc is refused when the new radius is 1e-9 or less. A construction line uses its stored direction as the normal without normalizing it again.

  • geom.js
  • lines 825 to 847
  • CadCraft 0.8.2
/** An entity offset by d toward the side of `side` (a point), or null. */
export function offsetEntity(e, d, side) {
  switch (e.type) {
    case 'line': {
      const L = Math.hypot(e.x2 - e.x1, e.y2 - e.y1); if (L < EPS) return null;
      let nx = -(e.y2 - e.y1) / L, ny = (e.x2 - e.x1) / L;
      if ((side.x - e.x1) * nx + (side.y - e.y1) * ny < 0) { nx = -nx; ny = -ny; }
      return { ...e, x1: e.x1 + nx * d, y1: e.y1 + ny * d, x2: e.x2 + nx * d, y2: e.y2 + ny * d };
    }
    case 'circle': case 'arc': {
      const out = dist({ x: e.cx, y: e.cy }, side) > e.r;
      const r = e.r + (out ? d : -d);
      return r > EPS ? { ...e, r } : null;
    }
    case 'poly': return offsetPoly(e, d, side);
    case 'xline': case 'ray': {
      let nx = -e.dy, ny = e.dx;
      if ((side.x - e.x) * nx + (side.y - e.y) * ny < 0) { nx = -nx; ny = -ny; }
      return { ...e, x: e.x + nx * d, y: e.y + ny * d };
    }
    default: return null;
  }
}

Offset of a polyline

64-bit

A polyline offset to one side, with its corners rebuilt.

Each straight segment is moved along its unit normal and each arc keeps its center with r ± d. A corner between two straight segments is the intersection of the two moved lines taken as unbounded; at a corner with an arc the vertex is the moved piece's own start point, and every vertex keeps its original bulge. No step removes loops where the result crosses itself.

  • geom.js
  • lines 849 to 885
  • CadCraft 0.8.2
function offsetPoly(e, d, side) {
  // Signed offset: decide the side from the nearest segment, then offset every segment the same way.
  const ps = polyPrims(e);
  if (!ps.length) return null;
  let near = ps[0], nd = Infinity;
  for (const p of ps) { const x = distToPrim(p, side); if (x < nd) { nd = x; near = p; } }
  const leftOf = (p, q) => {
    if (p.k === 'L') return (p.x2 - p.x1) * (q.y - p.y1) - (p.y2 - p.y1) * (q.x - p.x1) > 0;
    const outside = dist({ x: p.cx, y: p.cy }, q) > p.r;
    return p.rev ? outside : !outside;      // CCW arc: left = inside
  };
  const sgn = leftOf(near, side) ? 1 : -1;  // +1 = offset to the left of travel
  const off = ps.map((p) => {
    if (p.k === 'L') {
      const L = Math.hypot(p.x2 - p.x1, p.y2 - p.y1) || 1, nx = -(p.y2 - p.y1) / L * d * sgn, ny = (p.x2 - p.x1) / L * d * sgn;
      return { ...p, x1: p.x1 + nx, y1: p.y1 + ny, x2: p.x2 + nx, y2: p.y2 + ny };
    }
    const toLeftInside = !p.rev;            // travelling CCW, the left side is the centre
    const r = p.r + (sgn > 0 === toLeftInside ? -d : d);
    return r > EPS ? { ...p, r } : null;
  });
  if (off.some((p) => !p)) return null;
  const start = (p) => (p.k === 'L' ? { x: p.x1, y: p.y1 } : primPoint(p, p.rev ? 1 : 0));
  const end = (p) => (p.k === 'L' ? { x: p.x2, y: p.y2 } : primPoint(p, p.rev ? 0 : 1));
  const n = off.length, pts = [];
  for (let i = 0; i < n; i++) {
    const p = off[i];
    let s = start(p);
    if (i > 0 || e.closed) {
      const prev = off[(i - 1 + n) % n];
      if (prev.k === 'L' && p.k === 'L') { const x = intersect(prev, p, true, true)[0]; if (x) s = x; }
    }
    pts.push({ x: s.x, y: s.y, b: e.pts[i].b || 0 });
  }
  if (!e.closed) { const last = end(off[n - 1]); pts.push({ x: last.x, y: last.y, b: 0 }); }
  return { ...e, pts };
}

Extend to a boundary

64-bit

An object with the end nearest the pick moved out to the closest boundary it reaches.

The new end of a line is the intersection point as computed, not a rounded parameter. Only intersections past the end by more than 1e-9 of the line's length count. For an arc the new end angle is atan2 of the intersection minus the center, and the gap to it must exceed 1e-9 rad.

  • geom.js
  • lines 1113 to 1156
  • CadCraft 0.8.2
/** Extend the end of e nearest `pick` to the closest boundary. Returns the new entity or null. */
export function extendEntity(e, boundaries, pick, blocks, { edgeExtend = false } = {}) {
  // EDGEMODE 1: a boundary line or arc counts as the whole line or circle it lies on
  const bps = boundaries.filter((b) => b !== e && (b.id === undefined || b.id !== e.id)).flatMap((b) => prims(b, blocks).map((q) => ({ q, ext: edgeExtend && (b.type === 'line' || b.type === 'arc') })));
  if (e.type === 'line') {
    const atStart = dist(pick, { x: e.x1, y: e.y1 }) < dist(pick, { x: e.x2, y: e.y2 });
    const from = atStart ? { x: e.x2, y: e.y2 } : { x: e.x1, y: e.y1 }, to = atStart ? { x: e.x1, y: e.y1 } : { x: e.x2, y: e.y2 };
    const L = dist(from, to); if (L < EPS) return null;
    const ray = { k: 'L', x1: from.x, y1: from.y, x2: to.x, y2: to.y };
    let best = null;
    for (const { q, ext } of bps) for (const x of intersect(ray, q, true, ext)) {
      const t = primParam(ray, x, false);
      if (t > 1 + 1e-9 && (!best || t < best.t)) best = { t, x };
    }
    if (!best) return null;
    return atStart ? { ...e, x1: best.x.x, y1: best.x.y } : { ...e, x2: best.x.x, y2: best.x.y };
  }
  if (e.type === 'arc') {
    const [a0, a1] = arcRange(e.a0, e.a1);
    const c = { x: e.cx, y: e.cy };
    const atStart = dist(pick, polar(c, a0, e.r)) < dist(pick, polar(c, a1, e.r));
    const full = { k: 'A', cx: e.cx, cy: e.cy, r: e.r, a0: 0, a1: TAU, full: true };
    let best = null;
    for (const { q, ext } of bps) for (const x of intersect(full, q, true, ext)) {
      const a = normAngle(Math.atan2(x.y - e.cy, x.x - e.cx));
      const gap = atStart ? normAngle(a0 - a) : normAngle(a - a1);
      if (gap > 1e-9 && gap < TAU - (a1 - a0) && (!best || gap < best.gap)) best = { gap, a };
    }
    if (!best) return null;
    return atStart ? { ...e, a0: best.a } : { ...e, a1: best.a };
  }
  if (e.type === 'poly' && !e.closed && e.pts.length >= 2) {
    const n = e.pts.length;
    const atStart = dist(pick, e.pts[0]) < dist(pick, e.pts[n - 1]);
    const [p, q] = atStart ? [e.pts[1], e.pts[0]] : [e.pts[n - 2], e.pts[n - 1]];
    if (Math.abs((atStart ? e.pts[0].b : e.pts[n - 2].b) || 0) > 1e-12) return null;   // arc ends: not yet
    const tmp = extendEntity({ type: 'line', x1: p.x, y1: p.y, x2: q.x, y2: q.y }, boundaries, q, blocks, { edgeExtend });
    if (!tmp) return null;
    const pts = e.pts.map((v) => ({ ...v }));
    if (atStart) { pts[0].x = tmp.x2; pts[0].y = tmp.y2; } else { pts[n - 1].x = tmp.x2; pts[n - 1].y = tmp.y2; }
    return { ...e, pts };
  }
  return null;
}

Break between two points

64-bit

An object with the part between two picked points removed, or split at one point.

Both points are projected onto the object as parameters, which are used unrounded; the new ends are computed back from them. A circle becomes an arc between the two angles from atan2. Two points count as the same when they are within 1e-9 × max(1, |p1|).

  • geom.js
  • lines 1264 to 1292
  • CadCraft 0.8.2
/**
 * BREAK: e without the part between p1 and p2 (both put onto it). p2 the same as p1 splits it there.
 * → [pieces] | { error: 'type' | 'circle' }. A circle loses the part counter-clockwise from p1 to p2; a closed
 * polyline the part from p1 to p2 in its own direction.
 */
export function breakEntity(e, p1, p2) {
  const same = dist(p1, p2) <= EPS * Math.max(1, Math.hypot(p1.x, p1.y));
  if (e.type === 'circle') {
    if (same) return { error: 'circle' };
    const a1 = Math.atan2(p1.y - e.cy, p1.x - e.cx), a2 = Math.atan2(p2.y - e.cy, p2.x - e.cx);
    return [{ ...e, type: 'arc', a0: normAngle(a2), a1: normAngle(a1) }];
  }
  if (!['line', 'arc', 'poly', 'xline', 'ray'].includes(e.type)) return { error: 'type' };
  let t1 = paramOn(e, p1), t2 = same ? t1 : paramOn(e, p2);
  if (e.type === 'poly' && e.closed) {
    const n = e.pts.length;
    const open = { ...e, closed: false, pts: [...e.pts, ...e.pts, { ...e.pts[0] }] };
    if (same) return [subEntity(open, t1, t1 + n)].filter(Boolean);
    if (t2 < t1) t2 += n;
    return [subEntity(open, t2, t1 + n)].filter(Boolean);
  }
  if (t2 < t1) [t1, t2] = [t2, t1];
  const maxT = e.type === 'poly' ? e.pts.length - 1 : 1;
  const tiny = UNBOUNDED.has(e.type) ? 1e-15 : 1e-9;
  const out = [];
  if (t1 > tiny || e.type === 'xline') out.push(subEntity(e, 0, t1));
  if (t2 < maxT - tiny) out.push(subEntity(e, t2, maxT));
  return out.filter(Boolean);
}

Lengthen by an amount

64-bit

An object made longer or shorter by a length, or an arc by an angle, at the end nearest the pick.

line: moved end = fixed end ± u·(L + δ); arc: new sweep = sweep + δ ÷ r; polyline arc end: bulge = ± tan(new sweep ÷ 4)

The unit direction divides by the current length with no guard: a zero-length line with a positive delta returns NaN coordinates. A result of 1e-9 or less is refused, and an arc within 1e-9 rad of a full turn is refused.

  • geom.js
  • lines 1303 to 1352
  • CadCraft 0.8.2
/**
 * LENGTHEN: e with the end nearest `pick` moved so the object gets longer or shorter by `delta` (drawing units), or,
 * for an arc, its included angle by `dAngle` (radians). → entity | { error: 'type' | 'short' | 'full' }.
 * A polyline changes its end segment (a straight one along its direction, an arc one round its center).
 */
export function lengthenEntity(e, pick, { delta = 0, dAngle = null } = {}) {
  if (e.type === 'line') {
    const L = Math.hypot(e.x2 - e.x1, e.y2 - e.y1), nl = L + delta;
    if (!(nl > EPS)) return { error: 'short' };
    const atStart = dist(pick, { x: e.x1, y: e.y1 }) < dist(pick, { x: e.x2, y: e.y2 });
    const ux = (e.x2 - e.x1) / L, uy = (e.y2 - e.y1) / L;
    return atStart ? { ...e, x1: e.x2 - ux * nl, y1: e.y2 - uy * nl } : { ...e, x2: e.x1 + ux * nl, y2: e.y1 + uy * nl };
  }
  if (e.type === 'arc') {
    const sw = normAngle(e.a1 - e.a0) || TAU, ns = sw + (dAngle ?? delta / e.r);
    if (!(ns > 1e-9)) return { error: 'short' };
    if (ns >= TAU - 1e-9) return { error: 'full' };
    const c = { x: e.cx, y: e.cy };
    const atStart = dist(pick, polar(c, e.a0, e.r)) < dist(pick, polar(c, e.a1, e.r));
    return atStart ? { ...e, a0: normAngle(e.a1 - ns) } : { ...e, a1: normAngle(e.a0 + ns) };
  }
  if (e.type === 'poly' && !e.closed && e.pts.length >= 2) {
    const n = e.pts.length, pts = e.pts.map((p) => ({ ...p }));
    const atStart = dist(pick, pts[0]) < dist(pick, pts[n - 1]);
    const i = atStart ? 0 : n - 2;   // the end segment
    const P = pts[i], Q = pts[i + 1], b = P.b || 0;
    if (Math.abs(b) < 1e-12) {
      const L = dist(P, Q), nl = L + delta;
      if (!(nl > EPS)) return { error: 'short' };
      const ux = (Q.x - P.x) / L, uy = (Q.y - P.y) / L;
      if (atStart) { pts[0].x = Q.x - ux * nl; pts[0].y = Q.y - uy * nl; } else { pts[n - 1].x = P.x + ux * nl; pts[n - 1].y = P.y + uy * nl; }
      return { ...e, pts };
    }
    const a = bulgeArc(P, Q, b), sw = a.a1 - a.a0, ns = sw + (dAngle ?? delta / a.r);
    if (!(ns > 1e-9)) return { error: 'short' };
    if (ns >= TAU - 1e-9) return { error: 'full' };
    // travel P → Q: counter-clockwise when b > 0; the far end of the segment moves, the near one stays
    const c = { x: a.cx, y: a.cy }, dir = b > 0 ? 1 : -1;
    if (atStart) {   // P moves: back from Q against the travel
      const aq = Math.atan2(Q.y - c.y, Q.x - c.x), np = polar(c, aq - dir * ns, a.r);
      pts[0].x = np.x; pts[0].y = np.y;
    } else {
      const ap = Math.atan2(P.y - c.y, P.x - c.x), nq = polar(c, ap + dir * ns, a.r);
      pts[n - 1].x = nq.x; pts[n - 1].y = nq.y;
    }
    pts[i].b = dir * Math.tan(ns / 4);
    return { ...e, pts };
  }
  return { error: 'type' };
}

In detail

Method
Offset: line p ± n·d, circle and arc r ± d, polyline segment by segment with straight corners re-intersected. Extend: nearest intersection beyond the end. Lengthen: fixed end plus unit direction × new length, or sweep + delta ÷ r for arcs.
Number types
64-bit numbers.
Tolerances
EPS = 1e-9 absolute: shortest line that can be offset, smallest radius after an offset, shortest result of lengthen. Extend needs t > 1 + 1e-9 on a line and a gap over 1e-9 rad on an arc. Break treats two points as one when they are within 1e-9 × max(1, distance of the first from the origin).
Limits
A polyline offset does not remove loops where the result crosses itself. Polyline ends that are arcs are not extended.

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