Resources Numerical accuracy Calculation policy

Markup geometry while drawing and editing

Works out the points and boxes that are stored when a markup is drawn, snapped, typed to scale, resized, moved, simplified or lined up.

How the precision is kept

A markup's geometry is a list of points or a box in PDF user space, kept as 64-bit numbers. Every edit computes the new coordinates directly from the old ones in 64-bit; nothing is rounded to a grid. Directions that come from an angle go through cos and sin, so axis-aligned results can differ from the exact axis in the last bits.

Calculations under this policy

12 calculations in 4 source files. 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.js MarkupCraft 0.8.1 64-bit 7 calculations Reviewed

Tests bin/test-markupcraft.mjs

Snap to 45°

64-bit

Moves a point onto the nearest 45° direction from the point before it, keeping its distance.

a = round(atan2(dy, dx) ÷ (π ÷ 4)) × (π ÷ 4); p = from + (cos a, sin a) × hypot(dx, dy)

The direction is computed with cos and sin of the rounded angle, not taken from a table. In 64-bit cos(π ÷ 2) and sin(π) are tiny non-zero numbers, so a snapped vertical or leftward segment can differ from exactly vertical or horizontal in the last bits of the coordinate. The distance is kept from Math.hypot.

  • geom.js
  • lines 43 to 50
  • MarkupCraft 0.8.1
/** Snap p to the nearest 45° direction from `from`. */
export function snap45(from, p) {
  const dx = p[0] - from[0], dy = p[1] - from[1];
  const len = Math.hypot(dx, dy);
  if (!len) return [p[0], p[1]];
  const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
  return [from[0] + Math.cos(a) * len, from[1] + Math.sin(a) * len];
}

Square from a corner

64-bit

Makes the box from a fixed corner to the pointer square.

s = max(|dx|, |dy|); p = a + (sign dx, sign dy) × s

One subtraction and one addition per coordinate; both sides use the same s, so the box is square to the rounding of the two additions.

  • geom.js
  • lines 52 to 57
  • MarkupCraft 0.8.1
/** Make the box from a fixed corner to p square (Shift while drawing boxes). */
export function squareFrom(a, p) {
  const dx = p[0] - a[0], dy = p[1] - a[1];
  const s = Math.max(Math.abs(dx), Math.abs(dy));
  return [a[0] + Math.sign(dx || 1) * s, a[1] + Math.sign(dy || 1) * s];
}

Resize by a grip

64-bit

The new box or points when a grip of a markup is dragged.

box: dragged edges = pointer; stroke: x′ = x₀ + (x − box x₀) ÷ box width × new width

The result is computed from the markup as it was when the drag began, so rounding does not build up during one drag. A box is kept at least 2 points wide and high; that minimum is applied to boxes only, and a freehand stroke is rescaled to whatever width the drag gives. With Shift a corner keeps the start proportions using the larger of the two scale factors.

  • geom.js
  • lines 59 to 102
  • MarkupCraft 0.8.1
/**
 * Dragging a grip of the markup as it was when the drag began (`m0`) to point p → a patch
 * ({rect} / {pts}). opts.shift keeps proportions (box) or snaps to 45° (vertex).
 */
