Move, rotate, scale, mirror, array, stretch and lengthen
Builds the transform or the length change for the modify commands from the picked points and typed values.
How the precision is kept
Each command builds one 2D matrix of six 64-bit numbers and hands it to geom.js txEntity, or works out a length change and hands it to geom.js lengthenEntity. Rotations and scales about a base point are composed as translate × matrix × translate. An array makes every copy from the original with its own offset or angle, not from the previous copy.
Calculations under this policy
10 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.
Applies one transform to the selected objects, replacing them or adding copies.
Each object is passed with the matrix to geom.js txEntity and the result replaces the object or is added as a copy; nothing is rounded here. The object is read again from the drawing at the time of the change.
commands.js
lines 1426 to 1436
CadCraft 0.8.2
/** Move or copy objects through m, as one change. `after(pairs)` runs inside it, pairs = [[source id, result id]]. */
function apply(c, src, m, copy, after = null) {
c.doc.change(copy ? 'COPY' : 'MOVE', () => {
const pairs = [];
for (const e of src) {
const t = txEntity(c.doc.get(e.id) || e, m);
if (copy) { delete t.id; pairs.push([e.id, c.doc.add(t).id]); } else { c.doc.update(e.id, t); pairs.push([e.id, e.id]); }
}
after?.(pairs);
});
}
Displacement for MOVE and COPY
64-bit
The translation from a base point to a second point, or a typed displacement.
M = T(p − base)
The displacement is the picked point minus the base point, one subtraction per coordinate, or the typed point itself with the Displacement option. In COPY every copy uses its own picked point minus the same base point, so copies do not build up error.
commands.js
lines 1093 to 1112
CadCraft 0.8.2
async function moveLike(c, copy) {
const ids = await c.select();
if (!ids.size) return;
const src = [...ids].map((id) => c.doc.get(id)).filter(Boolean);
const b = await c.point(t('cmd.stretch.base'), { options: [opt('D', 'Displacement')] });
let base = b;
if (b.option === 'D') {
const d = await c.point(t('cmd.stretch.disp'));
apply(c, src, mTranslate(d.x, d.y), copy);
return;
}
for (;;) {
c.preview((cur) => src.map((e) => txEntity(e, mTranslate(cur.x - base.x, cur.y - base.y))));
const p = await c.point(t('cmd.mea.p2'), { base, none: true, options: copy ? [opt('U', 'Undo')] : [] });
if (!p) return;
if (p.option === 'U') { undoInside(c); continue; }
apply(c, src, mTranslate(p.x - base.x, p.y - base.y), copy);
if (!copy) return;
}
}
Rotate and scale about a base point
64-bit
The transform for ROTATE and SCALE.
M = T(base) · R(a) · T(−base); M = T(base) · S(s) · T(−base)
The angle is typed in degrees (converted with × π ÷ 180) or picked as atan2 from the base point; the scale factor is typed or picked as a distance. A typed 90° is π ÷ 2 as a 64-bit number, whose cosine in geom.js mRotate is about 6 × 10⁻¹⁷ rather than 0.
The transform that mirrors the selection across the line through two picked points.
M = T(a) · [cos 2θ, sin 2θ; sin 2θ, −cos 2θ] · T(−a), θ = atan2(b − a)
The matrix is built by geom.js mMirror from the angle of the mirror line. For a plumb line θ = π ÷ 2 and sin 2θ evaluates to about 1.2 × 10⁻¹⁶ rather than 0. Two points closer than 1e-12 drawing units (absolute) are refused.
commands.js
lines 1720 to 1727
CadCraft 0.8.2
def('MIRROR', ['MI'], async (c) => {
const ids = await c.select();
if (!ids.size) return;
const src = [...ids].map((id) => c.doc.get(id));
const a = await c.point(t('cmd.mirror.specify_first_point_mirror'));
c.preview((cur) => (dist(a, cur) > 1e-12 ? src.map((e) => txEntity(e, mMirror(a, cur))) : []));
const b = await c.point(t('cmd.mirror.specify_second_point_mirror'), { base: a });
if (dist(a, b) < 1e-12) return;
Copies of a rectangular or polar array
64-bit
The copies an array makes, in rows and columns or round a center.
rectangular: T(k·dx, r·dy); polar: step = fill ÷ items (full circle) or fill ÷ (items − 1); copy i = rotation by step × i about the center
Every copy is made from the original with its own offset k × dx, r × dy or its own angle step × i, so spacing error does not build up along the array. A fill angle within 1e-9 radians of a full turn is treated as a full circle. With rotation of items off, each copy is moved by the displacement of the selection's box middle under the rotation.
commands.js
lines 1463 to 1485
CadCraft 0.8.2
/** The copies an array makes (not the originals): rectangular {cols, rows, dx, dy} or polar {center, items, fill, rotate}. */
function arrayCopies(src, a, limit = 20000) {
const out = [];
const bounds = src.reduce((acc, e) => { const b = entityBounds(e, null); return validBounds(b) ? { minX: Math.min(acc.minX, b.minX), minY: Math.min(acc.minY, b.minY), maxX: Math.max(acc.maxX, b.maxX), maxY: Math.max(acc.maxY, b.maxY) } : acc; }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
if (a.kind === 'rect') {
for (let r = 0; r < a.rows; r++) for (let k = 0; k < a.cols; k++) {
if (!r && !k) continue;
const m = mTranslate(k * a.dx, r * a.dy);
for (const e of src) { out.push(txEntity(e, m)); if (out.length > limit) return null; }
}
} else {
const full = Math.abs(Math.abs(a.fill) - TAU) < 1e-9;
const step = full ? a.fill / a.items : a.fill / Math.max(1, a.items - 1);
const mid = validBounds(bounds) ? { x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2 } : a.center;
for (let i = 1; i < a.items; i++) {
const turn = mAbout(a.center, mRotate(step * i));
let m = turn;
if (!a.rotate) { const p = mApply(turn, mid); m = mTranslate(p.x - mid.x, p.y - mid.y); } // keep the objects upright
for (const e of src) { out.push(txEntity(e, m)); if (out.length > limit) return null; }
}
}
return out;
}
Which points a stretch moves
64-bit
Whether a point lies inside one of the crossing windows that define a stretch.
minX − 10⁻⁹ ≤ x ≤ maxX + 10⁻⁹ and minY − 10⁻⁹ ≤ y ≤ maxY + 10⁻⁹
A point is inside a rectangular window when it lies within the window grown by 1e-9 drawing units (absolute) on each side; for a lasso the test is lasso.js pointInPoly. Points inside move by the displacement, the second point minus the base point; geom.js stretchEntity builds the new object.
L is geom.js curveLength. The total option passes the difference total − L, so the new length is L plus that difference and can differ from the typed total in the last bit. In dynamic mode the cursor is projected on the end segment with one dot product divided by the segment's length; a zero-length segment divides by 1 instead.
commands.js
lines 1580 to 1604
CadCraft 0.8.2
const change = (e, pick, cur = null) => {
const L = curveLength(e);
if (L === null || (e.type === 'poly' && e.closed)) return { error: 'type' };
switch (S.mode) {
case 'DE': return lengthenEntity(e, pick, S.dAngle !== null && e.type === 'arc' ? { dAngle: S.dAngle } : { delta: S.delta });
case 'P': return lengthenEntity(e, pick, { delta: L * (S.percent / 100 - 1) });
case 'T': {
if (S.tAngle !== null && e.type === 'arc') return lengthenEntity(e, pick, { dAngle: S.tAngle - (normAngle(e.a1 - e.a0) || TAU) });
return lengthenEntity(e, pick, { delta: S.total - L });
}
default: { // DY: the end follows the pointer
if (!cur) return { error: 'type' };
if (e.type === 'arc') { const c0 = { x: e.cx, y: e.cy }, atStart = dist(pick, polar(c0, e.a0, e.r)) < dist(pick, polar(c0, e.a1, e.r)); const a = normAngle(angleOf(c0, cur)); return { ...e, ...(atStart ? { a0: a } : { a1: a }) }; }
let A, B; // the end segment's fixed end and the end that moves
if (e.type === 'line') { const atStart = dist(pick, { x: e.x1, y: e.y1 }) < dist(pick, { x: e.x2, y: e.y2 }); A = atStart ? { x: e.x2, y: e.y2 } : { x: e.x1, y: e.y1 }; B = atStart ? { x: e.x1, y: e.y1 } : { x: e.x2, y: e.y2 }; }
else {
const n = e.pts.length, atStart = dist(pick, e.pts[0]) < dist(pick, e.pts[n - 1]);
if (Math.abs((atStart ? e.pts[0].b : e.pts[n - 2].b) || 0) > 1e-12) return { error: 'type' };
A = atStart ? e.pts[1] : e.pts[n - 2]; B = atStart ? e.pts[0] : e.pts[n - 1];
}
const Ls = dist(A, B), along = ((cur.x - A.x) * (B.x - A.x) + (cur.y - A.y) * (B.y - A.y)) / (Ls || 1);
return lengthenEntity(e, pick, { delta: along - Ls });
}
}
};
Nearest position on a line or arc
64-bit
The position (0 at the start, 1 at the end) of the point on a line or arc nearest a given point.
line: t = clamp(((q − p₁)·(p₂ − p₁)) ÷ |p₂ − p₁|², 0, 1); arc: t = (angle from the start) ÷ sweep
For a line, one dot product over the squared length, clamped to 0…1; a line of zero length gives 0. For an arc the angle from the start is normalized to 0…2π and divided by the sweep, and a point outside the sweep goes to the nearer end. No tolerance is used, and an arc whose sweep is zero is not guarded.
commands.js
lines 1761 to 1765
CadCraft 0.8.2
function closestT(p, q) { // local import-free helper (geom.primParam without the arc rev flag)
if (p.k === 'L') { const dx = p.x2 - p.x1, dy = p.y2 - p.y1, L = dx * dx + dy * dy; return L ? Math.max(0, Math.min(1, ((q.x - p.x1) * dx + (q.y - p.y1) * dy) / L)) : 0; }
const a = normAngle(Math.atan2(q.y - p.cy, q.x - p.cx) - p.a0), s = p.a1 - p.a0;
return a <= s ? a / s : (a - s < TAU - a ? 1 : 0);
}
Point on an object nearest a pick
64-bit
Where a break point or a pick lands on a line, arc, circle or polyline.
For every primitive of the object the point at closestT is evaluated with geom.js primPoint and the nearest by Math.hypot is kept. A break point on an arc is therefore center + r·(cos, sin) of the interpolated angle.
commands.js
lines 1537 to 1542
CadCraft 0.8.2
/** The point on e nearest q (where a break or a pick lands). */
function onEntity(e, q) {
let best = null, bd = Infinity;
for (const p of prims(e)) { const x = primPoint(p, closestT(p, q)); const d = dist(x, q); if (d < bd) { bd = d; best = x; } }
return best || q;
}
Offset distance through a point
64-bit
The distance OFFSET uses: the typed one, or the distance from the object to a picked point.
through: d = min over the primitives of |p − nearest point on the primitive|
With the Through option the distance is the smallest Math.hypot distance from the picked point to the object's primitives; otherwise it is the typed distance unchanged. The offset curve itself is built by geom.js offsetEntity.
Translation by a difference of points; rotation and scale about a base point; mirror across a line; rectangular and polar arrays; nearest point on a line or arc by projection.
Number types
64-bit numbers.
Tolerances
1e-12 drawing units, absolute: a mirror line shorter than this is refused. 1e-9 drawing units, absolute: the window test in STRETCH. 1e-9 radians: a polar array's fill angle this close to a full turn is a full circle. A polyline end segment with |bulge| > 1e-12 is not lengthened dynamically.
Limits
A typed 90° is π ÷ 2 as a 64-bit number, whose cosine is about 6 × 10⁻¹⁷ rather than 0, so quarter-turn rotations and mirrors across a plumb line leave residues in the last digits. closestT does not guard an arc with zero sweep.
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.