Resources Numerical accuracy Calculation policy

Sketch intersections of lines, arcs and circles

Finds where sketch lines, arcs and circles cross each other; trim, extend and offset use the points.

How the precision is kept

Line with line, line with circle and circle with circle are solved in closed form in 64-bit. The parallel, tangent and concentric tests are relative to the sizes of the inputs. The line and circle case uses the form of the quadratic formula that avoids subtracting nearly equal numbers.

Calculations under this policy

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

sketch-intersect.js PartCraft 0.8.1 64-bit 7 calculations Reviewed

Tests tools/partcraft-test/test-sketch-tools.mjs

Point on a curve at a parameter

64-bit

The point of a line, circle, arc or spline at a given parameter.

line: a + (b − a)·t; circle: c + r·(cos t, sin t); arc: c + r·(cos(a₀ + t), sin(a₀ + t))

One 64-bit expression per coordinate. For a line at t = 1 the result is a + (b − a), which can differ from b in the last bit.

  • sketch-intersect.js
  • lines 35 to 41
  • PartCraft 0.8.1
/** Point at parameter t. */
export function pointOn(cv, t) {
  if (cv.kind === 'line') return [cv.a[0] + (cv.b[0] - cv.a[0]) * t, cv.a[1] + (cv.b[1] - cv.a[1]) * t];
  if (cv.kind === 'circle') return [cv.c[0] + cv.r * Math.cos(t), cv.c[1] + cv.r * Math.sin(t)];
  if (cv.kind === 'arc') return [cv.c[0] + cv.r * Math.cos(cv.a0 + t), cv.c[1] + cv.r * Math.sin(cv.a0 + t)];
  return SP.pointAt(cv.bs, t);
}

Parameter of the nearest point

64-bit

The parameter on a curve of the point nearest a given point.

line: t = ((p − a) · d) ÷ (d · d); circle and arc: angle = atan2(p_y − c_y, p_x − c_x)

The line case is one projection; a zero-length line divides by 1e-300 in place of zero. Angles come from atan2 and are brought into [0, 2π) with a remainder.

  • sketch-intersect.js
  • lines 63 to 72
  • PartCraft 0.8.1
/** Parameter of the nearest point (lines unclamped; arcs: angle past a0 in [0, 2π)). */
export function paramOf(cv, p) {
  if (cv.kind === 'line') {
    const dx = cv.b[0] - cv.a[0], dy = cv.b[1] - cv.a[1];
    return ((p[0] - cv.a[0]) * dx + (p[1] - cv.a[1]) * dy) / (dx * dx + dy * dy || 1e-300);
  }
  if (cv.kind === 'circle') return normAng(Math.atan2(p[1] - cv.c[1], p[0] - cv.c[0]));
  if (cv.kind === 'arc') return normAng(Math.atan2(p[1] - cv.c[1], p[0] - cv.c[0]) - cv.a0);
  return SP.closestParam(cv.bs, p);
}

Line and line

64-bit

The parameters at which two lines cross.

den = d₁ × d₂; tₐ = (w × d₂) ÷ den; t_b = (w × d₁) ÷ den, w = B.a − A.a

The lines are treated as parallel when |den| ≤ 1e-14 × |d₁| × |d₂|, a test relative to both lengths. For nearly parallel lines den is a small difference of products and the parameters lose digits in proportion.

  • sketch-intersect.js
  • lines 83 to 90
  • PartCraft 0.8.1
function lineLine(A, B) {
  const d1 = [A.b[0] - A.a[0], A.b[1] - A.a[1]], d2 = [B.b[0] - B.a[0], B.b[1] - B.a[1]];
  const den = d1[0] * d2[1] - d1[1] * d2[0];
  if (Math.abs(den) <= 1e-14 * Math.hypot(...d1) * Math.hypot(...d2)) return [];
  const w = [B.a[0] - A.a[0], B.a[1] - A.a[1]];
  const ta = (w[0] * d2[1] - w[1] * d2[0]) / den, tb = (w[0] * d1[1] - w[1] * d1[0]) / den;
  return [{ ta, tb }];
}

Line and circle

64-bit

The parameters along a line at which it crosses a circle.

