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.
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];
}
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.