Resources Numerical accuracy Calculation policy

Revision overlay: lining up two sheets and moving the takeoff

Fits the move, turn and scale that lays an old sheet over its revision from two pairs of picked points, and carries the marks and the scale across.

How the precision is kept

The alignment is a similarity (no mirror) solved in closed form from two point pairs, in 64-bit. Marks are moved by applying it to every point; bulges stay as they are because a similarity keeps every arc's turn. The fit is exact for the four picks, so its accuracy is the accuracy of the picks, magnified when the two points of a pair are close together.

Calculations under this policy

7 calculations in 3 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 4 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Similarity from two point pairs

64-bit

The move, turn and uniform scale that take two points of the old sheet to two points of the new one.

c + i·s = (b₂ − b₁) ÷ (a₂ − a₁); t = b₁ − (c + i·s)·a₁

c and s are the real and imaginary parts of (b₂ − b₁) ÷ (a₂ − a₁), each a sum of two products over |a₂ − a₁|². The translation is b₁ minus the rotated a₁, a subtraction that loses digits when the points are far from the origin compared with their spacing. Only an exactly zero distance is refused.

  • overlay.js
  • lines 23 to 31
  • TakeoffCraft 0.8.1
/** The similarity taking a1 → b1 and a2 → b2 (no mirror). → matrix | null when a1 = a2 or b1 = b2. */
export function fitTwoPoints(a1, a2, b1, b2) {
  const ax = a2.x - a1.x, ay = a2.y - a1.y, bx = b2.x - b1.x, by = b2.y - b1.y;
  const la = ax * ax + ay * ay;
  if (!(la > 0) || !(bx * bx + by * by > 0)) return null;
  // b = s·R·a: as a complex number, (bx + i by) / (ax + i ay)
  const c = (bx * ax + by * ay) / la, s = (by * ax - bx * ay) / la;
  return [c, s, -s, c, b1.x - (c * a1.x - s * a1.y), b1.y - (s * a1.x + c * a1.y)];
}

Scale of an alignment

64-bit

How much longer a length becomes under the alignment.

scale = √(m₀² + m₁²)

Math.hypot of the first matrix column; one call, no cancellation.

  • overlay.js
  • line 33
  • TakeoffCraft 0.8.1
export const scaleOf = (m) => Math.hypot(m[0], m[1]);

Marks moved onto the new sheet

64-bit

Applies the alignment to every point of a mark.

p′ = A·p; b′ = b

One matrix-times-point per vertex in 64-bit. Bulges are copied unchanged. The moved points are what the mark stores on the new sheet.

  • overlay.js
  • line 178
  • TakeoffCraft 0.8.1
export const movePts = (pts, A) => pts.map((p) => { const q = apply(A, p); return p.b ? { x: q.x, y: q.y, b: p.b } : { x: q.x, y: q.y }; });

Old sheet's pixels under the new sheet

64-bit

The matrix from a pixel of the new sheet's raster to a pixel of the old one.

A product of five matrices, two of them inverses, in 64-bit. It is used to compare the two pictures, not to move marks.

  • overlay.js
  • lines 35 to 43
  • TakeoffCraft 0.8.1
/**
 * Pixel map for warp(): NEW raster pixel → OLD raster pixel.
 * Tn, To: the sheets' display transforms (user → display); px: raster pixels per display unit; A: old user → new user.
 */
export function pixelMap(Tn, To, A, px) {
  const S = [px, 0, 0, px, 0, 0], Si = [1 / px, 0, 0, 1 / px, 0, 0];
  // new px → new display → new user → old user → old display → old px
  return multiply(S, multiply(To, multiply(invert(A), multiply(invert(Tn), Si))));
}
geom.js TakeoffCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-takeoffcraft.mjs

Applying, multiplying and inverting a transform

64-bit

The 2 × 3 matrix operations behind sheet transforms.

det = m₀·m₃ − m₁·m₂; M⁻¹ = adj(M) ÷ det

Plain 64-bit sums of products. invert divides every entry by det = m₀·m₃ − m₁·m₂ with no check for zero, and its translation terms are each a difference of two products.

  • geom.js
  • lines 263 to 270
  • TakeoffCraft 0.8.1
export const apply = (m, p) => ({ x: m[0] * p.x + m[2] * p.y + m[4], y: m[1] * p.x + m[3] * p.y + m[5] });
export function multiply(m, n) {
  return [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
}
export function invert(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];
}
takeoffcraft.js TakeoffCraft 0.8.1 64-bit 2 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Alignment from four picks

64-bit

Collects two points on the old sheet and two on the new one and fits the alignment.

Old-sheet picks are taken back through the inverse of the current alignment before the fit. The scale shown in the status line is rounded to 3 decimals; the stored matrix is not.

  • takeoffcraft.js
  • lines 2798 to 2809
  • TakeoffCraft 0.8.1
function alignPick(p) {
  const c = app.cmp, a = c.align, i = a.picks.length;
  a.picks.push(i % 2 === 0 ? G.apply(G.invert(c.A), p) : p);
  if (a.picks.length < 4) { status(t('cmp.align.step' + (a.picks.length + 1)), 0); view.overlayOnly(); return; }
  const [o1, n1, o2, n2] = a.picks;
  const A = fitTwoPoints(o1, o2, n1, n2);
  c.align = null;
  if (!A) { status(t('cmp.align.bad'), 9000); view.overlayOnly(); return; }
  c.A = A; c.chg = null;
  status(t('cmp.align.done', { scale: scaleOf(A).toFixed(3) }), 9000);
  renderCmp(); view.overlayOnly();
}

Scale carried to the new sheet

64-bit

The scale the moved marks are measured with on the new sheet.

ftPerUnit′ = ftPerUnit ÷ scale(A)

carried ftPerUnit = old ftPerUnit ÷ the alignment's scale, one division. When that scale is within 1e-6 of 1 the old scale record is used unchanged. If the new sheet already has a scale that differs by more than 0.1 % (relative), the person is asked before anything moves.

  • takeoffcraft.js
  • lines 2853 to 2856
  • TakeoffCraft 0.8.1
const carried = oldSc ? U.scaleRecord({ ...oldSc, method: 'calibrated', preset: null, ftPerUnit: oldSc.ftPerUnit / scaleOf(c.A), label: U.describeScale(oldSc.ftPerUnit / scaleOf(c.A), cur.info.userUnit), cal: null }) : null;
const asIs = oldSc && Math.abs(scaleOf(c.A) - 1) < 1e-6 ? oldSc : carried;
const newSc = model.scaleOf(cur.key);
if (asIs && newSc && Math.abs(newSc.ftPerUnit - asIs.ftPerUnit) / asIs.ftPerUnit > 0.001) {

In detail

Method
The complex ratio (b₂ − b₁) ÷ (a₂ − a₁) gives the scaled rotation; the translation follows from the first pair. Matrices are 2 × 3 in PDF order.
Number types
64-bit numbers.
Tolerances
A pair with zero distance is refused (exact test). The old scale is kept as it is when the alignment's scale is within 1e-6 of 1. A warning is shown when the new sheet's own scale differs from the carried one by more than 0.1 %.
Limits
The matrix inverse divides by the determinant without a guard. With two pairs there is no redundancy and so no measure of how well the sheets fit.

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