Changes sketch geometry with the editing tools: new end points for trimmed and extended curves, offset copies, mirrored and patterned copies, polygons, slots and projected model edges.
How the precision is kept
New coordinates are closed-form 64-bit results: intersection points from sketch-intersect.js, an offset along the unit normal, a reflection, a rotation. Pattern copies are each computed from the original with the step multiplied by the copy number, so rounding does not build up along the pattern. Decisions about which ends coincide use a tolerance of 1e-4 × max(1, sketch extent).
Open item. When projecting a circular edge, tell a closed circle from a short arc by the edge record instead of by the distance between its end points.
Calculations under this policy
11 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.
Scaled to the largest coordinate in the sketch with a floor of 1e-7. Every tool in this file multiplies it by 1000 before use, which gives 1e-4 × max(1, extent).
Where a curve is cut by every other curve of the sketch, for trim.
crossings within tol of the curve's own ends, or of one already found, are left out; tol = 1e-4 × max(1, extent)
The crossing points are those of sketch-intersect.js, unchanged. A real crossing closer than the tolerance to an end of the curve is not offered as a cut.
sketch-tools.js
lines 72 to 89
PartCraft 0.8.1
/** Crossings of curve e with every other curve: [{ t, p, by }] sorted by t (its own end points left out). */
export function crossings(sk, e) {
const cv = curveOf(sk, e), tol = 1e3 * tolOf(sk);
const r = rangeOf(cv);
const ends = r.periodic ? [] : [pointOn(cv, r.lo), pointOn(cv, r.hi)];
const out = [];
for (const o of sk.entities) {
if (o.id === e.id || o.type === 'point') continue;
let co;
try { co = curveOf(sk, o); } catch { continue; }
for (const x of intersect(cv, co)) {
if (ends.some((q) => dist(q, x.p) <= tol)) continue;
if (out.some((y) => dist(y.p, x.p) <= tol)) continue;
out.push({ t: x.ta, p: x.p, by: o });
}
}
return { cv, cuts: out.sort((a, b) => a.t - b.t), range: r };
}
Extending a line or arc
64-bit
Moves the picked end of a line or arc to the nearest curve it meets when continued.
line: gap = (t − 1) × L or −t × L; arc: gap = (t − sweep) × r or (2π − t) × r; nearest gap > tol wins
The end point becomes the intersection point itself. The gap along the curve is only used to choose between candidates; candidates closer than the tolerance are skipped.
sketch-tools.js
lines 222 to 252
PartCraft 0.8.1
/** Extend the end of line / arc `id` nearest `at` to the next curve it meets. → { made, dropped }. */
export function extend(sk, id, at) {
const e = S.byId(sk, id);
if (!e || (e.type !== 'line' && e.type !== 'arc')) throw new Error(tr('model.sketch_tools.extend_pick'));
const before = snapshot(sk);
const cv = curveOf(sk, e), tol = 1e3 * tolOf(sk);
const key = e.type === 'line' ? (dist(at, S.pointXY(sk, e.p1)) < dist(at, S.pointXY(sk, e.p2)) ? 'p1' : 'p2') : (dist(at, S.pointXY(sk, e.s)) < dist(at, S.pointXY(sk, e.e)) ? 's' : 'e');
let best = null;
for (const o of sk.entities) {
if (o.id === id || o.type === 'point') continue;
let co;
try { co = curveOf(sk, o); } catch { continue; }
for (const x of intersect(cv, co, { infA: true })) {
// how far past the end, along the curve
let gap;
if (e.type === 'line') { const L = dist(cv.a, cv.b); gap = key === 'p2' ? (x.ta - 1) * L : -x.ta * L; }
else gap = key === 'e' ? (x.ta - cv.sweep) * cv.r : (2 * Math.PI - x.ta) * cv.r;
if (e.type === 'arc' && x.ta <= cv.sweep + 1e-12) continue;
if (!(gap > tol)) continue;
if (!best || gap < best.gap) best = { gap, p: x.p, by: o };
}
}
if (!best) throw new Error(tr('model.sketch_tools.extend_nothing'));
const pid = ownPoint(sk, e, key);
dropOnPoint(sk, pid);
if (e.type === 'line') filterCurve(sk, id, (c) => lineKeeps(sk, c));
else filterCurve(sk, id, roundKeeps);
setPt(sk, pid, best.p);
tieTo(sk, pid, best.by);
return { made: [id], dropped: droppedSince(sk, before) };
}
Offset of each curve
64-bit
The offset copy of each line, circle, arc or spline of a chain, before the corners are joined.
line: S = a + d·n, E = b + d·n, n = (−Δy, Δx) ÷ L; circle: r − d; arc: r ∓ d, ends c + r·(cos, sin)
A line's normal divides by its length, which is not checked for zero here. Arc ends are recomputed from the arc's angles with the new radius. A radius that would fall to the tolerance or below is refused. Splines go to offsetFit with a tolerance of 0.001 mm.
sketch-tools.js
lines 323 to 349
PartCraft 0.8.1
const els = chain.items.map(({ id, fwd }) => {
const e = S.byId(sk, id);
if (e.type === 'line') {
let a = S.pointXY(sk, e.p1), b = S.pointXY(sk, e.p2);
if (!fwd) [a, b] = [b, a];
const l = dist(a, b), n = [-(b[1] - a[1]) / l, (b[0] - a[0]) / l];
return { e, kind: 'line', S: [a[0] + d * n[0], a[1] + d * n[1]], E: [b[0] + d * n[0], b[1] + d * n[1]], dir: [b[0] - a[0], b[1] - a[1]] };
}
if (e.type === 'circle') {
const r = e.r - d;
if (!(r > tol)) throw new Error(tr('model.sketch_tools.offset_circle'));
return { e, kind: 'circle', c: S.pointXY(sk, e.c), r };
}
if (e.type === 'arc') {
const { c, r: r0, a0, sweep } = S.arcAngles(sk, e);
const r = fwd ? r0 - d : r0 + d;
if (!(r > tol)) throw new Error(tr('model.sketch_tools.offset_arc'));
const at = (ang) => [c[0] + r * Math.cos(ang), c[1] + r * Math.sin(ang)];
return { e, kind: 'arc', c, r, ccw: fwd, S: at(fwd ? a0 : a0 + sweep), E: at(fwd ? a0 + sweep : a0), sweep0: sweep };
}
// spline
let bs = S.splineCurve(sk, e);
if (e.closed) { const f = SP.offsetFit(bs, d, OFFSET_TOL); return { e, kind: 'cspline', Q: f.Q, err: f.err }; }
if (!fwd) bs = SP.reverse(bs);
const f = SP.offsetFit(bs, d, OFFSET_TOL);
return { e, kind: 'spline', Q: f.Q, a0: f.a0, a1: f.a1, S: f.Q[0], E: f.Q[f.Q.length - 1], err: f.err };
});
Corners of an offset chain
64-bit
Joins neighboring offset curves at the point where they meet when extended.
corner = the intersection of the two full lines or circles nearest the midpoint of the gap
Ends already within the tolerance are simply given the same point. Otherwise the corner is an intersection from sketch-intersect.js with both curves taken as unbounded; a gap next to a spline cannot be closed this way and is refused.
sketch-tools.js
lines 355 to 364
PartCraft 0.8.1
for (let i = 0; i < (open ? n - 1 : n); i++) {
const A = els[i], B = els[(i + 1) % n];
if (dist(A.E, B.S) <= tol) { B.S = A.E; continue; }
if (A.kind === 'spline' || B.kind === 'spline') throw new Error(tr('model.sketch_tools.offset_spline_joins'));
const mid = [(A.E[0] + B.S[0]) / 2, (A.E[1] + B.S[1]) / 2];
const xs = intersect(full(A), full(B), { infA: true, infB: true });
if (!xs.length) throw new Error(tr('model.sketch_tools.offset_corner', { x: +mid[0].toFixed(3), y: +mid[1].toFixed(3) }));
const X = xs.reduce((m, x) => (dist(x.p, mid) < dist(m.p, mid) ? x : m)).p;
A.E = X; B.S = X;
}
Mirror about a line
64-bit
Reflects points about a sketch line, and reflects a spline's end angles.
t = (p − a) · u; q = a + t·u; p′ = 2q − p; angle′ = 2φ − angle
The reflection goes through the foot of the perpendicular in 64-bit. A point within the tolerance of the axis is not copied but reused. The axis direction divides by its length, which is checked to be greater than zero.
sketch-tools.js
lines 455 to 481
PartCraft 0.8.1
/** Mirror entities about line `axis`; mirror copies are tied by symmetric relations. → { made, dropped: 0, relations }. */
export function mirror(sk, ids, axis) {
const L = S.byId(sk, axis);
if (!L || L.type !== 'line') throw new Error(tr('model.sketch_tools.mirror_line'));
const a = S.pointXY(sk, L.p1), b = S.pointXY(sk, L.p2);
const l = dist(a, b);
if (!(l > 0)) throw new Error(tr('model.sketch_tools.mirror_no_length'));
const u = [(b[0] - a[0]) / l, (b[1] - a[1]) / l];
const refl = ([x, y]) => { const t = (x - a[0]) * u[0] + (y - a[1]) * u[1], q = [a[0] + t * u[0], a[1] + t * u[1]]; return [2 * q[0] - x, 2 * q[1] - y]; };
const phi = Math.atan2(u[1], u[0]);
const tol = 1e3 * tolOf(sk);
const onAxis = (pid) => { const p = S.pointXY(sk, pid); return Math.abs((p[0] - a[0]) * u[1] - (p[1] - a[1]) * u[0]) <= tol; };
const sel = ids.filter((id) => id !== axis && id !== L.p1 && id !== L.p2 && id !== S.ORIGIN);
if (!sel.length) throw new Error(tr('model.sketch_tools.mirror_select'));
const { map, made } = copyEntities(sk, sel, refl, { flip: true, ang: (t) => 2 * phi - t, same: onAxis });
let relations = 0;
for (const [o, n] of map) {
const e = S.byId(sk, o);
if (!e || o === S.ORIGIN) continue;
if (e.type === 'point') {
if (o === n) {
if (!sk.constraints.some((c) => c.type === 'coincident' && c.refs.includes(o) && c.refs.includes(axis))) { try { S.addConstraint(sk, 'coincident', [o, axis]); relations++; } catch { /* */ } }
} else { S.addConstraint(sk, 'symmetric', [o, n, axis]); relations++; }
} else if (e.type === 'circle') { S.addConstraint(sk, 'equal', [o, n]); relations++; }
}
return { made, dropped: 0, relations };
}
Linear pattern
64-bit
Copies geometry a number of times along a step.
copy i: (x + Δx × i, y + Δy × i)
Each copy is the original plus the step times its number, not the previous copy plus the step, so the rounding of one copy does not carry into the next.
sketch-tools.js
lines 486 to 497
PartCraft 0.8.1
/** `count` copies (count ≥ 2 including the original) stepped by [dx, dy]. One-shot. */
export function patternLinear(sk, ids, [dx, dy], count) {
if (!(count >= 2) || count > 200 || !Number.isInteger(count)) throw new Error(tr('model.sketch_tools.pattern_count'));
const made = [];
for (let i = 1; i < count; i++) {
const f = ([x, y]) => [x + dx * i, y + dy * i];
const r = copyEntities(sk, ids, f);
copyRelations(sk, r.map, TRANSLATION_KEEPS, f);
made.push(...r.made);
}
return { made, dropped: 0 };
}
Circular pattern
64-bit
Copies geometry a number of times around a center.
step = (360 ÷ count, or angle ÷ (count − 1)) × π ÷ 180; copy i: rotate about c by step × i
Each copy is the original rotated by step × i with one cos and one sin, so errors do not build up from copy to copy. The step is converted to radians with Math.PI.
sketch-tools.js
lines 499 to 513
PartCraft 0.8.1
/** `count` copies round centre c over `angle` degrees (360 = evenly round). One-shot. */
export function patternCircular(sk, ids, c, count, angle = 360) {
if (!(count >= 2) || count > 200 || !Number.isInteger(count)) throw new Error(tr('model.sketch_tools.pattern_count'));
if (!(angle > 0 && angle <= 360)) throw new Error(tr('model.sketch_tools.pattern_angle'));
const step = (angle >= 360 ? 360 / count : angle / (count - 1)) * Math.PI / 180;
const made = [];
for (let i = 1; i < count; i++) {
const th = step * i, co = Math.cos(th), si = Math.sin(th);
const f = ([x, y]) => [c[0] + co * (x - c[0]) - si * (y - c[1]), c[1] + si * (x - c[0]) + co * (y - c[1])];
const r = copyEntities(sk, ids, f, { ang: (t) => t + th });
copyRelations(sk, r.map, ROTATION_KEEPS, f);
made.push(...r.made);
}
return { made, dropped: 0 };
}
Regular polygon
64-bit
The corners of a regular polygon from its center, one corner and the number of sides.
cornerᵢ = c + r × (cos(a₀ + 2π·i ÷ n), sin(a₀ + 2π·i ÷ n))
The first corner is recomputed through atan2, cos and sin, so it can differ from the clicked corner in the last bits. Equal-length and on-circle relations are added, and the solver then holds the shape.
sketch-tools.js
lines 517 to 534
PartCraft 0.8.1
/** Regular polygon: centre c, first vertex v, n sides, inscribed in a construction circle. */
export function addPolygon(sk, c, v, n) {
if (!Number.isInteger(n) || n < 3 || n > 64) throw new Error(tr('model.sketch_tools.polygon_sides'));
const r = dist(c, v);
if (!(r > 0)) throw new Error(tr('model.sketch_tools.polygon_size'));
const a0 = Math.atan2(v[1] - c[1], v[0] - c[0]);
const circ = S.addCircle(sk, c, r, { construction: true });
const corners = [];
for (let i = 0; i < n; i++) corners.push([c[0] + r * Math.cos(a0 + 2 * Math.PI * i / n), c[1] + r * Math.sin(a0 + 2 * Math.PI * i / n)]);
const lines = [];
for (let i = 0; i < n; i++) lines.push(S.addLine(sk, corners[i], corners[(i + 1) % n]));
for (let i = 0; i < n; i++) {
S.addConstraint(sk, 'coincident', [lines[i].p2, lines[(i + 1) % n].p1]);
S.addConstraint(sk, 'coincident', [lines[i].p1, circ.id]);
if (i) S.addConstraint(sk, 'equal', [lines[0].id, lines[i].id]);
}
return { circle: circ.id, lines: lines.map((x) => x.id), made: [circ.id, ...lines.map((x) => x.id)] };
}
Straight slot
64-bit
The four corner points of a slot from its two centers and its width.
r = w ÷ 2; corners = center ± n × r, n = unit normal of the center line
The halving is exact and each corner is one product and one addition per coordinate. Zero length and zero width are refused.
sketch-tools.js
lines 536 to 552
PartCraft 0.8.1
/** Straight slot between centres a and b, width w. */
export function addSlot(sk, a, b, w) {
const l = dist(a, b), r = w / 2;
if (!(l > 0)) throw new Error(tr('model.sketch_tools.slot_centres'));
if (!(r > 0)) throw new Error(tr('model.sketch_tools.slot_width'));
const u = [(b[0] - a[0]) / l, (b[1] - a[1]) / l], n = [-u[1], u[0]];
const P = (c, s) => [c[0] + n[0] * r * s, c[1] + n[1] * r * s];
const ca = S.addPoint(sk, ...a), cb = S.addPoint(sk, ...b);
const p1 = S.addPoint(sk, ...P(a, 1)), p2 = S.addPoint(sk, ...P(b, 1)), p3 = S.addPoint(sk, ...P(b, -1)), p4 = S.addPoint(sk, ...P(a, -1));
const nid = (k) => k + (sk.seq++);
const arcB = { id: nid('a'), type: 'arc', c: cb, s: p3, e: p2 }, arcA = { id: nid('a'), type: 'arc', c: ca, s: p1, e: p4 };
const top = { id: nid('l'), type: 'line', p1: p2, p2: p1 }, bot = { id: nid('l'), type: 'line', p1: p4, p2: p3 };
sk.entities.push(arcB, arcA, top, bot);
for (const [L, A] of [[top, arcA], [top, arcB], [bot, arcA], [bot, arcB]]) S.addConstraint(sk, 'tangent', [L.id, A.id]);
S.addConstraint(sk, 'equal', [arcA.id, arcB.id]);
return { made: [arcA.id, arcB.id, top.id, bot.id], centres: [ca, cb], arcs: [arcA.id, arcB.id] };
}
Projecting a model edge
64-bit
Puts a straight or circular model edge onto the sketch plane as fixed geometry.
(u, v) = ((p − o) · x, (p − o) · y)
Points are projected with two 64-bit dot products and a circle keeps the kernel's radius. A circular edge whose two end points land within the tolerance of each other is added as a full circle, so a very short arc is not told apart from a closed circle. A circle not parallel to the sketch plane within 1e-9 is refused.
sketch-tools.js
lines 556 to 586
PartCraft 0.8.1
/**
* Project an exact-kernel edge record (regen.edges: type line|circle|curve, end points a / b, circle centre / axis /
* radius `circ`) onto the sketch plane `frame` {o, x, y, n} as fixed geometry (a Fix relation; it does not follow
* later model changes). Lines exactly; circles and arcs exactly when their plane is parallel to the sketch.
*/
export function projectEdge(sk, frame, edge) {
if (!edge) throw new Error(tr('model.sketch_tools.project_pick'));
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
const loc = (p) => { const d = [p[0] - frame.o[0], p[1] - frame.o[1], p[2] - frame.o[2]]; return [dot(d, frame.x), dot(d, frame.y)]; };
const tol = 1e3 * tolOf(sk);
if (edge.type === 'line') {
const A = loc(edge.a), B = loc(edge.b);
if (dist(A, B) <= tol) throw new Error(tr('model.sketch_tools.project_point'));
const l = S.addLine(sk, A, B);
S.addConstraint(sk, 'fix', [l.id]);
return { made: [l.id], dropped: 0, kind: 'line' };
}
if (edge.type === 'circle' && edge.circ) {
const { c, n, r } = edge.circ;
const cn = dot(n, frame.n);
if (Math.abs(Math.abs(cn) - 1) > 1e-9) throw new Error(tr('model.sketch_tools.project_ellipse'));
const C = loc(c);
const A = loc(edge.a), B = loc(edge.b);
if (dist(A, B) <= tol) { const k = S.addCircle(sk, C, r); S.addConstraint(sk, 'fix', [k.id]); return { made: [k.id], dropped: 0, kind: 'circle' }; }
// OCCT's circle runs counter-clockwise about its own axis: the same way round in the sketch when the axes agree
const a = cn > 0 ? S.addArc(sk, C, A, B) : S.addArc(sk, C, B, A);
S.addConstraint(sk, 'fix', [a.id]);
return { made: [a.id], dropped: 0, kind: 'arc' };
}
throw new Error(tr('model.sketch_tools.project_freeform'));
}
In detail
Method
Offset of a line along its left unit normal; arcs and circles by changing the radius; corners by intersecting the two offset curves; reflection through the foot of the perpendicular; rotation by cos and sin of step × copy number.
Number types
64-bit numbers on the sketch's points and circles.
Tolerances
tolOf = 1e-7 × max(1, extent); the tools use 1000 × that (1e-4 × max(1, extent)) for coincident ends, repeated crossings, chain joins, zero-length projections and offset radii. An extended arc ignores crossings within 1e-12 rad past its end. Spline offsets are fitted within OFFSET_TOL = 0.001 mm. A projected circle must be parallel to the sketch within | |n · n_sketch| − 1 | ≤ 1e-9.
Limits
The coincidence tolerance grows with the sketch: 0.05 units for a sketch whose largest coordinate is 500. An offset of a chain with a spline is an approximation within 0.001 mm and is not kept parametric. Projection handles lines, and circles and arcs parallel to the sketch plane, only.
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.