export function applyGrip(m0, grip, p, opts = {}) {
  if (grip.kind === 'vertex' || grip.kind === 'tip' || grip.kind === 'knee') {
    const pts = m0.pts.map((q) => [q[0], q[1]]);
    let q = p;
    if (opts.shift && pts.length > 1) {
      const nb = grip.i > 0 ? pts[grip.i - 1] : pts[1];
      q = snap45(nb, p);
    }
    pts[grip.i] = [q[0], q[1]];
    return { pts };
  }
  // box grips
  const box = BOX_TYPES.has(m0.type) ? m0.rect : pointsBounds(m0.pts);
  let [x0, y0, x1, y1] = box;
  const k = grip.key;
  if (k.includes('w')) x0 = p[0];
  if (k.includes('e')) x1 = p[0];
  if (k.includes('s')) y0 = p[1];
  if (k.includes('n')) y1 = p[1];
  if (opts.shift && k.length === 2) {
    // keep the aspect ratio from the opposite corner
    const w0 = box[2] - box[0], h0 = box[3] - box[1];
    if (w0 > 0 && h0 > 0) {
      const sx = Math.abs(x1 - x0) / w0, sy = Math.abs(y1 - y0) / h0, sc = Math.max(sx, sy);
      if (k.includes('w')) x0 = x1 - w0 * sc * Math.sign(x1 - x0 || 1); else x1 = x0 + w0 * sc * Math.sign(x1 - x0 || 1);
      if (k.includes('s')) y0 = y1 - h0 * sc * Math.sign(y1 - y0 || 1); else y1 = y0 + h0 * sc * Math.sign(y1 - y0 || 1);
    }
  }
  const nb = normRect([x0, y0, x1, y1]);
  const min = 2;
  if (nb[2] - nb[0] < min) nb[2] = nb[0] + min;
  if (nb[3] - nb[1] < min) nb[3] = nb[1] + min;
  if (BOX_TYPES.has(m0.type)) return { rect: nb };
  // freehand: scale every point from the old box to the new one
  const bw = box[2] - box[0] || 1, bh = box[3] - box[1] || 1;
  // a flipped drag mirrors the stroke
  const fx = (x) => (k.includes('w') || k.includes('e') ? x0 + ((x - box[0]) / bw) * (x1 - x0) : x);
  const fy = (y) => (k.includes('n') || k.includes('s') ? y0 + ((y - box[1]) / bh) * (y1 - y0) : y);
  return { pts: m0.pts.map(([x, y]) => [fx(x), fy(y)]) };
}

Move a markup

64-bit

Shifts a markup's points, box and cut-outs by a distance.

x′ = x + dx; y′ = y + dy

One 64-bit addition per stored coordinate. Each move rounds once, so moving there and back does not always restore the exact starting bits.

  • geom.js
  • lines 104 to 111
  • MarkupCraft 0.8.1
/** Patch that moves a markup by (dx, dy). */
export function moveBy(m, dx, dy) {
  const patch = {};
  if (m.pts) patch.pts = m.pts.map(([x, y]) => [x + dx, y + dy]);
  if (m.rect) patch.rect = [m.rect[0] + dx, m.rect[1] + dy, m.rect[2] + dx, m.rect[3] + dy];
  if (m.holes?.length) patch.holes = m.holes.map((h) => h.map(([x, y]) => [x + dx, y + dy]));
  return patch;
}

Simplify a freehand stroke

64-bit

Drops the points of a pen or highlighter stroke that lie within a tolerance of the line through their neighbors.

d = |dy·pₓ − dx·p_y + bₓ·a_y − b_y·aₓ| ÷ L

Douglas–Peucker with an explicit stack. Kept points are the original points, unchanged; none is moved or averaged. The distance formula uses absolute coordinates, and the tolerance is whatever the caller passes.

  • geom.js
  • lines 113 to 132
  • MarkupCraft 0.8.1
/** Douglas–Peucker: drop points closer than tol to the line through their neighbours. */
export function simplify(pts, tol) {
  if (pts.length < 3) return pts.slice();
  const keep = new Uint8Array(pts.length);
  keep[0] = keep[pts.length - 1] = 1;
  const stack = [[0, pts.length - 1]];
  while (stack.length) {
    const [a, b] = stack.pop();
    let best = -1, bd = tol;
    const [ax, ay] = pts[a], [bx, by] = pts[b];
    const dx = bx - ax, dy = by - ay, L = Math.hypot(dx, dy);
    for (let i = a + 1; i < b; i++) {
      const [px, py] = pts[i];
      const d = L ? Math.abs(dy * px - dx * py + bx * ay - by * ax) / L : Math.hypot(px - ax, py - ay);
      if (d > bd) { bd = d; best = i; }
    }
    if (best > 0) { keep[best] = 1; stack.push([a, best], [best, b]); }
  }
  return pts.filter((_, i) => keep[i]);
}

Inverse of a transform

64-bit

The inverse of a 2-D affine transform.

det = a·d − b·c; inverse = adjugate ÷ det

Each entry is divided by the determinant. Only a determinant of exactly zero is refused (the identity is returned), so a nearly singular transform is inverted as it is.

  • geom.js
  • lines 191 to 195
  • MarkupCraft 0.8.1
export function inv(m) {
  const det = m[0] * m[3] - m[1] * m[2];
  if (!det) return I.slice();
  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];
}

Line-up through two pairs of points

64-bit

