Resources Numerical accuracy Calculation policy

Room outline by flood fill

Finds the room around a clicked point on a picture of the sheet and returns its outline as an area mark.

How the precision is kept

The room is found on a raster of the sheet, not on its vectors. The outline runs along pixel edges and is then simplified, so it is accurate to about a pixel plus the 1-pixel simplification tolerance. The raster has at most 2 pixels per sheet display unit. The area itself is then measured in 64-bit from the stored outline.

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.

roomfill.js TakeoffCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Room around a point

64-bit

Flood-fills the room and returns its outline in pixel corners.

The 32-bit ink values are only compared with 0.25 to make a 0 or 1 wall mask; everything after is whole-number pixel work. The pixel count px that is returned is the region before the closing step and is not the area that is reported.

  • roomfill.js
  • lines 40 to 102
  • TakeoffCraft 0.8.1
export function roomAt(ink, seed, { gapPx = 20, on = 0.25, maxShare = 0.6 } = {}) {
  const { data, w, h } = ink;
  const sx = Math.floor(seed.x), sy = Math.floor(seed.y);
  if (sx < 0 || sy < 0 || sx >= w || sy >= h) return { ok: false, why: 'open' };
  const wall = new Uint8Array(w * h);
  for (let i = 0; i < wall.length; i++) wall[i] = data[i] >= on ? 1 : 0;
  if (wall[sy * w + sx]) return { ok: false, why: 'wall' };
  const r = Math.max(1, Math.round(gapPx / 2));
  const closed = dilateBin(wall, w, h, r);
  // the seed may sit within r of a line (a narrow closet, a click near a wall): start from the nearest open pixel
  let start = sy * w + sx;
  if (closed[start]) {
    let best = -1, bd = Infinity;
    for (let y = Math.max(0, sy - r); y <= Math.min(h - 1, sy + r); y++) for (let x = Math.max(0, sx - r); x <= Math.min(w - 1, sx + r); x++) {
      const d = (x - sx) ** 2 + (y - sy) ** 2;
      if (!closed[y * w + x] && d < bd) { bd = d; best = y * w + x; }
    }
    if (best < 0) return { ok: false, why: 'small' };
    start = best;
  }
  // flood fill (4-connected) among the open pixels
  const region = new Uint8Array(w * h);
  const stack = new Int32Array(w * h);
  let sp = 0, n = 0, edge = false;
  region[start] = 1; stack[sp++] = start;
  while (sp) {
    const i = stack[--sp];
    n++;
    const x = i % w, y = (i - x) / w;
    if (x === 0 || y === 0 || x === w - 1 || y === h - 1) edge = true;
    if (x > 0 && !region[i - 1] && !closed[i - 1]) { region[i - 1] = 1; stack[sp++] = i - 1; }
    if (x < w - 1 && !region[i + 1] && !closed[i + 1]) { region[i + 1] = 1; stack[sp++] = i + 1; }
    if (y > 0 && !region[i - w] && !closed[i - w]) { region[i - w] = 1; stack[sp++] = i - w; }
    if (y < h - 1 && !region[i + w] && !closed[i + w]) { region[i + w] = 1; stack[sp++] = i + w; }
  }
  if (edge || n > maxShare * w * h) return { ok: false, why: 'open' };
  // grow back to the lines' faces (not into them, not through a door: only pixels near the filled part)
  const grown = dilateBin(region, w, h, r);
  for (let i = 0; i < grown.length; i++) if (wall[i]) grown[i] = 0;
  // keep the part connected to the seed (what grew past a line's end into another space is dropped)
  const keep = new Uint8Array(w * h);
  sp = 0; keep[start] = 1; stack[sp++] = start;
  let px = 0;
  while (sp) {
    const i = stack[--sp];
    px++;
    const x = i % w, y = (i - x) / w;
    for (const j of [x > 0 ? i - 1 : -1, x < w - 1 ? i + 1 : -1, y > 0 ? i - w : -1, y < h - 1 ? i + w : -1]) {
      if (j >= 0 && grown[j] && !keep[j]) { keep[j] = 1; stack[sp++] = j; }
    }
  }
  if (px < 4 * r * r) return { ok: false, why: 'small' };
  // close slits the grow-back leaves where text or a fixture stands near a wall (dilate, then erode, by half the radius;
  // never into the lines)
  const c = Math.max(1, Math.round(r / 2));
  const dil = dilateBin(keep, w, h, c);
  for (let i = 0; i < dil.length; i++) dil[i] = dil[i] ? 0 : 1;
  const outside = dilateBin(dil, w, h, c);
  for (let i = 0; i < keep.length; i++) keep[i] = !outside[i] && !wall[i] ? 1 : 0;
  const ring = simplify(trace(keep, w, h), 1.0);
  if (ring.length < 3) return { ok: false, why: 'small' };
  return { ok: true, ring, px };
}

Outline traced along pixel edges

Exact

Walks the outer boundary of the filled region, corner by corner.

Whole-number pixel corners only; no floating point. The outline is the staircase boundary of the mask.

  • roomfill.js
  • lines 104 to 125
  • TakeoffCraft 0.8.1
