Turns a pointer position into drawing coordinates, finds the object snap under it, and works out the center, scale and twist of a sheet viewport after it was panned or zoomed.
How the precision is kept
The view is four 64-bit numbers: center x and y, zoom in CSS pixels per drawing unit, and a turn in radians. A pointer position is divided by the zoom and added to the center. An object snap returns the candidate point's own coordinates, not the pointer's. A viewport's view is read back from a 2 × 3 matrix product and compared with the stored one using a tolerance scaled to the values.
Calculations under this policy
7 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.
view.jsCadCraft 0.8.264-bit7 calculationsReviewed
Tests bin/test-cadcraft.mjs
Pointer position to drawing coordinates
64-bit
The drawing point under a screen position, in the active pane.
u = (sx − w ÷ 2) ÷ zoom; v = (h ÷ 2 − sy) ÷ zoom; x = cx + cos(tw)·u − sin(tw)·v; y = cy + sin(tw)·u + cos(tw)·v
64-bit numbers; one division by the zoom and one addition to the view center. With no turn the sine and cosine are skipped, so the result is exactly cx + u and cy + v. The measured input is the pointer position in CSS pixels.
view.js
lines 400 to 405
CadCraft 0.8.2
toWorld(sx, sy) {
const u = (sx - this.w / 2) / this.zoom, v = (this.h / 2 - sy) / this.zoom;
if (!this.tw) return { x: u + this.cx, y: v + this.cy };
const c = Math.cos(this.tw), s = Math.sin(this.tw);
return { x: this.cx + c * u - s * v, y: this.cy + s * u + c * v };
}
Object snap under the pointer
64-bit
The best snap point (endpoint, midpoint, center, intersection and so on) within the aperture of the pointer.
r = px ÷ zoom; keep candidates with |c − p| ≤ r; lowest priority number wins, then the smallest distance
The returned x and y are the candidate's own 64-bit coordinates from snapPoints and intersect in geom.js; the pointer position only selects among them. The aperture r is absolute in drawing units and shrinks as the view zooms in. Collection stops after about 400 nearby objects and intersections are tried among the first 60.
view.js
lines 577 to 607
CadCraft 0.8.2
/** The best object snap within the aperture (px) of p. */
snap(p, { kinds, base = null, px = 10 }) {
if (!kinds || !kinds.size) return null;
const r = px / this.zoom;
const bounds = this.ensureCache().bounds;
const nearby = [], frz = this.enteredFrozen();
for (const e of this.doc.entities.values()) {
if (!this.doc.visible(e) || this.hiddenHere(e, frz)) continue;
const b = bounds.get(e.id);
if (!b || (UNBOUNDED.has(e.type) ? distToEntity(e, p, null) > r : (p.x < b.minX - r || p.x > b.maxX + r || p.y < b.minY - r || p.y > b.maxY + r))) continue;
nearby.push(e);
if (nearby.length > 400) break;
}
let best = null;
const consider = (c) => {
const d = Math.hypot(c.x - p.x, c.y - p.y);
if (d > r) return;
const pr = c.pr ?? SNAP_PRIORITY[c.kind] ?? 5;
if (!best || pr < best.pr || (pr === best.pr && d < best.d)) best = { ...c, d, pr };
};
for (const e of nearby) for (const c of snapPoints(e, p, r, kinds, this.doc.blocks, base)) consider(c);
if (kinds.has('int') && nearby.length > 1) {
const ps = nearby.slice(0, 60).map((e) => prims(e, this.doc.blocks).filter((q) => !q.box && nearPrim(q, p, r)));
for (let i = 0; i < ps.length; i++) for (let j = i + 1; j < ps.length; j++) {
for (const a of ps[i]) for (const b of ps[j]) for (const x of intersect(a, b)) consider({ kind: 'int', x: x.x, y: x.y });
}
}
if (this.snapUnder) for (const c of this.snapUnder(p, r, kinds) || []) consider(c); // a PDF underlay's own lines (0.7 D7.24)
if (kinds.has('node') && this.snapExtra) { const c = this.snapExtra(p, r); if (c) consider(c); } // a point cloud's points (SG.1)
return best;
}
Viewport view after pan or zoom inside it
64-bit
The center, scale and twist an entered sheet viewport shows on screen, returned for the caller to store in the viewport when they differ from the stored ones.
M = P⁻¹ · T; s = √(M₀² + M₁²); tw = atan2(M₁, M₀); center = M⁻¹ · (vp.x, vp.y)
Scale and twist come from the first column of a 2 × 3 matrix product and the center from the matrix inverse. The comparison with the stored view uses 1e-9 × max(1, |a|, |b|), so it scales with the size of the values. A twist is added to the result only when it or the stored twist is not zero.
view.js
lines 347 to 356
CadCraft 0.8.2
/** The view the entered (not locked) viewport has on screen when it differs from the doc's: { lay, vp, cx, cy, s }, else null. */
pendingViewport() {
const ev = this.enteredViewport();
if (!ev || ev.vp.locked) return null;
const M = this.enteredMatrix(), s = Math.hypot(M[0], M[1]), tw = normTurn(Math.atan2(M[1], M[0]));
const c = ap(inverse(M), ev.vp.x, ev.vp.y);
const same = (a, b) => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
if (same(c.x, ev.vp.cx) && same(c.y, ev.vp.cy) && same(s, ev.vp.s) && same(tw, normTurn(ev.vp.tw || 0))) return null;
return { ...ev, cx: c.x, cy: c.y, s, ...(tw || ev.vp.tw ? { tw } : {}) };
}
View to matrix
64-bit
The drawing-to-pixel matrix of a view for a pane of a given size.
[z·c, z·s, z·s, −z·c, w ÷ 2 − z·(c·cx + s·cy), h ÷ 2 + z·(−s·cx + c·cy)] with c = cos(tw), s = sin(tw), z = zoom
Six 64-bit expressions. The translation terms multiply the view center by the zoom, so for a center far from the origin they are large numbers whose difference from the pane center is formed afterward.
view.js
lines 1236 to 1240
CadCraft 0.8.2
/** A view { cx, cy, zoom, tw } → its world → CSS px affine for a w × h pane (DR.8: turned by −tw on screen). */
export function affineOf(v, w, h) {
const z = v.zoom, c = Math.cos(v.tw || 0), s = Math.sin(v.tw || 0);
return [z * c, z * s, z * s, -z * c, w / 2 - z * (c * v.cx + s * v.cy), h / 2 + z * (-s * v.cx + c * v.cy)];
}
Matrix to view
64-bit
Reads the center, zoom and turn back out of a drawing-to-pixel matrix.
zoom = √(A₀² + A₁²); tw = atan2(A₁, A₀); center = A⁻¹ · (w ÷ 2, h ÷ 2)
Math.hypot for the zoom, atan2 for the turn, and the 2 × 3 inverse for the center. The turn goes through normTurn, which sets angles below 1e-12 rad to exactly 0.
view.js
lines 1241 to 1246
CadCraft 0.8.2
/** … and back: an affine of that shape (a similarity with y flipped) → { cx, cy, zoom, tw }. */
export function viewOf(A, w, h) {
const zoom = Math.hypot(A[0], A[1]), tw = normTurn(Math.atan2(A[1], A[0]));
const c = ap(inverse(A), w / 2, h / 2);
return { cx: c.x, cy: c.y, zoom, tw };
}
Inverse of a 2 × 3 matrix
64-bit
The inverse of a drawing-to-pixel matrix.
det = m₀·m₃ − m₁·m₂; inverse = adjugate ÷ det
Closed form with six divisions by the determinant. The division is not guarded in this function; for a view matrix the determinant is −zoom², and zoomAt keeps the zoom at 1e-9 or more.
view.js
lines 1247 to 1250
CadCraft 0.8.2
function inverse(m) {
const det = m[0] * m[3] - m[1] * m[2];
return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det, (m[2] * m[5] - m[3] * m[4]) / det, (m[1] * m[4] - m[0] * m[5]) / det];
}
Angle brought into one turn
64-bit
An angle reduced to the range −π to π.
r = atan2(sin a, cos a); |r| < 1e-12 → 0
The reduction goes through sine, cosine and atan2 instead of a remainder, which can change the last bits of an angle already in range. Results below 1e-12 rad in size become exactly 0.
view.js
lines 1251 to 1252
CadCraft 0.8.2
/** An angle in (−π, π], with the tiny ones (float noise) as 0. */
function normTurn(a) { let r = Math.atan2(Math.sin(a), Math.cos(a)); if (Math.abs(r) < 1e-12) r = 0; return r; }
In detail
Method
Similarity transform with y flipped: screen = zoom × R(−turn) × (world − center) + pane ÷ 2, and its closed-form inverse.
Number types
64-bit numbers throughout; matrices are plain arrays of 6 numbers.
Tolerances
Snap aperture 10 px ÷ zoom (drawing units). A viewport view counts as unchanged within 1e-9 × max(1, |a|, |b|). An angle smaller than 1e-12 rad is set to 0. zoomAt keeps the zoom between 1e-9 and 1e9.
Limits
A point taken from the pointer alone is only as fine as the pointer position: one CSS pixel is 1 ÷ zoom drawing units. The snap search looks at no more than about 400 nearby objects and intersects only the first 60 of them.
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.