The move, turn and uniform scale that carries two picked points on one sheet onto two picked points on another.

s = |b| ÷ |a|; θ = atan2(b) − atan2(a); M = [s·cos θ, s·sin θ, −s·sin θ, s·cos θ, e, f]

The turn goes through two atan2 calls and back through cos and sin, rather than through the dot and cross products of the two vectors. The translation is fixed so the first pair maps exactly; the second pair maps to rounding. Refused when either pair is closer than 1e-9 points, an absolute tolerance.

  • geom.js
  • lines 198 to 211
  • MarkupCraft 0.8.1
/**
 * The similarity (move + turn + uniform scale) that takes old points a1, a2 onto new points b1, b2.
 * Used to line up two sheets by picking the same two features on each.
 */
export function similarity(a1, a2, b1, b2) {
  const ax = a2[0] - a1[0], ay = a2[1] - a1[1], bx = b2[0] - b1[0], by = b2[1] - b1[1];
  const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by);
  if (la < 1e-9 || lb < 1e-9) return null;
  const s = lb / la;
  const ang = Math.atan2(by, bx) - Math.atan2(ay, ax);
  const c = Math.cos(ang) * s, sn = Math.sin(ang) * s;
  const e = b1[0] - (c * a1[0] - sn * a1[1]), f = b1[1] - (sn * a1[0] + c * a1[1]);
  return [c, sn, -sn, c, e, f];
}
sketch.js MarkupCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-markupcraft.mjs, bin/test-markupcraft-pen.mjs

Next point from a typed length and angle

64-bit

Places the next point of a line or outline at a typed real length, along a typed angle or toward the pointer.

len = mm ÷ mmPerPt; a = (deg + page turn) × π ÷ 180; p = from + (cos a, sin a) × len

The length is one 64-bit division of the parsed millimeters by the sheet's mmPerPt. A typed angle is converted to radians and passed to cos and sin, so 90° or 180° gives a direction that is off the axis by the rounding of cos(π ÷ 2) or sin(π). Without an angle the direction is the unit vector to the pointer, refused when the pointer is closer than 1e-9 points.

  • sketch.js
  • lines 15 to 35
  • MarkupCraft 0.8.1
export function sketchPoint(from, toward, text, cal, rot = 0) {
  if (!cal || !(cal.mmPerPt > 0)) return { error: 'sketch.err.scale' };
  const [lenText, angText] = String(text || '').split(/[<@]/);
  const mm = parseLength(lenText, cal.unit);
  if (!(mm > 0)) return { error: 'sketch.err.length' };
  const len = mm / cal.mmPerPt;
  let dx, dy;
  if (angText !== undefined && angText.trim() !== '') {
    const deg = Number(angText.trim().replace(/°$/, ''));
    if (!Number.isFinite(deg)) return { error: 'sketch.err.angle' };
    // as seen: right = 0°, up = 90°; turn back into user space by the page's rotation
    const a = ((deg + (rot || 0)) * Math.PI) / 180;   // a page turned 90° clockwise shows user +x pointing down (seen −90°)
    dx = Math.cos(a); dy = Math.sin(a);
  } else {
    const vx = toward[0] - from[0], vy = toward[1] - from[1];
    const d = Math.hypot(vx, vy);
    if (d < 1e-9) return { error: 'sketch.err.direction' };
    dx = vx / d; dy = vy / d;
  }
  return { pt: [from[0] + dx * len, from[1] + dy * len] };
}
pen-clean.js MarkupCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-markupcraft-pen.mjs

Cleaned pen stroke to a markup

64-bit

Turns what the stroke cleaner recognized (line, circle, arc, outline) into the points or box of a line, ellipse, arc, rectangle, polyline or polygon markup.

circle → box (c − r, c + r); arc → points at a₀, a₀ + sweep ÷ 2, a₁ on the circle

The recognized shape comes from cleanSketch in stroke-clean.js; this function only converts it. An arc's three points are rebuilt with cos and sin from its center, radius and angles, and the arc markup later recomputes the circle from those three points. A closed four-point stroke becomes a rectangle when every corner is within 1e-6 points (absolute) of the bounding box.

  • pen-clean.js
  • lines 17 to 37
  • MarkupCraft 0.8.1
