Resources Numerical accuracy Calculation policy

Snapping to and measuring the sheet's own linework

Reads the vector lines of a PDF page or a CAD sheet, snaps picked points to them, and measures a whole connected path or closed loop from one click.

How the precision is kept

Segments are kept in a Float64Array in sheet units, and lengths and areas are 64-bit sums over them. Straight lines are used as drawn. A Bézier curve is cut into 8 straight chords, and a picked path that follows a curve is measured along those chords, so it comes out short of the true curve.

Open item. Measure a picked curve from its Bézier control points, or from many more chords, instead of the 8 chords kept for snapping.

Calculations under this policy

11 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.

snap.js TakeoffCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Bézier curves cut into chords

64-bit

Turns each cubic or quadratic curve of the page into 8 straight segments.

B(t) = u³p₀ + 3u²t·c₁ + 3u·t²·c₂ + t³p₃, u = 1 − t, t = s ÷ 8

Control points go through the current matrix first; points at t = 1 ÷ 8 … 7 ÷ 8 are evaluated in Bernstein form in 64-bit and the last point is set to the curve's true end. The inner points are on the curve; the chords between them are inside it.

  • snap.js
  • lines 62 to 83
  • TakeoffCraft 0.8.1
// cubic (2: c1, c2, end) or quadratic (3: c, end), flattened
const pts = c === 2 ? [d[k + 1], d[k + 2], d[k + 3], d[k + 4], d[k + 5], d[k + 6]] : [d[k + 1], d[k + 2], d[k + 3], d[k + 4]];
const ex = X(pts.at(-2), pts.at(-1)), ey = Y(pts.at(-2), pts.at(-1));
let lx = px, ly = py;
for (let s = 1; s <= CURVE_STEPS; s++) {
  const t = s / CURVE_STEPS, u = 1 - t;
  let x, y;
  if (c === 2) {
    const c1x = X(pts[0], pts[1]), c1y = Y(pts[0], pts[1]), c2x = X(pts[2], pts[3]), c2y = Y(pts[2], pts[3]);
    x = u * u * u * px + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex;
    y = u * u * u * py + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey;
  } else {
    const cx = X(pts[0], pts[1]), cy = Y(pts[0], pts[1]);
    x = u * u * px + 2 * u * t * cx + t * t * ex;
    y = u * u * py + 2 * u * t * cy + t * t * ey;
  }
  if (s === CURVE_STEPS) { x = ex; y = ey; }
  push(lx, ly, x, y, 1 | (s === 1 ? 2 : 0) | (s === CURVE_STEPS ? 4 : 0));
  lx = x; ly = y;
}
px = ex; py = ey;
k += c === 2 ? 7 : 5;

Where two segments cross

64-bit

The crossing point of two segments, offered as a snap point.

u = ((c − a) × s) ÷ (r × s); point = a + r·u

Parametric solve with den = r × s. Parallel is |den| < 1e-12 × max(1, |r|·|s|): scaled to the segments, but absolute once the product of lengths is below 1. Parameters within 1e-9 of 0 or 1 count as ends, and two segments that only meet end to end return nothing. The point is a + r·u.

  • snap.js
  • lines 194 to 208
  • TakeoffCraft 0.8.1
/** Where two segments cross (ends included), or null (parallel, or apart). */
export function intersect(g, s, t) {
  const ax = g[s * 4], ay = g[s * 4 + 1], bx = g[s * 4 + 2], by = g[s * 4 + 3];
  const cx = g[t * 4], cy = g[t * 4 + 1], dx = g[t * 4 + 2], dy = g[t * 4 + 3];
  const rx = bx - ax, ry = by - ay, sx = dx - cx, sy = dy - cy;
  const den = rx * sy - ry * sx;
  if (Math.abs(den) < 1e-12 * Math.max(1, Math.hypot(rx, ry) * Math.hypot(sx, sy))) return null;
  const u = ((cx - ax) * sy - (cy - ay) * sx) / den, v = ((cx - ax) * ry - (cy - ay) * rx) / den;
  const e = 1e-9;
  if (u < -e || u > 1 + e || v < -e || v > 1 + e) return null;
  // two pieces that only meet end to end (a corner, a curve's chords) do not cross: that point is an end, or nothing
  const atEnd = (w) => w < e || w > 1 - e;
  if (atEnd(u) && atEnd(v)) return null;
  return { x: ax + rx * u, y: ay + ry * u };
}