/** The outer boundary of a mask, along pixel edges, keeping the region on the right (clockwise on screen). Corners only. */
export function trace(mask, w, h) {
  let s = -1;
  for (let i = 0; i < mask.length; i++) if (mask[i]) { s = i; break; }
  if (s < 0) return [];
  const inside = (x, y) => x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] === 1;
  const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1];                 // E S W N (y down)
  // the pixels ahead on the right and on the left of a corner, by direction
  const AR = [[0, 0], [-1, 0], [-1, -1], [0, -1]], AL = [[0, -1], [0, 0], [-1, 0], [-1, -1]];
  const x0 = s % w, y0 = (s - x0) / w;
  let x = x0, y = y0, d = 0;
  const out = [];
  for (let guard = 0; guard < 4 * (w + h) * 64 + 16; guard++) {
    const rIn = inside(x + AR[d][0], y + AR[d][1]), lIn = inside(x + AL[d][0], y + AL[d][1]);
    const nd = !rIn ? (d + 1) % 4 : lIn ? (d + 3) % 4 : d;
    if (x === x0 && y === y0 && nd === 0 && out.length) break;
    if (nd !== d || !out.length) out.push({ x, y });
    d = nd;
    x += DX[d]; y += DY[d];
  }
  return out;
}

Outline simplified

64-bit

Removes corners that lie within a tolerance of the line between their neighbors.

d = |(p − a) × (b − a)| ÷ |b − a| ≤ 1.0 px

Douglas–Peucker with the distance |cross| ÷ L in 64-bit and a tolerance of 1.0 pixel passed by roomAt. Kept corners are not moved, so each stays on a pixel corner; the staircase along a slanted wall becomes a line that can sit up to a pixel off it.

  • roomfill.js
  • lines 127 to 145
  • TakeoffCraft 0.8.1
/** Douglas–Peucker on a closed ring. */
export function simplify(ring, eps) {
  if (ring.length < 4) return ring;
  // split at the two points farthest apart, simplify both halves
  let a = 0, b = 0, best = -1;
  for (let i = 0; i < ring.length; i++) { const d = (ring[i].x - ring[0].x) ** 2 + (ring[i].y - ring[0].y) ** 2; if (d > best) { best = d; b = i; } }
  const dp = (pts) => {
    if (pts.length < 3) return pts;
    const p = pts[0], q = pts[pts.length - 1];
    const dx = q.x - p.x, dy = q.y - p.y, L = Math.hypot(dx, dy) || 1;
    let k = -1, m = -1;
    for (let i = 1; i < pts.length - 1; i++) { const d = Math.abs((pts[i].x - p.x) * dy - (pts[i].y - p.y) * dx) / L; if (d > m) { m = d; k = i; } }
    if (m <= eps) return [p, q];
    const l = dp(pts.slice(0, k + 1)), r2 = dp(pts.slice(k));
    return [...l.slice(0, -1), ...r2];
  };
  const first = dp(ring.slice(a, b + 1)), second = dp([...ring.slice(b), ring[a]]);
  return [...first.slice(0, -1), ...second.slice(0, -1)];
}
takeoffcraft.js TakeoffCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-takeoffcraft.mjs

Room outline back in sheet units

64-bit

Sets the raster scale and the door gap in pixels, and maps the outline's pixel corners back to sheet coordinates.

gapPx = gap ÷ ftPerUnit × per × w; p = T⁻¹·(q ÷ w)

w = min(2, 3000 ÷ longer side) pixels per display unit. gapPx = door gap in feet ÷ ftPerUnit × display units per sheet unit × w. Each corner is ÷ w and then through the inverse page transform in 64-bit. The mark's area comes from measuring those points.

  • takeoffcraft.js
  • lines 3298 to 3324
  • TakeoffCraft 0.8.1
async function fillRoom(p) {
  if (roomBusy) return;
  const sheet = cur, sc = model.scaleOf(sheet.key), it = model.item(app.active);
  if (!sc || !it) return;
  roomBusy = true;
  try {
    const w = Math.min(2, 3000 / Math.max(sheet.info.width, sheet.info.height));
    if (!roomInk || roomInk.key !== sheet.key || roomInk.w !== w) {
      busy(t('room.reading'));
      try { roomInk = { key: sheet.key, w, ink: await rasterize(sheet, w) }; } finally { busy(null); }
    }
    if (cur !== sheet) return;
    const T = sheet.info.transform, per = Math.hypot(T[0], T[1]);        // display units per user unit
    const seed = G.apply(T, p);
    const gapPx = settings.roomGap / sc.ftPerUnit * per * w;
    const r = roomAt(roomInk.ink, { x: seed.x * w, y: seed.y * w }, { gapPx });
    if (!r.ok) { status(t('room.err.' + r.why, { gap: U.formatFtIn(settings.roomGap) }), 9000); return; }
    const inv = G.invert(T);
    const pts = r.ring.map((q) => G.apply(inv, { x: q.x / w, y: q.y / w }));
    const why = G.outlineProblem(pts);
    if (why) { status(why, 9000); return; }
    app.sel = model.addMark({ item: it.id, sheet: sheet.key, pts, kind: 'area', ...inArea() });
    const m = measureMark(model.mark(app.sel), sc.ftPerUnit, it);
    status(t('room.done', { sf: U.fixed(m.areaSqft, 2), n: pts.length, mod: modKey }), 9000);
    renderMarkProps();
  } finally { roomBusy = false; }
}

In detail

Method
Lines are thickened by half the door-gap, the open region is flood-filled from the click (4-connected), grown back to the lines' faces, closed, traced along pixel edges, and simplified with Douglas–Peucker.
Number types
The ink raster is a Float32Array of darkness 0 to 1, compared with a threshold once; masks are Uint8Array; the outline's corners are whole pixel numbers, then 64-bit sheet coordinates.
Tolerances
Ink at or above 0.25 is a line. Door gaps up to gapPx pixels are closed (radius max(1, round(gapPx ÷ 2))). Simplification tolerance 1.0 pixel. A fill that reaches the sheet's edge or covers more than 60 % of it is refused.
Limits
Raster scale is min(2, 3000 ÷ the sheet's longer side) pixels per display unit, so a pixel is at least half a display unit. The outline follows the faces of the lines as they were rasterized, not their centerlines.

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