/** The fields of the markup that says what this cleaned entity is, or null (keep the pen). */
export function fieldsFor(e) {
  if (e.type === 'line') return { type: 'line', pts: [[e.x1, e.y1], [e.x2, e.y2]] };
  if (e.type === 'circle') return { type: 'ellipse', rect: [e.cx - e.r, e.cy - e.r, e.cx + e.r, e.cy + e.r] };
  if (e.type === 'arc') {
    let sweep = e.a1 - e.a0; if (sweep <= 0) sweep += Math.PI * 2;
    const at = (a) => [e.cx + e.r * Math.cos(a), e.cy + e.r * Math.sin(a)];
    return { type: 'arc', pts: [at(e.a0), at(e.a0 + sweep / 2), at(e.a1)] };
  }
  if (e.type === 'poly') {
    const P = e.pts.map((p) => [p.x, p.y]);
    if (e.closed && P.length === 4) {   // a right-angled four-sided closed stroke that is level: a rectangle
      const xs = P.map((p) => p[0]), ys = P.map((p) => p[1]);
      const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
      const level = P.every((p) => (Math.abs(p[0] - x0) < 1e-6 || Math.abs(p[0] - x1) < 1e-6) && (Math.abs(p[1] - y0) < 1e-6 || Math.abs(p[1] - y1) < 1e-6));
      if (level) return { type: 'rect', rect: [x0, y0, x1, y1] };
    }
    if (P.length >= (e.closed ? 3 : 2)) return { type: e.closed ? 'polygon' : 'polyline', pts: P };
  }
  return null;
}
markupcraft.js MarkupCraft 0.8.1 64-bit 3 calculations Reviewed

Tests None found by the review

Finished freehand stroke

64-bit

What is stored when a pen or highlighter stroke is finished.

tolerance = 0.6 ÷ zoom (points)

The simplify tolerance is 0.6 screen pixels converted to points by the current zoom, so the same hand movement stores more points when drawn zoomed in. A stroke of one point gets a second point 0.01 points to its right.

  • markupcraft.js
  • lines 2441 to 2446
  • MarkupCraft 0.8.1
function finishFree(pts) {
  if (!curSheet()) return;
  const clean = pts.length > 2 ? simplify(pts, 0.6 / view.z) : pts;
  if (clean.length === 1) clean.push([clean[0][0] + 0.01, clean[0][1]]);
  commitNew(newMarkup(tool, { pts: clean }));
}

Align the old sheet in a comparison

64-bit

The fit of the old sheet onto the new one from two points picked on each, and the scale and turn reported for it.

scale = hypot(M₀, M₁); turn = atan2(M₁, M₀) × 180 ÷ π

The first two picks are taken back through the inverse of the alignment then in force, then similarity() gives the new fit, all in 64-bit. The status line shows the scale in percent and the turn in degrees with 2 decimals; the fit itself is kept unrounded.

  • markupcraft.js
  • lines 4099 to 4122
  • MarkupCraft 0.8.1
function alignClick(p) {
  const a = cmp.aligning;
  a.pts.push({ screenUser: p });
  const n = a.pts.length;
  if (n === 1) ui.cmpStatus.textContent = t('compare.align.2');
  if (n === 2) { cmp.balance = 1; applyCompare(); ui.cmpStatus.textContent = t('compare.align.3'); }
  if (n === 3) ui.cmpStatus.textContent = t('compare.align.4');
  if (n === 4) {
    // the old sheet was shown moved by T: undo that to get points in the old sheet's own space
    const T = alignment(cmp), Ti = inv(T);
    const o1 = applyT(Ti, a.pts[0].screenUser), o2 = applyT(Ti, a.pts[1].screenUser);
    const fit = similarity(o1, o2, a.pts[2].screenUser, a.pts[3].screenUser);
    cmp.aligning = null;
    cmp.balance = +ui.cmpBalance.value / 100;
    if (!fit) { ui.cmpStatus.textContent = t('compare.align.same'); applyCompare(); return; }
    cmp.fit = fit; cmp.dx = 0; cmp.dy = 0;
    const sc = Math.hypot(fit[0], fit[1]), ang = Math.atan2(fit[1], fit[0]) * 180 / Math.PI;
    ui.cmpStatus.textContent = t('compare.align.done', { scale: (sc * 100).toFixed(2), angle: ang.toFixed(2) });
    status(t('compare.aligned'));
    applyCompare();
    updateCursor();
  }
  view.invalidate();
}

