Resources Numerical accuracy Calculation policy

Walls and openings from PDF pages and scans

Reads the lines and door-swing arcs of a plan page, scales them to millimeters from two picked points, and turns them into walls with door and window openings.

How the precision is kept

Page coordinates are multiplied by one scale factor into 64-bit millimeters. Curves are recognized as arcs when three sampled points fit a circle within 1.2 % of its radius. Walls, merged runs and openings are found with fixed tolerances in millimeters and angles, so the result follows the page within those tolerances; a scanned page is also limited by its pixels.

Calculations under this policy

9 calculations in 1 source file. 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.

plan-pages.js BimCraft 0.8.1 64-bit 9 calculations Reviewed

Tests tools/bimcraft-test/plans-test.mjs

Scale from two points and a distance

64-bit

How many millimeters one page unit is, from two picked points and the true distance between them.

scale = distance in mm ÷ √(Δx² + Δy²)

One Math.hypot and one division; returns 0 when the points coincide or the distance is not positive.

  • plan-pages.js
  • line 101
  • BimCraft 0.8.1
export const mmPerUnit = (a, b, distMm) => { const d = Math.hypot(b[0] - a[0], b[1] - a[1]); return d > 0 && distMm > 0 ? distMm / d : 0; };

Circle through three points

64-bit

The center and radius of the circle through three points.

d = 2(p_x(q_y − r_y) + q_x(r_y − p_y) + r_x(p_y − q_y)); center from the squared lengths ÷ d

The formula uses the squared distances of the points from the origin, so for points far from the origin the numerators subtract large nearly equal numbers. The test |d| < 1e-9 is absolute, in page units squared, and is not scaled to the size of the triangle.

  • plan-pages.js
  • lines 29 to 36
  • BimCraft 0.8.1
/** The circle through three points, or null. */
function circle3(p, q, r) {
  const d = 2 * (p[0] * (q[1] - r[1]) + q[0] * (r[1] - p[1]) + r[0] * (p[1] - q[1]));
  if (Math.abs(d) < 1e-9) return null;
  const a = p[0] * p[0] + p[1] * p[1], b = q[0] * q[0] + q[1] * q[1], c = r[0] * r[0] + r[1] * r[1];
  const cx = (a * (q[1] - r[1]) + b * (r[1] - p[1]) + c * (p[1] - q[1])) / d, cy = (a * (r[0] - q[0]) + b * (p[0] - r[0]) + c * (q[0] - p[0])) / d;
  return { cx, cy, r: Math.hypot(p[0] - cx, p[1] - cy) };
}

Arc from a Bézier curve

64-bit

Recognizes a cubic curve that is a circular arc and gives its center, radius, start angle and sweep.

circle through P(0), P(0.5), P(1); accepted when |dist(P(t), center) − r| ≤ 0.012 r at t = 0.25 and 0.75

The radius must be between 0.3 and 400 chord lengths. Angles are Math.atan2 values wrapped to 0 … 2π, and the sweep direction is chosen so that the curve's middle point lies inside it.

  • plan-pages.js
  • lines 39 to 49
  • BimCraft 0.8.1
/** A cubic Bézier that is (within 1 %) a circular arc → { cx, cy, r, a0, sweep } (counterclockwise), else null. */
function arcOfBezier(P0, P1, P2, P3) {
  const chord = len(P0, P3);
  if (!(chord > 1e-6)) return null;
  const M = bez(P0, P1, P2, P3, 0.5), c = circle3(P0, M, P3);
  if (!c || !(c.r > chord * 0.3) || c.r > chord * 400) return null;
  for (const t of [0.25, 0.75]) { const q = bez(P0, P1, P2, P3, t); if (Math.abs(Math.hypot(q[0] - c.cx, q[1] - c.cy) - c.r) > 0.012 * c.r) return null; }
  const A0 = Math.atan2(P0[1] - c.cy, P0[0] - c.cx), AM = Math.atan2(M[1] - c.cy, M[0] - c.cx), A1 = Math.atan2(P3[1] - c.cy, P3[0] - c.cx);
  const ccw = norm(A1 - A0);
  return norm(AM - A0) <= ccw ? { ...c, a0: A0, sweep: ccw } : { ...c, a0: A1, sweep: norm(A0 - A1) };
}

