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