A·t² + B·t + C = 0; q = −½·(B + sign(B)·√(B² − 4AC)); t₁ = q ÷ A, t₂ = C ÷ q

Both roots are computed without subtracting nearly equal numbers (the q form). The discriminant is compared with 1e-12 × (B² + |4AC|): within that band the line is tangent and one root −B ÷ 2A is returned. C = |a − c|² − r² itself cancels when the line starts close to the circle. A is not checked for zero, so a zero-length line gives 0 ÷ 0.

  • sketch-intersect.js
  • lines 92 to 105
  • PartCraft 0.8.1
/** Line a + t·d against a circle (c, r): parameters t. */
function lineCircleT(a, b, c, r) {
  const d = [b[0] - a[0], b[1] - a[1]], f = [a[0] - c[0], a[1] - c[1]];
  const A = d[0] * d[0] + d[1] * d[1], Bq = 2 * (f[0] * d[0] + f[1] * d[1]), C = f[0] * f[0] + f[1] * f[1] - r * r;
  const disc = Bq * Bq - 4 * A * C;
  const scale = Bq * Bq + Math.abs(4 * A * C);
  if (disc < -1e-12 * scale) return [];
  if (disc <= 1e-12 * scale) return [-Bq / (2 * A)];
  const s = Math.sqrt(disc);
  // stable roots
  const q = -0.5 * (Bq + Math.sign(Bq || 1) * s);
  const t1 = q / A, t2 = C / q;
  return t1 < t2 ? [t1, t2] : [t2, t1];
}

Circle and circle

64-bit

The points where two circles cross.

a = (r₁² − r₂² + d²) ÷ 2d; h = √(r₁² − a²); points = c₁ + a·û ± h·û⊥

h² = r₁² − a² subtracts nearly equal numbers when the circles are close to tangent, so the two points lose digits there; within 1e-12 × r₁² of zero one tangent point is returned. Circles with centers closer than 1e-14 × (r₁ + r₂) give no points.

  • sketch-intersect.js
  • lines 107 to 117
  • PartCraft 0.8.1
function circleCircleP(c1, r1, c2, r2) {
  const dx = c2[0] - c1[0], dy = c2[1] - c1[1], d = Math.hypot(dx, dy);
  if (d <= 1e-14 * (r1 + r2)) return [];
  const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
  const h2 = r1 * r1 - a * a;
  const m = [c1[0] + a * dx / d, c1[1] + a * dy / d];
  if (h2 < -1e-12 * r1 * r1) return [];
  if (h2 <= 1e-12 * r1 * r1) return [m];
  const h = Math.sqrt(h2);
  return [[m[0] - h * dy / d, m[1] + h * dx / d], [m[0] + h * dy / d, m[1] - h * dx / d]];
}

Is a crossing on the bounded curve

64-bit

Decides whether a crossing lies on the drawn part of a line or arc, and snaps it to the end when it is within the tolerance.

line: −eps ÷ L ≤ t ≤ 1 + eps ÷ L; arc: angle ≤ sweep + eps ÷ r

The length tolerance is converted to a parameter by dividing by the line's length or the arc's radius. A parameter just outside the range is clamped to the end, so the point returned is the end point itself.

  • sketch-intersect.js
  • lines 198 to 212
  • PartCraft 0.8.1
/** Is parameter t on the curve (bounded), allowing eps in length units? Returns the parameter snapped into range, or null. */
function within(cv, t, epsLen) {
  if (cv.kind === 'line') {
    const L = Math.hypot(cv.b[0] - cv.a[0], cv.b[1] - cv.a[1]) || 1e-300, e = epsLen / L;
    return t >= -e && t <= 1 + e ? Math.max(0, Math.min(1, t)) : null;
  }
  if (cv.kind === 'circle') return normAng(t);
  if (cv.kind === 'arc') {
    const e = epsLen / cv.r, a = normAng(t);
    if (a <= cv.sweep + e) return Math.min(a, cv.sweep);
    if (TAU - a <= e) return 0;
    return null;
  }
  return t;
}

Crossing points of two curves

64-bit

All crossing points of two sketch curves, sorted along the first.