Page lines and arcs in millimeters

64-bit

Scales a PDF page's line segments and arcs to millimeters and finds their bounds.

x_mm = x_pt × mm per point

Each coordinate, center and radius is multiplied once by the scale; angles are unchanged.

  • plan-pages.js
  • lines 87 to 90
  • BimCraft 0.8.1
const k = mmPerPt;
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
const S = segs.map((q) => { const r = [q[0] * k, q[1] * k, q[2] * k, q[3] * k]; x0 = Math.min(x0, r[0], r[2]); x1 = Math.max(x1, r[0], r[2]); y0 = Math.min(y0, r[1], r[3]); y1 = Math.max(y1, r[1], r[3]); return r; });
return { source: 'pdf', segs: S, arcs: arcs.map((a) => ({ cx: a.cx * k, cy: a.cy * k, r: a.r * k, a0: norm(a.a0), sweep: a.sweep })), bounds: Number.isFinite(x0) ? { x0, y0, x1, y1 } : null, curves };

Scan lines and arcs in millimeters

64-bit

Scales a traced scan from pixels to millimeters and turns its y axis up.

x = px × k; y = (height in px − py) × k

The y flip subtracts in pixels before the multiplication. Arc start angles are negated and wrapped for the flipped axis.

  • plan-pages.js
  • lines 93 to 99
  • BimCraft 0.8.1
export function scanParts(res, { mmPerPx = 1, hPx = 0 } = {}) {
  const k = mmPerPx, Y = (y) => (hPx - y) * k;
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  const segs = res.lines.map((l) => { const r = [l.x1 * k, Y(l.y1), l.x2 * k, Y(l.y2)]; x0 = Math.min(x0, r[0], r[2]); x1 = Math.max(x1, r[0], r[2]); y0 = Math.min(y0, r[1], r[3]); y1 = Math.max(y1, r[1], r[3]); return r; });
  const arcs = res.arcs.filter((a) => a.a1 - a.a0 >= 0.5 && a.a1 - a.a0 <= Math.PI * 1.2).map((a) => ({ cx: a.cx * k, cy: Y(a.cy), r: a.r * k, a0: norm(-a.a1), sweep: a.a1 - a.a0 }));
  return { source: 'scan', segs, arcs, bounds: Number.isFinite(x0) ? { x0, y0, x1, y1 } : null, curves: res.arcs.length };
}

Joining lines cut at a tee

64-bit

Merges lines of one straight run that are split by a joining wall.

merged when |cos angle| ≥ cos 1.2°, both ends within 35 mm of the first line, and the gap ≤ 350 mm

The merged line keeps the first line's start point and direction; the second line's ends are projected on it, so their offset of up to 35 mm across is dropped. Lines shorter than 1 mm are skipped. At most 40 passes are made.

  • plan-pages.js
  • lines 125 to 158
  • BimCraft 0.8.1
/**
 * Lines of one straight run that a joining wall cut (a face line the trace or the drawing stops at each side of a T) are one line:
 * collinear (within `tol` mm, `angDeg`°) with at most `gap` mm between them. A door's gap is wider than that.
 */