Snap point for a picked point

64-bit

The end point, midpoint, intersection or nearest point on a line within the snap distance.

End points are returned exactly as stored; a midpoint is (a + b) ÷ 2; the nearest point is a + (b − a)·t with t clamped to [0, 1]. The snap distance is given in sheet units by the caller (screen pixels ÷ zoom). The returned point is what the mark stores.

  • snap.js
  • lines 143 to 183
  • TakeoffCraft 0.8.1
/**
 * The snap point for p within tol (user units), or null.
 * `kinds` limits what is offered: { end, mid, int, near } (all true by default).
 */
snap(p, tol, kinds = {}) {
  const want = { end: true, mid: true, int: true, near: true, ...kinds };
  const g = this.segs, cur = this.curve;
  const cand = this.near(p, tol).filter((s) => distSeg(p, g, s) <= tol);
  if (!cand.length) return null;
  let best = null;
  const RANK = { end: 0, int: 1, mid: 2 };     // the same spot found twice (a crossing at a midpoint): the stronger name
  const offer = (x, y, kind) => {
    const d = Math.hypot(x - p.x, y - p.y);
    if (d > tol) return;
    if (!best || d < best.d - 1e-9 || (Math.abs(d - best.d) <= 1e-9 && RANK[kind] < RANK[best.kind])) best = { x, y, kind, d };
  };
  for (const s of cand) {
    const ax = g[s * 4], ay = g[s * 4 + 1], bx = g[s * 4 + 2], by = g[s * 4 + 3];
    if (want.end) {
      // a flattened curve's inner chord points are not ends; its own two ends are
      if (!cur[s] || cur[s] & 2) offer(ax, ay, 'end');
      if (!cur[s] || cur[s] & 4) offer(bx, by, 'end');
    }
    if (want.mid && !cur[s]) offer((ax + bx) / 2, (ay + by) / 2, 'mid');
  }
  if (want.int) {
    const list = cand.slice(0, 80);
    for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
      const q = intersect(g, list[i], list[j]);
      if (q) offer(q.x, q.y, 'int');
    }
  }
  if (best) return best;
  if (!want.near) return null;
  for (const s of cand) {
    const q = closestOnSeg(p, g, s);
    const d = Math.hypot(q.x - p.x, q.y - p.y);
    if (!best || d < best.d) best = { x: q.x, y: q.y, kind: 'near', d };
  }
  return best && best.d <= tol ? best : null;
}
pickpath.js TakeoffCraft 0.8.1 64-bit 6 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Connected path through a segment

64-bit

The segment that was clicked and everything joined to it end to end within the gap tolerance.

The gap is clamped to 0.05 … 60 PDF units and does not follow the sheet's scale. The path is grown forward and then backward, up to 20,000 segments in all. Each join's distance is then recomputed from the segment ends, and one of 1e-9 or less is recorded as touching (gap 0).

  • pickpath.js
  • lines 56 to 78
  • TakeoffCraft 0.8.1
/** The path through `seed`, grown both ways until it ends, closes or meets a segment already used. */
export function chainFrom(idx, seed, gap = GAP_DEFAULT) {
  const g = idx.segs;
  gap = Math.min(GAP_MAX, Math.max(GAP_MIN, gap));
  const used = new Set([seed]);
  const fwd = [];
  extend(idx, used, { s: seed, rev: false }, gap, fwd, MAX_PATH_SEGS);
  // the other way: walk from the seed's first end, reversed, then flip that run back into order
  const back = [];
  extend(idx, used, { s: seed, rev: true }, gap, back, MAX_PATH_SEGS - fwd.length);
  const segs = [];
  for (let i = back.length - 1; i >= 0; i--) segs.push({ s: back[i].s, rev: !back[i].rev, gap: 0 });
  segs.push({ s: seed, rev: false, gap: 0 });
  segs.push(...fwd);
  // gaps belong to the join they sit in: recompute from the geometry, which is the same either way
  const gaps = [];
  for (let i = 1; i < segs.length; i++) {
    const [, , px, py] = ends(g, segs[i - 1].s, segs[i - 1].rev), [qx, qy] = ends(g, segs[i].s, segs[i].rev);
    const d = Math.hypot(qx - px, qy - py);
    segs[i].gap = d > 1e-9 ? d : 0;
  }
  return finish({ seed, segs }, g, gap);
}

