Applies a move, rotation, scale or mirror to drawing objects, and places block references and viewports.
How the precision is kept
A transform is six 64-bit numbers [a, b, c, d, e, f] with x′ = a·x + c·y + e and y′ = b·x + d·y + f. Points go through the matrix once. Arc angles, text rotation and block rotation are derived again from the transformed geometry with atan2. A matrix counts as a uniform scale with rotation when its two columns agree in length and are perpendicular within 1e-9, relative.
Open item. Leave an arc's stored angles untouched when the transform is a plain move, and add the rotation to them directly when it is a rotation.
Calculations under this policy
8 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-bit8 calculationsReviewed
Tests bin/test-cadcraft.mjs
Composing two transforms
64-bit
The single transform that applies n first and then m.
Each of the six results is a sum of two or three products of 64-bit numbers. There is no tolerance and no re-normalization, so each composition adds its own rounding.
The transform that reflects across the line through two points.
a = atan2(q − p); M = [cos 2a, sin 2a, sin 2a, −cos 2a] about p
The reflection is built from the angle of the mirror line (atan2, then cos and sin of twice the angle), not from the line's direction components. The offset is formed by composing a move to the origin, the reflection and the move back, which subtracts products of the size of p's coordinates.
geom.js
lines 75 to 79
CadCraft 0.8.2
/** Mirror across the line through p and q. */
export function mMirror(p, q) {
const a = angleOf(p, q), c = Math.cos(2 * a), s = Math.sin(2 * a);
return mAbout(p, [c, s, s, -c, 0, 0]);
}
Scale and rotation of a transform
64-bit
The uniform scale factor, rotation and handedness of a matrix, or nothing when it shears or stretches.
The two tests are relative: |sx − sy| ≤ 1e-9 × max(sx, sy) and |a·c + b·d| ≤ 1e-9 × sx × sy. The scale returned is sx alone, so a matrix that passes with sx and sy differing by up to 1e-9 scales radii by sx.
geom.js
lines 81 to 87
CadCraft 0.8.2
/** Uniform scale factor and rotation of m, or null when it shears/stretches (arcs then become ellipses). */
export function mSimilar(m) {
const sx = Math.hypot(m[0], m[1]), sy = Math.hypot(m[2], m[3]);
const dot = m[0] * m[2] + m[1] * m[3];
if (Math.abs(sx - sy) > 1e-9 * Math.max(sx, sy) || Math.abs(dot) > 1e-9 * sx * sy) return null;
return { s: sx, rot: Math.atan2(m[1], m[0]), flip: mDet(m) < 0 };
}
Inverse transform
64-bit
The transform that undoes a given one.
det = a·d − b·c; inverse = [d, −b, −c, a, c·f − d·e, b·e − a·f] ÷ det
Every entry is divided by the determinant. Only a determinant of exactly 0 is refused; a nearly singular matrix is inverted with no warning, and its determinant is a difference of two products that can cancel.
geom.js
lines 348 to 353
CadCraft 0.8.2
export function mInverse(m) {
const d = mDet(m);
if (!d) return null;
const [a, b, c, e, f, g] = m;
return [e / d, -b / d, -c / d, a / d, (c * g - e * f) / d, (b * f - a * g) / d];
}
Block reference matrix
64-bit
The transform from a block's own coordinates to where a reference places it.
M = T(x, y) · R(rotation) · S(sx, sy) · T(−base)
Four matrices multiplied in order with mMul. The rotation uses cos and sin of the stored angle, so a reference turned 90° carries cos = 6.1 × 10⁻¹⁷ instead of 0 in its matrix.
The transform from model coordinates to paper coordinates for a layout viewport, at its scale and twist.
no twist: [s, 0, 0, s, x − cx·s, y − cy·s]; twisted: c = s·cos tw, n = s·sin tw, [c, n, −n, c, x − (c·cx − n·cy), y − (n·cx + c·cy)]
Without a twist the off-diagonal entries are exact zeros. Applying the matrix computes s·p plus an offset of the size of the view center times the scale, so a paper coordinate carries rounding in proportion to that size and not to the size of the sheet.
geom.js
lines 175 to 180
CadCraft 0.8.2
/** Model → paper for a viewport: its view centre (cx, cy) lands on its centre (x, y), at scale s, turned by its twist `tw` (radians, DR.8). */
export function viewportMatrix(vp) {
if (!vp.tw) return [vp.s, 0, 0, vp.s, vp.x - vp.cx * vp.s, vp.y - vp.cy * vp.s];
const c = Math.cos(vp.tw) * vp.s, n = Math.sin(vp.tw) * vp.s;
return [c, n, -n, c, vp.x - (c * vp.cx - n * vp.cy), vp.y - (n * vp.cx + c * vp.cy)];
}
Viewport view center
64-bit
Converts a viewport's view center between the file's display coordinates and the model point shown at the viewport's center.
x = tx + cos tw·dx + sin tw·dy; y = ty − sin tw·dx + cos tw·dy
With no twist the result is the target plus the stored center, one addition per coordinate. With a twist it is a rotation by cos and sin of the twist; the reverse function vpDcsCentre rotates back, and the two rotations are not exact inverses in 64-bit arithmetic.
geom.js
lines 182 to 193
CadCraft 0.8.2
/**
* A VIEWPORT's view centre is in display coordinates (DCS: the model turned by the twist, about the view target), not in the
* model's (DXF reference, group 12; ezdxf turns it back the same way). CAD-TWIST-SAVE 2026-10-24: CadCraft wrote and read
* the model point as is, so a twisted viewport came back right only in CadCraft — BimCraft, AutoCAD and ezdxf showed another
* area. `vpModelCentre`: the stored centre (+ target) → the model point at the viewport's centre; `vpDcsCentre`: the reverse.
*/
export function vpModelCentre(dc, t, tw) {
const dx = Number(dc?.x) || 0, dy = Number(dc?.y) || 0, tx = Number(t?.x) || 0, ty = Number(t?.y) || 0;
if (!tw) return { x: tx + dx, y: ty + dy };
const c = Math.cos(tw), s = Math.sin(tw);
return { x: tx + c * dx + s * dy, y: ty - s * dx + c * dy };
}
Transforming an object
64-bit
A copy of a drawing object put through a move, rotation, scale or mirror.
point: M·p; arc: angles = atan2(M·end − M·center); radius, text height, block scale × s
Line, point, polyline, hatch and spline coordinates go through the matrix once. An arc's two angles are recomputed for every transform, a plain move included: each end point is computed with cos and sin, transformed, and turned back into an angle with atan2 of the end minus the center. For an arc far from the origin that subtraction keeps fewer digits: moving an arc of radius 1 centered at (10⁶, 10⁶) by (0.1, 0.1) changes a start angle of 0.3 to 0.3000000000162566.
geom.js
lines 683 to 756
CadCraft 0.8.2
/** A new entity: e put through the affine m (similar transforms only, except inserts and text). */
export function txEntity(e, m) {
const P = (x, y) => mApply(m, { x, y });
const sim = mSimilar(m) || { s: Math.sqrt(Math.abs(mDet(m))), rot: Math.atan2(m[1], m[0]), flip: mDet(m) < 0 };
switch (e.type) {
case 'line': { const a = P(e.x1, e.y1), b = P(e.x2, e.y2); return { ...e, x1: a.x, y1: a.y, x2: b.x, y2: b.y }; }
case 'point': { const a = P(e.x, e.y); return { ...e, x: a.x, y: a.y }; }
case 'circle': { const c = P(e.cx, e.cy); return { ...e, cx: c.x, cy: c.y, r: e.r * sim.s }; }
case 'arc': {
const c = P(e.cx, e.cy);
const s = P(e.cx + e.r * Math.cos(e.a0), e.cy + e.r * Math.sin(e.a0)), t = P(e.cx + e.r * Math.cos(e.a1), e.cy + e.r * Math.sin(e.a1));
let a0 = Math.atan2(s.y - c.y, s.x - c.x), a1 = Math.atan2(t.y - c.y, t.x - c.x);
if (sim.flip) [a0, a1] = [a1, a0];
return { ...e, cx: c.x, cy: c.y, r: e.r * sim.s, a0: normAngle(a0), a1: normAngle(a1) };
}
case 'poly': return { ...e, pts: e.pts.map((p) => ({ ...P(p.x, p.y), b: sim.flip ? -(p.b || 0) : (p.b || 0) })) };
case 'ellipse': {
const c = P(e.cx, e.cy), a = P(e.cx + e.mx, e.cy + e.my);
let t0 = e.t0, t1 = e.t1;
if (sim.flip && t1 - t0 < TAU - EPS) [t0, t1] = [TAU - t1, TAU - t0];
return { ...e, cx: c.x, cy: c.y, mx: a.x - c.x, my: a.y - c.y, t0, t1 };
}
case 'spline': {
const Q = (p) => P(p.x, p.y);
const dir = (d) => (d ? { x: m[0] * d.x + m[2] * d.y, y: m[1] * d.x + m[3] * d.y } : d); // a direction: the matrix without its move
const out = { ...e, ctrl: e.ctrl.map(Q), ...(e.fit ? { fit: e.fit.map(Q) } : {}) };
if (e.st) out.st = dir(e.st);
if (e.et) out.et = dir(e.et);
return out;
}
case 'text': {
const a = P(e.x, e.y);
let rot = (e.rot || 0) + sim.rot;
if (sim.flip) {
// Mirrored text is placed mirrored but stays readable (MIRRTEXT 0): reflect the
// direction across the mirror line, then turn it round if it would read right to left.
rot = Math.atan2(m[1], m[0]) - (e.rot || 0);
if (Math.cos(rot) < -1e-9) rot += Math.PI;
}
return { ...e, x: a.x, y: a.y, h: e.h * sim.s, rot: normAngle(rot), ...(e.ww ? { ww: e.ww * sim.s } : {}) };
}
case 'insert': {
const a = P(e.x, e.y);
// a mirror m = R(α)·diag(1, −1) turns R(r)·S(sx, sy) into R(α − r)·S(sx, −sy) = R(α + π − r)·S(−sx, sy) (X scale negative,
// as AutoCAD keeps it); a plain rotation adds. (Fixed 2026-10-13, session addons: a mirror used to add α, landing 180° off.)
const rot = sim.flip ? sim.rot + Math.PI - (e.rot || 0) : (e.rot || 0) + sim.rot;
return { ...e, x: a.x, y: a.y, rot: normAngle(rot), sx: (e.sx ?? 1) * sim.s * (sim.flip ? -1 : 1), sy: (e.sy ?? 1) * sim.s };
}
case 'hatch': return { ...e, loops: e.loops.map((l) => { const o = []; for (let i = 0; i + 1 < l.length; i += 2) { const p = P(l[i], l[i + 1]); o.push(p.x, p.y); } return o; }) };
case 'dim': {
const out = { ...e };
delete out.block; // its imported geometry no longer fits: draw it again
for (const k of ['p1', 'p2', 'c', 'loc']) if (e[k]) out[k] = P(e[k].x, e[k].y);
if (e.kind === 'linear') { const u = { x: m[0] * Math.cos(e.rot || 0) + m[2] * Math.sin(e.rot || 0), y: m[1] * Math.cos(e.rot || 0) + m[3] * Math.sin(e.rot || 0) }; out.rot = normAngle(Math.atan2(u.y, u.x)); }
return out;
}
case 'viewport': {
const c = P(e.x, e.y);
return { ...e, x: c.x, y: c.y, w: e.w * sim.s, h: e.h * sim.s, s: e.s * sim.s };
}
case 'xline': case 'ray': {
const a = P(e.x, e.y), u = unitDir(m[0] * e.dx + m[2] * e.dy, m[1] * e.dx + m[3] * e.dy) || { dx: e.dx, dy: e.dy };
return { ...e, x: a.x, y: a.y, dx: u.dx, dy: u.dy };
}
case 'mleader': {
const pts = e.pts.map((p) => P(p.x, p.y)), last = e.pts[e.pts.length - 1];
// the landing keeps pointing the way it pointed (a mirror can turn it round); the text stays level
const far = P(last.x + (e.dir === -1 ? -1 : 1), last.y), q = pts[pts.length - 1];
const st = leaderStyle(e), k = sim.s;
return { ...e, pts, dir: far.x >= q.x ? 1 : -1, h: (e.h || st.asz) * k, ...(e.ww ? { ww: e.ww * k } : {}), st: { ...st, asz: st.asz * k, dogleg: st.dogleg * k, gap: st.gap * k } };
}
default: return { ...e };
}
}
In detail
Method
2 × 3 affine matrices composed by multiplication; rotations from cos and sin of the angle in radians.
Number types
64-bit numbers; matrices are plain arrays of 6 numbers.
Tolerances
mSimilar: |sx − sy| ≤ 1e-9 × max(sx, sy) and |column dot product| ≤ 1e-9 × sx × sy, both relative. Mirrored text is turned round when cos(rotation) < −1e-9. An elliptical arc counts as closed when t1 − t0 ≥ 2π − 1e-9. mInverse refuses only a determinant of exactly 0.
Limits
For a matrix that shears or scales unevenly, txEntity scales radii, text heights and block scales by √|det| and leaves the shape's kind unchanged.
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.