Line up a picture by two points

64-bit

The new box of a picture markup so two points on it land on two points on the sheet.

k = √(rw·rh ÷ (W·H)) × fit scale; box = center ± (w·k ÷ 2, h·k ÷ 2)

The fit is computed in screen coordinates and the center mapped back to user space, in 64-bit. A turn of 0.1° or less is ignored; a larger turn redraws the picture's pixels on a canvas whose size is rounded to whole pixels, so the turn is in the pixels and the stored box stays upright. The status line rounds the scale to whole percent and the turn to 0.1°.

  • markupcraft.js
  • lines 1823 to 1855
  • MarkupCraft 0.8.1
/** Carry the picture so its two points land on theirs: scaled, moved and, when the turn is more than a hair, turned in its pixels. */
async function applyLineUp(id, [a1, b1, a2, b2]) {
  const m = store.get(id);
  if (!m || m.type !== 'image') return;
  const scr = (q) => view.toScreen(q);
  const fit = fitTwoPoints(scr(a1), scr(a2), scr(b1), scr(b2));
  if (!fit) { status(t('lineup.same')); return; }
  const info = imageInfo(m.img);
  if (!info) return;
  const [W, H] = [info.width, info.height];
  const rw = Math.abs(m.rect[2] - m.rect[0]), rh = Math.abs(m.rect[3] - m.rect[1]);
  const swapped = Math.abs(rw / rh - W / H) > Math.abs(rw / rh - H / W);
  const k = Math.sqrt((rw * rh) / (W * H)) * fit.scale;   // points per pixel after the line-up
  const turn = Math.abs(fit.angle) > (0.1 * Math.PI) / 180;
  let img = m.img, w2 = W, h2 = H;
  if (turn) {
    [w2, h2] = turnedSize(W, H, fit.angle).map((v) => Math.max(1, Math.round(v)));
    const im = await new Promise((ok, bad) => { const i = new Image(); i.onload = () => ok(i); i.onerror = bad; i.src = m.img; });
    const c = el('canvas', { width: w2, height: h2 });
    const g = c.getContext('2d');
    g.translate(w2 / 2, h2 / 2); g.rotate(fit.angle); g.drawImage(im, -W / 2, -H / 2);
    img = cleanImage(c.toDataURL('image/png'));
    if (!img) { status(t('lineup.too_big')); return; }
  }
  const c0 = scr([(m.rect[0] + m.rect[2]) / 2, (m.rect[1] + m.rect[3]) / 2]);
  const c1 = fit.map(c0);
  const center = view.toUser(c1[0], c1[1]);
  const [ww, hh] = swapped ? [h2 * k, w2 * k] : [w2 * k, h2 * k];
  const rect = [center[0] - ww / 2, center[1] - hh / 2, center[0] + ww / 2, center[1] + hh / 2];
  store.change(t('lineup.undo'), (tx) => tx.update(id, { rect, img }));
  refreshAll();
  status(t('lineup.done', { pct: Math.round(fit.scale * 100), deg: Math.round((fit.angle * 180 / Math.PI) * 10) / 10 }));
}

In detail

Method
45° snap: angle rounded to a multiple of π ÷ 4, point rebuilt at the same distance. Typed point: length = mm ÷ mmPerPt along a typed angle or toward the pointer. Resize: edges set to the pointer, strokes rescaled linearly. Simplify: Douglas–Peucker. Line-up of two sheets or of a picture: a similarity (move, turn, uniform scale) through two pairs of points.
Number types
64-bit numbers throughout. Affine transforms are six-number arrays [a, b, c, d, e, f].
Tolerances
Freehand strokes are simplified with a tolerance of 0.6 screen pixels, that is 0.6 ÷ zoom in points. A resized box is kept at least 2 points wide and high. A similarity is refused when either pair of points is closer than 1e-9 points. A typed point needs a pointer direction longer than 1e-9 points. A cleaned pen stroke becomes a rectangle only when each corner is within 1e-6 points of its bounding box. A picture is turned in its pixels only when the turn is more than 0.1°.
Limits
The simplified pen stroke depends on the zoom it was drawn at. A matrix is inverted unless its determinant is exactly zero. A fit through two pairs of points has no redundancy, so it carries the full error of the picked points.

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.

Every policy that takes this approach