Next segment at a junction

64-bit

Chooses which segment a path continues along from an end point.

score = turn angle + (0.01 when the ends are more than 1e-9 apart)

A segment end qualifies when its Math.hypot distance to the path's end is at most the gap. Among those, the smallest turn angle wins, with 0.01 radian added for an end that does not touch (distance above 1e-9), so a clean junction beats a bridged gap.

  • pickpath.js
  • lines 23 to 40
  • TakeoffCraft 0.8.1
/** The next segment from the point (x, y) heading `dir`: the unused one with an end within `gap`, bending least. */
function nextFrom(idx, used, x, y, dir, gap) {
  const g = idx.segs;
  let best = null;
  for (const s of idx.near({ x, y }, gap)) {
    if (used.has(s)) continue;
    for (const rev of [false, true]) {
      const [ax, ay, bx, by] = ends(g, s, rev);
      const d = Math.hypot(ax - x, ay - y);
      if (d > gap) continue;
      const t = turn(dir, angleOf(ax, ay, bx, by));
      // a clean junction beats a gap; among equals the smaller bend wins
      const score = t + (d > 1e-9 ? 0.01 : 0);
      if (!best || score < best.score) best = { s, rev, d, score };
    }
  }
  return best;
}

Vertices of a picked path

64-bit

The points of the path in order, as they will be stored in the mark.

Segment ends are copied from the Float64Array. A point within 1e-9 PDF units of the one before is dropped (absolute). Where a gap was bridged both ends are kept, so the mark runs straight across the gap.

  • pickpath.js
  • lines 104 to 111
  • TakeoffCraft 0.8.1
/** The vertices of a path in order (a closed path does not repeat its first point). */
export function pathPoints(path) {
  const g = path.g, pts = [];
  const add = (x, y) => { const q = pts.at(-1); if (!q || Math.hypot(q.x - x, q.y - y) > 1e-9) pts.push({ x, y }); };
  for (const e of path.segs) { const [ax, ay, bx, by] = ends(g, e.s, e.rev); add(ax, ay); add(bx, by); }
  if (path.closed && pts.length > 1 && Math.hypot(pts[0].x - pts.at(-1).x, pts[0].y - pts.at(-1).y) <= path.gap) pts.pop();
  return pts;
}

Length of a picked path

64-bit

The length of the path in sheet units.

L = Σ |pᵢ − pᵢ₋₁|

A sum of Math.hypot distances added in order, with the closing segment added for a closed path. Bridged gaps count as straight hops. A curved part is the length of its chords.

  • pickpath.js
  • lines 113 to 120
  • TakeoffCraft 0.8.1
/** Length of the path in user units, bridged gaps included (the straight hop across each). */
export function pathLength(path) {
  const pts = pathPoints(path);
  let L = 0;
  for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
  if (path.closed && pts.length > 2) L += Math.hypot(pts[0].x - pts.at(-1).x, pts[0].y - pts.at(-1).y);
  return L;
}

Area of a picked loop

64-bit

The area inside a closed path in square sheet units.

A = |Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)| ÷ 2

Shoelace sum over the vertices using their page coordinates as they are, halved and made positive. The terms are products of absolute coordinates, so a small loop far from the page origin loses digits in the sum.

  • pickpath.js
  • lines 122 to 129
  • TakeoffCraft 0.8.1