export function joinCollinearMm(segs, { tol = 35, gap = 350, angDeg = 1.2 } = {}) {
  let L = segs.map((s) => s.slice(0, 4));
  const cos = Math.cos(angDeg * Math.PI / 180);
  let changed = true, guard = 0;
  while (changed && guard++ < 40) {
    changed = false;
    const dead = new Set();
    for (let i = 0; i < L.length; i++) {
      if (dead.has(i)) continue;
      const a = L[i], la = Math.hypot(a[2] - a[0], a[3] - a[1]);
      if (la < 1) continue;
      const u = [(a[2] - a[0]) / la, (a[3] - a[1]) / la], n = [-u[1], u[0]];
      for (let j = i + 1; j < L.length; j++) {
        if (dead.has(j)) continue;
        const b = L[j], lb = Math.hypot(b[2] - b[0], b[3] - b[1]);
        if (lb < 1 || Math.abs(((b[2] - b[0]) * u[0] + (b[3] - b[1]) * u[1]) / lb) < cos) continue;
        if (Math.abs((b[0] - a[0]) * n[0] + (b[1] - a[1]) * n[1]) > tol || Math.abs((b[2] - a[0]) * n[0] + (b[3] - a[1]) * n[1]) > tol) continue;
        const sa = [0, la].sort((p, q) => p - q), sb = [(b[0] - a[0]) * u[0] + (b[1] - a[1]) * u[1], (b[2] - a[0]) * u[0] + (b[3] - a[1]) * u[1]].sort((p, q) => p - q);
        const g = Math.max(sb[0] - sa[1], sa[0] - sb[1]);
        if (g > gap) continue;
        const lo = Math.min(sa[0], sb[0]), hi = Math.max(sa[1], sb[1]);
        L[i] = [a[0] + u[0] * lo, a[1] + u[1] * lo, a[0] + u[0] * hi, a[1] + u[1] * hi];
        dead.add(j); changed = true;
        break;
      }
    }
    if (dead.size) L = L.filter((_, k) => !dead.has(k));
  }
  return L;
}

Hatching detection

64-bit

Finds bundles of short parallel lines so they are not taken for walls.

Line directions are binned in 3° steps with Math.round, so two lines either side of a bin boundary land in different bins. A bundle needs at least 5 lines no more than 300 mm and more than 1 mm apart.

  • plan-pages.js
  • lines 103 to 123
  • BimCraft 0.8.1
/** Bundles of short parallel lines (hatching): their indexes. */
function hatchOf(segs, { maxLen = 4500, gap = 300, minRun = 5 } = {}) {
  const cand = [];
  segs.forEach((s, i) => { const L = Math.hypot(s[2] - s[0], s[3] - s[1]); if (L <= maxLen) cand.push({ i, a: ((Math.atan2(s[3] - s[1], s[2] - s[0]) % Math.PI) + Math.PI) % Math.PI }); });
  const bins = new Map();
  for (const c of cand) { const b = Math.round(c.a / (3 * Math.PI / 180)) % 60; if (!bins.has(b)) bins.set(b, []); bins.get(b).push(c); }
  const out = new Set();
  for (const list of bins.values()) {
    if (list.length < minRun) continue;
    const a = list.reduce((s, c) => s + c.a, 0) / list.length, nx = -Math.sin(a), ny = Math.cos(a), ux = Math.cos(a), uy = Math.sin(a);
    const it = list.map((c) => { const s = segs[c.i]; return { i: c.i, off: (s[0] + s[2]) / 2 * nx + (s[1] + s[3]) / 2 * ny, t0: Math.min(s[0] * ux + s[1] * uy, s[2] * ux + s[3] * uy), t1: Math.max(s[0] * ux + s[1] * uy, s[2] * ux + s[3] * uy) }; }).sort((p, q) => p.off - q.off);
    let run = [it[0]];
    const flush = () => { if (run.length >= minRun) for (const r of run) out.add(r.i); };
    for (let k = 1; k < it.length; k++) {
      const p = run[run.length - 1], q = it[k];
      if (q.off - p.off <= gap && q.off - p.off > 1 && Math.min(q.t1, p.t1) - Math.max(q.t0, p.t0) > -gap) run.push(q); else { flush(); run = [q]; }
    }
    flush();
  }
  return out;
}

Merged wall line and thickness

64-bit

The center line and thickness of a wall made of collinear pieces with openings between them.

