Resources Numerical accuracy Calculation policy

Revision overlay: what changed under a mark

Compares the pictures of an old sheet and its revision and flags the marks whose drawing changed.

How the precision is kept

The comparison is made on rasters, pixel by pixel, with thresholds; it produces a mask, pixel counts and a yes-or-no flag per mark, not a measured quantity. The raster size sets what it can see: at most 2 pixels per display unit and about 12 million pixels per sheet.

Calculations under this policy

4 calculations in 2 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.

overlay.js TakeoffCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Where the two sheets differ

64-bit

Marks each pixel as removed, added or unchanged, and counts both kinds.

removed: old ≥ 0.35 and max(new within r) < 0.2; added: the reverse

Reads two 32-bit ink rasters and compares them with fixed thresholds; the outputs are a byte mask and two whole-number pixel counts. No arithmetic is done on the ink values here beyond the comparisons.

  • overlay.js
  • lines 82 to 97
  • TakeoffCraft 0.8.1
/**
 * Where the two sheets differ: 1 = ink only in the old (removed), 2 = ink only in the new (added), 0 = same.
 * `on`: how dark a pixel must be to count as ink; `off`: how light the other sheet must stay within r pixels.
 */
export function changeMask(oldImg, newImg, { r = 2, on = 0.35, off = 0.2 } = {}) {
  const { w, h } = newImg;
  const dOld = dilate(oldImg, r).data, dNew = dilate(newImg, r).data;
  const a = oldImg.data, b = newImg.data;
  const mask = new Uint8Array(w * h);
  let removed = 0, added = 0;
  for (let i = 0; i < mask.length; i++) {
    if (a[i] >= on && dNew[i] < off) { mask[i] = 1; removed++; }
    else if (b[i] >= on && dOld[i] < off) { mask[i] = 2; added++; }
  }
  return { mask, w, h, removed, added };
}

How much changed along a mark

64-bit

The share of a mark's path with changed ink beside it, whether a corner changed, and the changed pixels near it.

share = samples with changed ink within the buffer ÷ samples

The path is sampled once per pixel of length (arcs as 5° chords); each sample is a box sum from the whole-number summed-area table. share = hits ÷ samples in 64-bit. Pixels are counted when their middle is within the buffer of a segment, by squared distance.

  • overlay.js
  • lines 99 to 144
  • TakeoffCraft 0.8.1
/**
 * How much the drawing changed under a mark (in the new raster). `toPx(p)` maps the mark's points (old user space) to new
 * raster pixels. Along its path (a run, an area's outlines; a count is its point), every pixel step asks "is there
 * changed ink within `buffer` pixels?":
 *   { removed, added, total }   changed pixels within `buffer` of the path
 *   share                       the part of the path that has changed ink beside it (0…1)
 *   corner                      the drawing changed at one of its points (an end moved, a corner, a wall got longer)
 * `changed(r, kind)` is the rule the app flags by.
 */
export function markChange(m, cm, toPx, { buffer = 8 } = {}) {
  const { mask, w, h } = cm;
  const S = cm.sum || (cm.sum = sumOf(mask, w, h));
  const any = (x, y) => boxSum(S, w, h, x - buffer, y - buffer, x + buffer, y + buffer) > 0;
  const rings = m.kind === 'area' ? [[m.pts, true], ...(m.holes || []).map((r) => [r, true])] : [[m.pts, false]];
  const segs = [], corners = [];
  for (const [ring, closed] of rings) {
    for (const p of ring) corners.push(toPx(p));
    const pts = flatten(ring, closed).map(toPx);
    if (pts.length === 1) segs.push([pts[0], pts[0]]);
    for (let i = 1; i < pts.length; i++) segs.push([pts[i - 1], pts[i]]);
    if (closed && pts.length > 2) segs.push([pts[pts.length - 1], pts[0]]);
  }
  if (!segs.length) return { removed: 0, added: 0, total: 0, share: 0, corner: false };
  // the share of the path with changed ink beside it, one sample per pixel
  let samples = 0, hit = 0;
  for (const [a, b] of segs) {
    const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)));
    for (let k = 0; k <= n; k++) { samples++; if (any(a.x + (b.x - a.x) * k / n, a.y + (b.y - a.y) * k / n)) hit++; }
  }
  const corner = corners.some((c) => any(c.x, c.y));
  // changed pixels near the path, by kind
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const [p, q] of segs) for (const s of [p, q]) { x0 = Math.min(x0, s.x); y0 = Math.min(y0, s.y); x1 = Math.max(x1, s.x); y1 = Math.max(y1, s.y); }
  const bx0 = Math.max(0, Math.floor(x0 - buffer)), by0 = Math.max(0, Math.floor(y0 - buffer));
  const bx1 = Math.min(w - 1, Math.ceil(x1 + buffer)), by1 = Math.min(h - 1, Math.ceil(y1 + buffer));
  const b2 = buffer * buffer;
  let removed = 0, added = 0;
  for (let y = by0; y <= by1; y++) for (let x = bx0; x <= bx1; x++) {
    const v = mask[y * w + x];
    if (!v) continue;
    const p = { x: x + 0.5, y: y + 0.5 };
    if (!segs.some(([a, b]) => d2seg(p, a, b) <= b2)) continue;
    if (v === 1) removed++; else added++;
  }
  return { removed, added, total: removed + added, share: samples ? hit / samples : 0, corner };
}