/** Area of the closed path's outline in user units squared (shoelace), 0 when it is open. */
export function pathArea(path) {
  if (!path.closed) return 0;
  const pts = pathPoints(path);
  let a = 0;
  for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; a += p.x * q.y - q.x * p.y; }
  return Math.abs(a) / 2;
}

Closed loops around a clicked point

64-bit

Finds the closed paths that have the clicked point inside, smallest first.

Four rays from the point are intersected with every segment: a ray and a segment are parallel when |den| < 1e-12 (absolute), and hits need t > 1e-9 and u within 1e-9 of [0, 1]. The first 12 hits on each ray are chained and kept when the chain closes around the point.

  • pickpath.js
  • lines 163 to 191
  • TakeoffCraft 0.8.1
/**
 * Closed paths that have p inside, smallest first. Looks along four rays from p and follows the segment each meets
 * first; a hit whose chain closes around p is a loop. Reads every segment once per click (never while hovering).
 */
export function loopsAround(idx, p, gap = GAP_DEFAULT, limit = 6) {
  const g = idx.segs, n = idx.n;
  const hits = [];
  for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
    const along = [];                                 // every segment the ray crosses, nearest first: an outer loop lies behind an inner one
    for (let s = 0; s < n; s++) {
      const ax = g[s * 4], ay = g[s * 4 + 1], bx = g[s * 4 + 2], by = g[s * 4 + 3];
      const ex = bx - ax, ey = by - ay, den = dx * ey - dy * ex;
      if (Math.abs(den) < 1e-12) continue;
      const t = ((ax - p.x) * ey - (ay - p.y) * ex) / den, u = ((ax - p.x) * dy - (ay - p.y) * dx) / den;
      if (t > 1e-9 && u >= -1e-9 && u <= 1 + 1e-9) along.push({ s, t });
    }
    along.sort((a, b) => a.t - b.t);
    for (const h of along.slice(0, 12)) hits.push(h.s);
  }
  const out = [], seen = new Set();
  for (const s of hits) {
    if (seen.has(s)) continue;
    const path = chainFrom(idx, s, gap);
    for (const e of path.segs) seen.add(e.s);
    if (path.closed && inside(pathPoints(path), p)) out.push(path);
  }
  out.sort((a, b) => pathArea(a) - pathArea(b));
  return out.slice(0, limit);
}
cadset.js TakeoffCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-takeoffcraft-cad.mjs

A CAD sheet's lines for snapping

64-bit

The drawing's own line segments in page units, through the sheet and viewport matrices.

Each end is one matrix-times-point in 64-bit, stored in a Float64Array. A model segment seen through a viewport is kept when its midpoint is inside the viewport's rectangle, with no tolerance. Arcs are already chords when they arrive here.

  • cadset.js
  • lines 253 to 290
  • TakeoffCraft 0.8.1
