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.jsCadCraft 0.8.264-bit5 calculationsReviewed
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.