eps = 1e-9 × max(1, size of A, size of B); duplicates within 10 × eps removed

The tolerance scales with the larger curve, with a floor of 1e-9. Each point is recomputed on curve A at its parameter, so it lies on A to rounding; its distance from B is then whatever the closed form left.

  • sketch-intersect.js
  • lines 214 to 243
  • PartCraft 0.8.1
/**
 * Crossing points of curves A and B → [{ p, ta, tb }] (sorted along A). `infA` / `infB`: a line is
 * infinite, an arc its full circle (parameters then run outside [0, 1] / past the sweep, angles in [0, 2π)).
 */
export function intersect(A, B, { infA = false, infB = false } = {}) {
  if (A.kind === 'spline' && B.kind !== 'spline') return intersect(B, A, { infA: infB, infB: infA }).map((x) => ({ p: x.p, ta: x.tb, tb: x.ta })).sort((x, y) => x.ta - y.ta);
  const scale = Math.max(1, sizeOf(A), sizeOf(B));
  const eps = 1e-9 * scale;
  let raw = [];   // {ta, tb} unbounded
  if (A.kind === 'line' && B.kind === 'line') raw = lineLine(A, B);
  else if (A.kind === 'line' && round(B)) raw = lineCircleT(A.a, A.b, B.c, B.r).map((t) => { const p = pointOn(A, t); return { ta: t, tb: paramOf(B, p) }; });
  else if (round(A) && B.kind === 'line') raw = lineCircleT(B.a, B.b, A.c, A.r).map((t) => { const p = pointOn(B, t); return { ta: paramOf(A, p), tb: t }; });
  else if (round(A) && round(B)) raw = circleCircleP(A.c, A.r, B.c, B.r).map((p) => ({ ta: paramOf(A, p), tb: paramOf(B, p) }));
  else if (B.kind === 'spline' && A.kind === 'line') {
    const d = [A.b[0] - A.a[0], A.b[1] - A.a[1]], l = Math.hypot(...d) || 1e-300;
    raw = splineVsImplicit(B, (q) => (d[0] * (q[1] - A.a[1]) - d[1] * (q[0] - A.a[0])) / l, eps).map((u) => ({ ta: paramOf(A, SP.pointAt(B.bs, u)), tb: u }));
  } else if (B.kind === 'spline' && round(A)) {
    raw = splineVsImplicit(B, (q) => Math.hypot(q[0] - A.c[0], q[1] - A.c[1]) - A.r, eps).map((u) => ({ ta: paramOf(A, SP.pointAt(B.bs, u)), tb: u }));
  } else raw = splineSpline(A, B, scale);
  const out = [];
  for (const x of raw) {
    const ta = infA ? (A.kind === 'arc' ? normAng(x.ta) : x.ta) : within(A, x.ta, eps);
    const tb = infB ? (B.kind === 'arc' ? normAng(x.tb) : x.tb) : within(B, x.tb, eps);
    if (ta === null || tb === null) continue;
    const p = pointOn(A, ta);
    if (out.some((y) => Math.hypot(y.p[0] - p[0], y.p[1] - p[1]) <= 10 * eps)) continue;
    out.push({ p, ta, tb });
  }
  return out.sort((x, y) => x.ta - y.ta);
}

In detail

Method
Cramer's rule for two lines; quadratic with q = −½(B + sign(B)·√disc), t₁ = q ÷ A, t₂ = C ÷ q for a line and a circle; radical line construction for two circles.
Number types
64-bit numbers in plain arrays.
Tolerances
Parallel lines: |d₁ × d₂| ≤ 1e-14 × |d₁| × |d₂|. Line and circle: tangent when |disc| ≤ 1e-12 × (B² + |4AC|), no crossing below −1e-12 of that. Two circles: concentric when the center distance ≤ 1e-14 × (r₁ + r₂); tangent when |h²| ≤ 1e-12 × r₁². A crossing counts as on a bounded curve within eps = 1e-9 × max(1, size of the two curves) in length; crossings closer than 10 × eps are one.
Limits
Near tangency the points lose digits: the half chord is the square root of a small difference of squares. Overlapping lines and equal concentric circles give no points.

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