/** → { segs: Float64Array [x1,y1,x2,y2…], curve: Uint8Array, n, truncated }. Arcs are already flattened by the compiler: all segments are "straight". */
export function cadLinework(cad, index, { max = 400000 } = {}) {
  const sheet = cad.sheets[index];
  let segs = new Float64Array(4096 * 4), n = 0, truncated = false;
  const push = (ax, ay, bx, by) => {
    if (n >= max) { truncated = true; return; }
    if (!(fin(ax) && fin(ay) && fin(bx) && fin(by)) || (ax === bx && ay === by)) return;
    if (n * 4 >= segs.length) { const g = new Float64Array(Math.min(max * 4, segs.length * 2)); g.set(segs); segs = g; }
    segs[n * 4] = ax; segs[n * 4 + 1] = ay; segs[n * 4 + 2] = bx; segs[n * 4 + 3] = by; n++;
  };
  const shown = (b, vp) => layerShown(cad, b.layer) && !(vp?.frozen?.size && vp.frozen.has(cad.layerHandles.get(b.layer)));
  const take = (space, m, vp = null) => {
    for (const b of space.batches.values()) {
      if (!shown(b, vp)) continue;
      for (const pl of b.lines) {
        for (let i = 0; i + 3 < pl.length && !truncated; i += 2) {
          const ax = m[0] * pl[i] + m[2] * pl[i + 1] + m[4], ay = m[1] * pl[i] + m[3] * pl[i + 1] + m[5];
          const bx = m[0] * pl[i + 2] + m[2] * pl[i + 3] + m[4], by = m[1] * pl[i + 2] + m[3] * pl[i + 3] + m[5];
          if (vp) {   // only what the viewport shows: its middle must be inside the window (page units)
            const r = vp.pageRect, mx = (ax + bx) / 2, my = (ay + by) / 2;
            if (mx < r.x0 || mx > r.x1 || my < r.y0 || my > r.y1) continue;
          }
          push(ax, ay, bx, by);
        }
      }
    }
  };
  if (sheet.kind === 'model') take(sheet.space, sheet.toPage);
  else {
    const model = cad.drawing.spaces.find((s) => s.kind === 'model');
    for (const v of sheet.viewports) if (model) {
      const t0 = sheet.toPage;
      take(model, v.m, { ...v, pageRect: { x0: t0[0] * v.rect.x0 + t0[4], x1: t0[0] * v.rect.x1 + t0[4], y0: t0[3] * v.rect.y0 + t0[5], y1: t0[3] * v.rect.y1 + t0[5] } });
    }
    take(sheet.space, sheet.toPage);
  }
  return { segs: segs.subarray(0, n * 4), curve: new Uint8Array(n), n, truncated };
}
takeoffcraft.js TakeoffCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-takeoffcraft.mjs

Picked path in real units

64-bit

The length and area of what is picked, shown while picking.

ft = L × k; SF = A × k²

Length × ftPerUnit and area × ftPerUnit², added over the picked paths in 64-bit. Rounded for the text only.

  • takeoffcraft.js
  • lines 1063 to 1078
  • TakeoffCraft 0.8.1
/** The live text under the toolbar: lengths and areas of what is picked, the tolerance, how many gaps were bridged. */
function pickReport() {
  const pk = app.pick, sc = cur && model.scaleOf(cur.key);
  if (!pk?.staged.length || !sc) return;
  const k = sc.ftPerUnit, tol = t('sx.su880.tol', { gap: pickGapText() });
  const bridged = (e) => (e.path.gaps.length ? t('sx.su880.bridged', { n: e.path.gaps.length }) : '');
  if (pk.staged.length === 1) {
    const e = pk.staged[0], segs = t('sx.su880.seg', { n: e.path.segs.length }), len = ftIn(PP.pathLength(e.path) * k);
    ui.measure.textContent = e.kind === 'loop' ? t('sx.su880.measure.area', { area: U.fixed(PP.pathArea(e.path) * k * k, 2), len, segs, tol, bridged: bridged(e) })
      : t(e.path.closed ? 'sx.su880.measure.closed' : 'sx.su880.measure.line', { segs, len, tol, bridged: bridged(e) });
  } else {
    let len = 0, area = 0;
    for (const e of pk.staged) { if (e.kind === 'loop') area += PP.pathArea(e.path) * k * k; else len += PP.pathLength(e.path) * k; }
    ui.measure.textContent = t('sx.su880.measure.many', { n: pk.staged.length, len: ftIn(len), area: U.fixed(area, 2), tol });
  }
}

In detail

Method
Path chaining: from the clicked segment, follow the unused segment whose end is within the gap tolerance and bends least, both ways. Length: sum of distances between successive vertices, bridged gaps included. Area: shoelace over the loop's vertices.
Number types
Float64Array for segments; 64-bit numbers elsewhere.
Tolerances
Gap between joined ends: 1.5 PDF units by default, settable from 0.05 to 60. Ends closer than 1e-9 PDF units count as touching (absolute). Intersection: parallel when |den| < 1e-12 × max(1, |r|·|s|); parameters accepted within 1e-9 of [0, 1]. Snap ties within 1e-9.
Limits
For a quarter circle drawn as one curve, the eight chords are about 0.16 % short (x² ÷ 6 with x = π ÷ 32). On a CAD sheet arcs arrive already flattened by the drawing compiler, so a snap lands on chord ends. At most 400,000 segments are read per sheet.

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