Changed pixels in a box

Exact

How many changed pixels lie in a rectangle of the mask.

n = S[y₁+1][x₁+1] − S[y₀][x₁+1] − S[y₁+1][x₀] + S[y₀][x₀]

Four lookups in a Uint32Array of whole-number running counts. The box corners are floored and clipped to the raster first.

  • overlay.js
  • lines 161 to 168
  • TakeoffCraft 0.8.1
/** How many nonzero mask pixels in the box [x0, x1] × [y0, y1] (clipped). */
export function boxSum(S, w, h, x0, y0, x1, y1) {
  x0 = Math.max(0, Math.floor(x0)); y0 = Math.max(0, Math.floor(y0));
  x1 = Math.min(w - 1, Math.floor(x1)); y1 = Math.min(h - 1, Math.floor(y1));
  if (x1 < x0 || y1 < y0) return 0;
  const W = w + 1;
  return S[(y1 + 1) * W + x1 + 1] - S[y0 * W + x1 + 1] - S[(y1 + 1) * W + x0] + S[y0 * W + x0];
}
takeoffcraft.js TakeoffCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-takeoffcraft.mjs

Raster sizes and the mark test

64-bit

Chooses the two raster scales, builds the pixel map between them and tests every mark of the old sheet.

px = min(2, √(12,000,000 ÷ (W × H)))

Each sheet is rendered at min(2, √(12e6 ÷ (width × height))) pixels per display unit. Mark points go through the alignment and the page transform in 64-bit, then × pixels per unit.

  • takeoffcraft.js
  • lines 2817 to 2826
  • TakeoffCraft 0.8.1
const px = Math.min(2, Math.sqrt(12e6 / (cur.info.width * cur.info.height)));
const pxOld = Math.min(2, Math.sqrt(12e6 / (c.old.info.width * c.old.info.height)));
const [nw, ol] = [await rasterize(cur, px), await rasterize(c.old, pxOld)];
const M = pixelMap(cur.info.transform, c.old.info.transform, c.A, 1);
// pixelMap at 1 px per unit; the two rasters have their own pixel sizes
const Mpx = G.multiply([pxOld, 0, 0, pxOld, 0, 0], G.multiply(M, [1 / px, 0, 0, 1 / px, 0, 0]));
const cm = changeMask(warp(ol, Mpx, nw.w, nw.h), nw, { r: Math.max(1, Math.round(px * 1.5)) });
const toPx = (q) => { const d = G.apply(cur.info.transform, G.apply(c.A, q)); return { x: d.x * px, y: d.y * px }; };
const flagged = new Set();
for (const m of model.marksOn(c.old.key)) if (changed(markChange(m, cm, toPx, { buffer: Math.max(3, px * 4) }), m.kind)) flagged.add(m.id);

In detail

Method
The old raster is resampled into the new one's pixels (bilinear). Ink counts as changed when one sheet has it and the other has none within r pixels. Along each mark's path, one sample per pixel asks whether changed ink lies within a buffer, using a summed-area table.
Number types
Float32Array ink rasters (0 to 1, from 8-bit pixels); Uint8Array mask; Uint32Array summed-area table; 64-bit numbers for the path sampling.
Tolerances
Ink: at or above 0.35 is ink, below 0.2 within r pixels is none. r = max(1, round(1.5 × pixels per unit)). Buffer = max(3, 4 × pixels per unit) pixels. A run or an area is flagged when a corner changed or at least 15 % of its path has changed ink beside it; a count when any changed pixel is near it.
Limits
A change smaller than r pixels, or lighter than the ink threshold, is not seen. The flag is a prompt to look, not a measurement.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach