Works out radii, arc angles, the current value of a dimension, sketch fillets and the size of a sketch.
How the precision is kept
A sketch stores 64-bit point coordinates and circle radii; an arc's radius and angles are derived from its three points each time they are needed. Distances use Math.hypot and angles use atan2 of a cross and a dot product, so no value passes through a rounded constant or a narrower type.
Calculations under this policy
9 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 radius of a circle (stored) or of an arc (the distance from its center to its start point).
r = |s − c|
A circle returns its stored 64-bit radius. An arc's radius is Math.hypot of two coordinate differences, computed afresh each time.
sketch.js
lines 70 to 75
PartCraft 0.8.1
/** Radius of a circle (stored) or an arc (|s − c|). */
export function radiusOf(sk, e) {
if (e.type === 'circle') return e.r;
const c = pointXY(sk, e.c), s = pointXY(sk, e.s);
return Math.hypot(s[0] - c[0], s[1] - c[1]);
}
Arc angles
64-bit
The center, radius, start angle and sweep of an arc from its three points.
a₀ = atan2(s − c); sweep = atan2(e − c) − a₀, brought to (1e-12, 2π]
The sweep is a difference of two atan2 values with 2π added or subtracted until it is in range. A sweep of 1e-12 rad or less is taken as a full turn, so an arc whose end is on its start is a whole circle.
sketch.js
lines 141 to 149
PartCraft 0.8.1
export function arcAngles(sk, a) {
const c = pointXY(sk, a.c), s = pointXY(sk, a.s), e = pointXY(sk, a.e);
const a0 = Math.atan2(s[1] - c[1], s[0] - c[0]);
let a1 = Math.atan2(e[1] - c[1], e[0] - c[0]);
let sweep = a1 - a0;
while (sweep <= 1e-12) sweep += 2 * Math.PI;
while (sweep > 2 * Math.PI) sweep -= 2 * Math.PI;
return { c, r: Math.hypot(s[0] - c[0], s[1] - c[1]), a0, sweep };
}
New arc: end point on the radius
64-bit
Places a new arc's end point on the circle through its start point.
r = |s − c|; e = c + r × (cos a, sin a), a = atan2(e₀ − c)
The clicked end is replaced by the point at the same angle on the radius, computed through atan2, cos and sin. When the end is an existing point it is used as it is and a coincident relation is added instead.
sketch.js
lines 191 to 206
PartCraft 0.8.1
/** Counter-clockwise arc about c from s; e is put on the radius through it. */
export function addArc(sk, c, s, e, opts = {}) {
const made = [];
const cxy = typeof c === 'string' ? pointXY(sk, c) : c;
const sxy = typeof s === 'string' ? pointXY(sk, s) : s;
const r = Math.hypot(sxy[0] - cxy[0], sxy[1] - cxy[1]);
const exy0 = typeof e === 'string' ? pointXY(sk, e) : e;
const ang = Math.atan2(exy0[1] - cxy[1], exy0[0] - cxy[0]);
const exy = [cxy[0] + r * Math.cos(ang), cxy[1] + r * Math.sin(ang)];
const cid = endPoint(sk, c, made), sid = endPoint(sk, s, made);
const eid = typeof e === 'string' ? endPoint(sk, e, made) : addPoint(sk, exy[0], exy[1]);
const a = { id: nextId(sk, 'a'), type: 'arc', c: cid, s: sid, e: eid };
if (opts.construction) a.construction = true;
sk.entities.push(a);
return { id: a.id, c: cid, s: sid, e: eid, constraints: made };
}
Current value of a relation or dimension
64-bit
The value a new dimension starts with: a distance, a horizontal or vertical distance, a radius, a diameter, an offset or an angle, and which side it is on.
distance = |b − a|; point to line = |signed distance|; diameter = 2r; angle° = |atan2(u × v, u · v)| × 180 ÷ π
Every value is one 64-bit expression on the stored coordinates. The angle uses atan2 of the cross and dot products, which keeps its digits at small angles and near 180°, and is converted with Math.PI. The sign is kept apart from the value so that the dimension stays on its side.
sketch.js
lines 300 to 352
PartCraft 0.8.1
/** Current geometric value (and side) for a constraint type on refs (already normalised). */
export function measure(sk, type, refs, sig = matchSig(sk, type, refs)?.sig) {
const P = (id) => pointXY(sk, id);
const E = (id) => byId(sk, id);
const L = (id) => { const l = E(id); return [P(l.p1), P(l.p2)]; };
const sgn = (v) => (v < 0 ? -1 : 1);
switch (type) {
case 'fix': {
const e = E(refs[0]);
if (e.type === 'point') return { value: [e.x, e.y] };
if (e.type === 'line') return { value: [...P(e.p1), ...P(e.p2)] };
if (e.type === 'circle') return { value: [...P(e.c), e.r] };
if (e.type === 'spline') return { value: e.pts.flatMap((id) => P(id)) };
return { value: [...P(e.c), ...P(e.s), ...P(e.e)] };
}
case 'distance': {
if (sig === 'PP') { const a = P(refs[0]), b = P(refs[1]); return { value: Math.hypot(b[0] - a[0], b[1] - a[1]) }; }
if (sig === 'L') { const [a, b] = L(refs[0]); return { value: Math.hypot(b[0] - a[0], b[1] - a[1]) }; }
const p = sig === 'PL' ? P(refs[0]) : L(refs[1])[0];
const s = signedDist(p, L(sig === 'PL' ? refs[1] : refs[0]));
return { value: Math.abs(s), sign: sgn(s) };
}
case 'hdistance': case 'vdistance': {
const [a, b] = sig === 'L' ? L(refs[0]) : [P(refs[0]), P(refs[1])];
const d = type === 'hdistance' ? b[0] - a[0] : b[1] - a[1];
return { value: Math.abs(d), sign: sgn(d) };
}
case 'offset': case 'offsetlink': {
const s = sig === 'LL' ? signedDist(L(refs[1])[0], L(refs[0])) : radiusOf(sk, E(refs[1])) - radiusOf(sk, E(refs[0]));
return { value: Math.abs(s), sign: sgn(s) };
}
case 'radius': return { value: radiusOf(sk, E(refs[0])) };
case 'diameter': return { value: 2 * radiusOf(sk, E(refs[0])) };
case 'angle': {
const [a1, b1] = L(refs[0]), [a2, b2] = L(refs[1]);
const u = [b1[0] - a1[0], b1[1] - a1[1]], v = [b2[0] - a2[0], b2[1] - a2[1]];
const ang = Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]);
return { value: Math.abs(ang) * 180 / Math.PI, sign: sgn(ang) };
}
case 'tangent': {
if (sig.includes('S')) return {};
if (sig === 'LR') {
const r = E(refs[1]);
return { sign: sgn(signedDist(P(r.c), L(refs[0]))) };
}
const a = E(refs[0]), b = E(refs[1]);
const ca = P(a.c), cb = P(b.c), ra = radiusOf(sk, a), rb = radiusOf(sk, b);
const d = Math.hypot(cb[0] - ca[0], cb[1] - ca[1]);
return Math.abs(d - (ra + rb)) <= Math.abs(d - Math.abs(ra - rb)) ? { kind: 'ext' } : { kind: 'int', sign: sgn(ra - rb) };
}
default: return {};
}
}
Signed distance from a point to a line
64-bit
How far a point is from a line through two points, positive on the left.
s = (Δx·(p_y − a_y) − Δy·(p_x − a_x)) ÷ |b − a|
A cross product divided by the line's length. For a point very close to a long line the two products are nearly equal and their difference loses digits relative to the distance. A zero length is replaced by 1e-300.
sketch.js
lines 354 to 358
PartCraft 0.8.1
function signedDist(p, [a, b]) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const l = Math.hypot(dx, dy) || 1e-300;
return (dx * (p[1] - a[1]) - dy * (p[0] - a[0])) / l;
}
Nearly parallel test
64-bit
Decides whether two lines are close enough to parallel to take a distance instead of an angle.
|u × v| ÷ (|u| × |v|) < 1e-6
The sine of the angle between the lines, scale-free, compared with a fixed 1e-6.
sketch.js
lines 257 to 261
PartCraft 0.8.1
function nearlyParallel(sk, [l1, l2]) {
const u = lineVec(sk, l1), v = lineVec(sk, l2);
const c = (u[0] * v[1] - u[1] * v[0]) / ((Math.hypot(...u) * Math.hypot(...v)) || 1);
return Math.abs(c) < 1e-6;
}
Sketch fillet
64-bit
Trims two lines at a corner and adds a tangent arc of a given radius between them.
θ = acos(u₁ · u₂); d = r ÷ tan(θ ÷ 2); tangent points = P + uᵢ × d; center = P + bisector × r ÷ sin(θ ÷ 2)
The corner angle comes from acos of a dot product that is clamped to [−1, 1]; acos loses digits when the lines are nearly in line, and corners within 1e-6 rad of straight or folded are refused. The radius is then also stored as a driving dimension, so the solver holds it.
sketch.js
lines 431 to 478
PartCraft 0.8.1
/**
* Sketch fillet at the corner where two lines meet (pointId = one of the corner's end points):
* trims both lines, adds a tangent arc of radius r sharing their new end points, and a driving
* radius. Constraints on the corner points themselves (they no longer exist) are removed.
*/
export function filletCorner(sk, pointId, r) {
if (!(r > 0)) throw new Error(tr('model.sketch.fillet_radius_zero'));
const at = pointXY(sk, pointId);
if (!at) throw new Error(tr('model.sketch.fillet_pick_corner'));
const tol = 1e-6 * Math.max(1, extent(sk));
const ends = [];
for (const e of sk.entities) {
if (e.type !== 'line' || e.construction) continue;
for (const [mine, other] of [[e.p1, e.p2], [e.p2, e.p1]]) {
const q = pointXY(sk, mine);
if (Math.hypot(q[0] - at[0], q[1] - at[1]) <= tol) ends.push({ line: e, mine, other });
}
}
if (ends.length !== 2 || ends[0].line === ends[1].line) throw new Error(tr('model.sketch.fillet_two_lines'));
const P = at;
const [A, B] = ends;
const fa = pointXY(sk, A.other), fb = pointXY(sk, B.other);
const la = Math.hypot(fa[0] - P[0], fa[1] - P[1]), lb = Math.hypot(fb[0] - P[0], fb[1] - P[1]);
const u1 = [(fa[0] - P[0]) / la, (fa[1] - P[1]) / la], u2 = [(fb[0] - P[0]) / lb, (fb[1] - P[1]) / lb];
const cosT = Math.max(-1, Math.min(1, u1[0] * u2[0] + u1[1] * u2[1]));
const th = Math.acos(cosT);
if (th < 1e-6 || Math.PI - th < 1e-6) throw new Error(tr('model.sketch.fillet_in_line'));
const d = r / Math.tan(th / 2);
if (d >= la - tol || d >= lb - tol) throw new Error(tr('model.sketch.fillet_too_big'));
const t1 = [P[0] + u1[0] * d, P[1] + u1[1] * d], t2 = [P[0] + u2[0] * d, P[1] + u2[1] * d];
const bis = [u1[0] + u2[0], u1[1] + u2[1]], bl = Math.hypot(...bis);
const h = r / Math.sin(th / 2);
const c = [P[0] + bis[0] / bl * h, P[1] + bis[1] / bl * h];
let ida = A.mine, idb = B.mine;
if (ida === idb) { idb = addPoint(sk, P[0], P[1]); const l = B.line; if (l.p1 === ida) l.p1 = idb; else l.p2 = idb; }
// constraints on the corner points go with the corner
const removed = sk.constraints.filter((k) => k.refs.includes(ida) || k.refs.includes(idb));
sk.constraints = sk.constraints.filter((k) => !removed.includes(k));
setPt(sk, ida, t1); setPt(sk, idb, t2);
const cid = addPoint(sk, c[0], c[1]);
const ccw = (t1[0] - c[0]) * (t2[1] - c[1]) - (t1[1] - c[1]) * (t2[0] - c[0]) > 0;
const arc = { id: nextId(sk, 'a'), type: 'arc', c: cid, s: ccw ? ida : idb, e: ccw ? idb : ida };
sk.entities.push(arc);
addConstraint(sk, 'tangent', [A.line.id, arc.id]);
addConstraint(sk, 'tangent', [B.line.id, arc.id]);
addConstraint(sk, 'radius', [arc.id], r);
return { arc: arc.id, removed: removed.length };
}
Size of a sketch
64-bit
The largest coordinate size in a sketch, which the tolerances of the sketch code are scaled by.
extent = max(|x|, |y| over the points; |c_x| + r, |c_y| + r over the circles)
It measures distance from the sketch origin, not the width of the geometry, so a small sketch far from the origin gets tolerances scaled to its distance from the origin.
sketch.js
lines 482 to 489
PartCraft 0.8.1
export function extent(sk) {
let m = 0;
for (const e of sk.entities) {
if (e.type === 'point') m = Math.max(m, Math.abs(e.x), Math.abs(e.y));
if (e.type === 'circle') { const c = pointXY(sk, e.c); m = Math.max(m, Math.abs(c[0]) + e.r, Math.abs(c[1]) + e.r); }
}
return m;
}
Box of a sketch
64-bit
The bounding box of the sketch geometry.
Points and circles are exact (center ± radius). Arcs are taken from points sampled at 32 segments per full turn and splines at 8 per span, so the box can miss the extreme of an arc or spline that lies between samples.
sketch.js
lines 491 to 502
PartCraft 0.8.1
/** Bounding box of the sketch geometry {x0,y0,x1,y1} or null. */
export function bounds(sk) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
const inc = (x, y) => { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); };
for (const e of sk.entities) {
if (e.type === 'point') inc(e.x, e.y);
else if (e.type === 'circle') { const c = pointXY(sk, e.c); inc(c[0] - e.r, c[1] - e.r); inc(c[0] + e.r, c[1] + e.r); }
else if (e.type === 'arc') for (const p of tessArc(sk, e, 32)) inc(p[0], p[1]);
else if (e.type === 'spline') { try { for (const p of tessSpline(sk, e, 8)) inc(p[0], p[1]); } catch { /* points on top of each other */ } }
}
return x0 === Infinity ? null : { x0, y0, x1, y1 };
}
In detail
Method
Distance by Math.hypot; signed distance to a line by a cross product over its length; angle between lines by atan2(cross, dot); fillet by tangent length r ÷ tan(θ ÷ 2) and center distance r ÷ sin(θ ÷ 2) along the bisector.
Number types
64-bit numbers on the sketch's entities.
Tolerances
Arc sweep at or under 1e-12 rad counts as a full turn. Lines are nearly parallel when the sine of their angle is under 1e-6. Fillet: corner found within 1e-6 × max(1, extent); corners within 1e-6 rad of straight or folded are refused; the tangent length must leave more than the tolerance of each line. A zero-length line divides by 1e-300 in place of zero.
Limits
The box of a sketch takes arcs from 32 sampled segments per full turn and splines from 8 per span, so it can fall slightly short of the true extreme.
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.