Picks the scale of each plan, elevation and section, places it on its sheet, and writes the sheet's lines, fills and text into the PDF.
How the precision is kept
A drawing point is mapped to the sheet by one multiplication by 1 ÷ ratio and one addition, in 64-bit millimeters. The PDF converts millimeters to points with 72 ÷ 25.4 and writes path coordinates with 6 decimals of a point; line widths, text positions and the text rotation matrix are written with 3 decimals. Sheet positions that a person or the automatic arrangement sets are stored rounded to 0.1 or 0.01 mm.
Open item. Write the text matrix in the PDF with the same 6 decimals as the path coordinates.
Calculations under this policy
14 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.
Chooses the largest standard scale at which a drawing of a given width and height fits the space on the sheet.
first ratio with w ÷ r ≤ room_w and h ÷ r ≤ room_h; else r = ⌈max(w ÷ room_w, h ÷ room_h)⌉
The ratios are whole numbers from a fixed list (1:20 to 1:1000, or 3/4 inch to 1/32 inch to the foot). The comparison has no tolerance, so a drawing that exactly fills the space fits. When no listed scale fits, the ratio is rounded up to a whole number.
res-sheets.js
lines 48 to 54
BimCraft 0.8.1
export function scaleFor(w, h, room, units = 'ftin', minRatio = 0) {
const list = SCALES[isMetric(units) ? 'mm' : 'ftin'].filter(([r]) => r >= minRatio);
const s = list.find(([r]) => w / r <= room[0] && h / r <= room[1]);
if (s) return { ratio: s[0], label: s[1] };
const r = Math.ceil(Math.max(w / room[0], h / room[1]));
return { ratio: r, label: '1:' + r }; // i18n-ignore: a scale ratio
}
Placing a drawing in its cell
64-bit
Centers a drawing in its space on the sheet at the chosen scale and gives the mapping from drawing millimeters to sheet millimeters.
k = 1 ÷ ratio; sheet = (x + (w − W k) ÷ 2 − x₀ k) + p × k
The scale factor is 1 ÷ ratio, which is not exact in binary for most ratios, and every point is multiplied by it once. The offset multiplies the drawing's minimum corner by the same factor, so a drawing far from the origin subtracts two large sheet values to place itself.
res-sheets.js
lines 215 to 227
BimCraft 0.8.1
/** A drawing cell: content in its own millimetres with bounds, drawn at the scale that fits the space (or a shared one). */
function drawCell(title, bb, units, drawWith, forced = null, minRatio = 0) {
const W = Math.max(1, bb.x1 - bb.x0), H = Math.max(1, bb.y1 - bb.y0);
const cell = { title, scale: null, bb, draw: null };
cell.draw = (x, y, w, h) => {
const sc = forced ? { ratio: forced, label: SCALES[isMetric(units) ? 'mm' : 'ftin'].find((s) => s[0] === forced)?.[1] || '1:' + forced } : scaleFor(W, H, [w, h], units, minRatio); // i18n-ignore: a scale ratio
cell.scale = sc.label;
const k = 1 / sc.ratio, ox = x + (w - W * k) / 2 - bb.x0 * k, oy = y + (h - H * k) / 2 - bb.y0 * k;
cell.place = { P: at(k, ox, oy), ratio: sc.ratio, label: sc.label };
return drawWith(at(k, ox, oy), k);
};
return cell;
}
A view placed by hand on a sheet
64-bit
Works out the scale and the box of a view placed at a given point of a sheet.
ratio = placed ratio, else the view's own, else the scale that fits a third of the sheet; box = middle ± (W ÷ ratio) ÷ 2
The view's size on paper is its bounds times 1 ÷ ratio, and its corner is the stored middle minus half of that. The middle was stored rounded to 2 decimals of a millimeter by the sheet writer.
Picks one standard scale for all the drawn views of a sheet and packs them in rows.
first ratio at which every view fits; x, y = round(middle × 10) ÷ 10
Each view's middle is rounded to 0.1 mm of paper before it is returned. The fit test compares the packed height with the top of the free area with no tolerance.
res-sheets.js
lines 334 to 359
BimCraft 0.8.1
/** 0.8 EG.56: "Arrange for me". Picks ONE standard scale for the sheet's drawn views (the largest from the list, but never larger than 1:48 / 1:50, at which every
* view fits the free area) and shelf-packs them: tallest first, left to right, each row's views sharing a baseline, a label strip under every view.
* Schedules keep their size. → { views (the spec's views with new x, y, ratio), ratio, label, fits, rows }. Nothing is dropped: if even the smallest scale
* does not fit, `fits` is false and the views run past the top (the person changes the paper). */
export function arrangeSheet(spec, input, { paper = 'tabloid', units = 'ftin', gap = 8 } = {}) {
const [PW, PH] = PAPERS[paper] || PAPERS.tabloid;
const left = 16, right = PW - 16 - (spec.legend?.length ? LEGEND_W : 0), bottom = 10 + 18 + 6, top = PH - 14, strip = 14;
const list = SCALES[isMetric(units) ? 'mm' : 'ftin'].map(([r, l]) => ({ ratio: r, label: l })).filter((x) => x.ratio >= (isMetric(units) ? 50 : 48)).sort((a, b) => a.ratio - b.ratio);
const attempt = (ratio) => {
const v2 = (spec.views || []).map((v) => ({ ...v, ratio: v.kind === 'schedule' ? 0 : ratio }));
const c = composeSheet({ ...spec, views: v2 }, input, { paper, units });
const items = c.views.map((x) => ({ i: x.i, w: x.box[2] - x.box[0], h: x.box[3] - x.box[1] })).sort((a, b) => b.h - a.h || a.i - b.i);
let x = left, y = bottom, rowH = 0, rows = 1;
for (const it of items) {
if (x > left && x + it.w > right) { x = left; y += rowH + strip + gap; rowH = 0; rows++; }
it.x0 = x; it.y0 = y + strip; x += it.w + gap; rowH = Math.max(rowH, it.h);
}
const used = y + strip + rowH;
for (const it of items) { const v = v2[it.i]; v.x = Math.round((it.x0 + it.w / 2) * 10) / 10; v.y = Math.round((it.y0 + it.h / 2) * 10) / 10; }
return { views: v2, fits: used <= top && items.every((it) => it.w <= right - left), rows };
};
let pick = null;
for (const sc of list) { const r = attempt(sc.ratio); if (r.fits) { pick = { ...r, ratio: sc.ratio, label: sc.label }; break; } }
if (!pick) { const sc = list[list.length - 1] || { ratio: 100, label: '1:100' }; pick = { ...attempt(sc.ratio), ratio: sc.ratio, label: sc.label }; }
return pick;
}
Arcs drawn as chords
64-bit
Turns each arc of a plan symbol, such as a door swing, into a polyline for the sheet.
steps = max(8, ⌈|a₁ − a₀| ÷ (π ÷ 32)⌉)
An arc is at least 8 chords and each chord spans at most π ÷ 32 radians. The chord ends are on the arc; between them the drawn line lies inside the arc.
res-sheets.js
line 77
BimCraft 0.8.1
for (const a of s.arcs || []) { const steps = Math.max(8, Math.ceil(Math.abs(a.a1 - a.a0) / (Math.PI / 32))); const pts = []; for (let i = 0; i <= steps; i++) { const t = a.a0 + (a.a1 - a.a0) * i / steps; pts.push(P([a.c[0] + a.r * Math.cos(t), a.c[1] + a.r * Math.sin(t)])); } out.push({ t: 'line', pts, w: 0.18, g: 0 }); }
Coordinates written to the PDF
64-bit
Formats a path coordinate for the PDF.
text = Number(v.toFixed(6))
Path coordinates are written in points with 6 decimals, that is 1e-6 point or about 3.5e-7 mm of paper.
res-sheets.js
line 388
BimCraft 0.8.1
const coord = v => String(Number(v.toFixed(6)));
Widths, text positions and matrices written to the PDF
64-bit
Formats line widths, dash lengths, clip rectangles, text sizes and the text matrix for the PDF.
text = v.toFixed(3), with |v| < 5e-5 written as 0
These values are written with 3 decimals. For the text matrix this rounds the cosine and sine of a rotated text, such as a dimension's, to 3 decimals, and its position to 0.001 point.
Converts every line, fill, clip and text of a sheet from millimeters to PDF points and writes the page's drawing operators.
points = mm × 72 ÷ 25.4
Each coordinate is multiplied by 72 ÷ 25.4 once and then formatted. Lines and fills use the 6-decimal format; the clip rectangle, the line width and the text matrix use the 3-decimal format. Text is offset by a width from a built-in table of Helvetica character widths.
res-sheets.js
lines 389 to 403
BimCraft 0.8.1
function pageOps(items, names = new Map()) {
const ops = ['1 J 1 j'];
for (const it of items) {
if (it.t === 'image') { const nm = names.get(it); if (nm) ops.push(PDFI.drawOps({ im: nm.im, gs: nm.gs, cm: PDFI.unitMatrix(it.m, it.w, it.h).map((v) => v * PT) })); continue; }
if (it.t === 'fill') { ops.push(it.color ? colorRgb(it.color).map(coord).join(' ') + ' rg' : num(it.g) + ' g'); for (const p of it.polys) { p.forEach((q, i) => ops.push(coord(q[0] * PT) + ' ' + coord(q[1] * PT) + (i ? ' l' : ' m'))); ops.push('h'); } ops.push('f*'); }
else if (it.t === 'line') { ops.push(num(it.w * PT) + ' w ' + (it.color ? colorRgb(it.color).map(coord).join(' ') + ' RG ' : num(it.g || 0) + ' G ') + (it.dash ? '[' + it.dash.map((d) => num(d * PT)).join(' ') + '] 0 d' : '[] 0 d')); it.pts.forEach((q, i) => ops.push(coord(q[0] * PT) + ' ' + coord(q[1] * PT) + (i ? ' l' : ' m'))); ops.push('S'); }
else if (it.t === 'clip') { const [x0, y0, x1, y1] = it.rect; ops.push('q ' + [x0, y0, x1 - x0, y1 - y0].map((v) => num(v * PT)).join(' ') + ' re W n'); } // i18n-ignore: PDF operators
else if (it.t === 'unclip') ops.push('Q');
else if (it.t === 'text') {
const w = textW(it.s, it.size), dx = it.align === 'left' ? 0 : it.align === 'right' ? -w : -w / 2, rot = it.rot || 0, c = Math.cos(rot), sn = Math.sin(rot);
ops.push('BT /' + (it.bold ? 'F2' : 'F1') + ' ' + num(it.size) + ' Tf ' + num(it.g || 0) + ' g ' + [c, sn, -sn, c, it.x * PT + dx * c, it.y * PT + dx * sn].map(num).join(' ') + ' Tm ' + lit(it.s) + ' Tj ET'); // i18n-ignore: PDF text operators
}
}
return ops.join('\n');
}
Direction a section line looks
64-bit
The unit direction along a section line and the horizontal direction it looks.
u = (b − a) ÷ |b − a|; look = (−u_y, u_x) or (u_y, −u_x)
Uses Math.hypot for the length; a zero length is replaced by 1. The look direction is the unit vector turned a quarter turn, so it stays a unit vector to rounding.
res-sheets.js
lines 497 to 501
BimCraft 0.8.1
/** The plane of a section line: o, the way it looks (unit, horizontal, plan of its level). */
export function sectionDir(sl) {
const d = [sl.b[0] - sl.a[0], sl.b[1] - sl.a[1]], L = Math.hypot(...d) || 1, u = [d[0] / L, d[1] / L];
return { o: sl.a, dir: sl.look === 'right' ? [u[1], -u[0]] : [-u[1], u[0]], u };
}
Section line written to the model
64-bit
Writes a section line's two ends, its look side and its depth into the model.
AX, AY, BX, BY, Depth = round(value ÷ unit, 6)
The ends and the depth are stored rounded to 6 decimals of the file's length unit and read back by multiplying with the unit in millimeters.
Lays a ceiling pattern's grid lines inside a ceiling's outline, with a tile centered on the outline's box.
lines at c + m ÷ 2 + j × m; crossing = q_a + (q_b − q_a) × (k − p_a) ÷ (p_b − p_a)
The first line is computed from the box center and the later ones by adding the tile size repeatedly. A line within 1e-6 mm of the box edge is left out, crossings are sorted and paired, and a piece of 0.5 mm or less is dropped.
views-model.js
lines 296 to 321
BimCraft 0.8.1
/** 0.6 DC.117: a ceiling pattern's grid lines inside a ceiling's outline. poly [[x, y]…], holes [poly…], pattern { u, v } (mm
* along x and y; 0 = none). A tile is centred on the outline's box; each line is cut where it crosses the outline (even-odd). */
export function ceilingGrid(poly, holes = [], pattern = null) {
const out = [];
if (!pattern || !(pattern.u > 0) || !(pattern.v > 0) || !poly?.length) return out;
const rings = [poly, ...(holes || [])];
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of poly) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
// the spans of the line {axis = k} inside the rings: crossings sorted, taken in pairs
const spans = (k, vertical) => {
const xs = [];
for (const r of rings) for (let i = 0; i < r.length; i++) {
const a = r[i], b = r[(i + 1) % r.length];
const [pa, qa, pb, qb] = vertical ? [a[0], a[1], b[0], b[1]] : [a[1], a[0], b[1], b[0]];
if ((pa > k) === (pb > k)) continue;
xs.push(qa + (qb - qa) * (k - pa) / (pb - pa));
}
xs.sort((m, n) => m - n);
for (let i = 0; i + 1 < xs.length; i += 2) if (xs[i + 1] - xs[i] > 0.5) out.push(...(vertical ? [k, xs[i], k, xs[i + 1]] : [xs[i], k, xs[i + 1], k]));
};
const lines = (c, lo, hi, m, vertical) => { for (let k = c + m / 2 - Math.ceil((c + m / 2 - lo) / m) * m; k <= hi; k += m) if (k > lo + 1e-6 && k < hi - 1e-6) spans(k, vertical); };
lines(cx, x0, x1, pattern.u, true);
lines(cy, y0, y1, pattern.v, false);
return out;
}
Crop region of a view
64-bit
Orders the four numbers of a crop rectangle and rejects one that is not a real rectangle.
valid when x₁ − x₀ ≥ 100 and y₁ − y₀ ≥ 100
Only minimum and maximum are taken, so the values are unchanged. All four zero means no crop.
views-model.js
lines 64 to 71
BimCraft 0.8.1
/** A crop region from its four numbers (mm): ordered, or null when there is none (all 0) or it is not a real rectangle. */
export function cropOf(c) {
if (!Array.isArray(c) || c.length !== 4 || !c.every((v) => Number.isFinite(Number(v)))) return null;
const [a, b, d, e] = c.map(Number);
if (!a && !b && !d && !e) return null;
const r = [Math.min(a, d), Math.min(b, e), Math.max(a, d), Math.max(b, e)];
return r[2] - r[0] >= 100 && r[3] - r[1] >= 100 ? r : null;
}
View positions stored on a sheet
64-bit
Stores where each view sits on a hand-arranged sheet and at which scale.
x, y = round(value, 2) mm; ratio = whole number
A view's middle is stored rounded to 2 decimals of a millimeter of paper and its ratio rounded to a whole number.
Moves another level's plan linework into this level's plan as plain segments.
p′ = (T₀ x + T₄ y + T₁₂, T₁ x + T₅ y + T₁₃); arcs as 12 chords
Each point goes through the 4×4 matrix once. Every arc is drawn as 12 chords whatever its radius or angle.
views-model.js
lines 277 to 294
BimCraft 0.8.1
/** 0.6 DC.117: what an underlay draws — another level's walls (their joined outline), cut elements and door / window symbols
* (arcs as 12 chords) as segments in this level's plan; T maps that level's plan into this one (column-major 4×4). */
export function underlaySegments(sc, T) {
const P = (x, y) => [T[0] * x + T[4] * y + T[12], T[1] * x + T[5] * y + T[13]];
const out = [];
const line = (a, b) => { const p = P(a[0], a[1]), q = P(b[0], b[1]); out.push(p[0], p[1], q[0], q[1]); };
const segs = (f) => { for (let i = 0; i + 3 < f.length; i += 4) line([f[i], f[i + 1]], [f[i + 2], f[i + 3]]); };
for (const pl of sc.wallEdges || (sc.walls || []).flatMap((w) => w.pieces || [])) for (let i = 0; i < pl.length; i++) line(pl[i], pl[(i + 1) % pl.length]);
for (const c of sc.cut || []) segs(c.segs);
for (const s of sc.symbols || []) {
for (const l of [...(s.lines || []), ...(s.thin || [])]) segs(l);
for (const a of s.arcs || []) {
const n = 12;
for (let i = 0; i < n; i++) { const t0 = a.a0 + (a.a1 - a.a0) * i / n, t1 = a.a0 + (a.a1 - a.a0) * (i + 1) / n; line([a.c[0] + a.r * Math.cos(t0), a.c[1] + a.r * Math.sin(t0)], [a.c[0] + a.r * Math.cos(t1), a.c[1] + a.r * Math.sin(t1)]); }
}
}
return out;
}
In detail
Method
The largest standard scale at which the drawing's bounds fit the space; a whole-number 1:n when none fits. Sheet point = origin + drawing point ÷ ratio.
Number types
64-bit numbers. Scale ratios are whole numbers. One point is 25.4 ÷ 72 mm, both exact.
Tolerances
PDF numbers written with 3 decimals are set to 0 when below 5e-5. Section lines shorter than 100 mm are refused. A crop region must be at least 100 mm each way.
Limits
Door swings and other arcs are drawn as chords. Text widths use a built-in table of Helvetica widths, so text is centered by that table, not by the reader's font.
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.