t = Σ tᵢ·Lᵢ ÷ Σ Lᵢ; offset = Σ offsetᵢ·Lᵢ ÷ Σ Lᵢ; A, B = base + u·s + n·offset

Thickness and sideways offset are length-weighted means of the pieces, summed in order. The direction is the first piece's, so pieces up to 2° off it are straightened onto that line.

  • plan-pages.js
  • lines 202 to 207
  • BimCraft 0.8.1
const tot = rr.reduce((s, x) => s + (x.s1 - x.s0), 0), tAvg = rr.reduce((s, x) => s + x.t * (x.s1 - x.s0), 0) / tot;
// the wall's line: the first piece's centre line pushed onto the average offset of the pieces
const off = rr.reduce((s, x) => { const w = walls[x.k]; return s + dot(sub(w.a, base.a), n) * (x.s1 - x.s0); }, 0) / tot;
const A = [base.a[0] + u[0] * rr[0].s0 + n[0] * off, base.a[1] + u[1] * rr[0].s0 + n[1] * off];
const B = [base.a[0] + u[0] * rr[rr.length - 1].s1 + n[0] * off, base.a[1] + u[1] * rr[rr.length - 1].s1 + n[1] * off];
const wall = { a: A, b: B, t: tAvg, gaps: [] };

Squaring a scanned page

64-bit

Decides whether a scan is turned back before it is traced.

turned by the found angle when |angle| > 0.15° and confidence > 1.6

The angle comes from the raster library's findSkew. In the automatic mode a scan skewed by 0.15° or less, or one whose skew is found with low confidence, is traced as it is, so its lines keep that skew.

  • plan-pages.js
  • lines 287 to 303
  • BimCraft 0.8.1
/**
 * A picture (a scan, or a raster PDF page drawn at some dpi: { w, h, channels, data }) → its trace, ready for scanParts. The sheet is turned
 * square first when it was scanned crooked (`deskew`: 'auto' turns it when the angle found is over 0.15° and the sheet has long lines,
 * a number turns it by that, false leaves it). `tol` is in pixels.
 */
export function tracePicture(img, { deskew = 'auto', tol = 1.5, specks = 8, win = null } = {}) {
  let grey = toGrey(img);
  let bm = threshold(grey, { mode: 'auto' });
  const found = findSkew(bm);
  let deg = 0;
  if (typeof deskew === 'number') deg = deskew;
  else if (deskew === 'auto' && Math.abs(found.deg) > 0.15 && found.confidence > 1.6) deg = found.deg;
  if (deg) { grey = rotateImage({ w: grey.w, h: grey.h, channels: 1, data: grey.data }, -deg, { fill: 255 }); bm = threshold(grey, { mode: 'auto' }); }
  if (specks > 0) despeckle(bm, { maxArea: specks });
  const result = trace(bm, { tol, win: win || undefined });
  return { bitmap: bm, skew: { deg: found.deg, confidence: found.confidence, applied: deg }, result };
}

In detail

Method
Circle through three points; Bézier sampled at t = 0.25, 0.5, 0.75; collinear line merging; hatch detection by 3° direction bins; wall pairing from model-trace.js; gaps between collinear walls become openings; length-weighted averages for a merged wall's thickness and offset.
Number types
64-bit numbers. Scan input is in pixels from the raster trace.
Tolerances
Arc fit 0.012 × r; arc sweep 0.5 to 1.2 π rad. Arc joining: centers and radii within 1 % of r, end angles within 0.02 rad. Collinear merge: 35 mm across, 350 mm gap, 1.2°. Wall grouping: |sin| ≤ 0.035 (2°), offset within 0.4 × thickness. Opening gap 500 mm to 2600 mm; door up to 1300 mm. Deskew applied above 0.15° with confidence above 1.6. circle3 treats a determinant below 1e-9 (absolute) as three points in a line.
Limits
The scale comes from two picked points, so every length carries the error of that pick. A merged wall takes the first piece's direction.

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