Resources Numerical accuracy Calculation policy

Drawing sheets to scale and the PDF

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.

res-sheets.js BimCraft 0.8.1 64-bit 10 calculations Reviewed

Tests tools/bimcraft-test/res-test.mjs, tools/bimcraft-test/views2-test.mjs, tools/bimcraft-test/views09-test.mjs

Scale that fits

64-bit

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.

  • res-sheets.js
  • lines 314 to 319
  • BimCraft 0.8.1
  ratio = v.ratio > 0 ? v.ratio : src.scene?.scale > 0 ? src.scene.scale : src.view?.scale > 0 ? src.view.scale : scaleFor(W, H, [PW / 3, PH / 3], units, isMetric(units) ? 50 : 48).ratio;
  const k = 1 / ratio;
  w = W * k; h = H * k;
  draw = (x0, y0) => { const P = at(k, x0 - bb.x0 * k, y0 - bb.y0 * k); return planLike(v.kind) ? planItems(src.scene, P, k) : viewItems(src.view, P, k); };
}
const x0 = v.x - w / 2, y0 = v.y - h / 2;

Arranging the views of a sheet

64-bit

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.

  • res-sheets.js
  • line 387
  • BimCraft 0.8.1
const num = (v) => { const s = (Math.abs(v) < 5e-5 ? 0 : v).toFixed(3); return s.replace(/\.?0+$/, '') || '0'; };

Sheet items to PDF operators

64-bit

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.

  • res-sheets.js
  • line 517
  • BimCraft 0.8.1
w.upsertPset(id, SECTION_PSET, [['Level', 'IFCIDENTIFIER', s.level], ['Name', 'IFCLABEL', s.name], ['AX', 'IFCLENGTHMEASURE', round(w.u(s.a[0]))], ['AY', 'IFCLENGTHMEASURE', round(w.u(s.a[1]))], ['BX', 'IFCLENGTHMEASURE', round(w.u(s.b[0]))], ['BY', 'IFCLENGTHMEASURE', round(w.u(s.b[1]))], ['Look', 'IFCLABEL', s.look], ['Depth', 'IFCLENGTHMEASURE', round(w.u(s.depth))]]);
views-model.js BimCraft 0.8.1 64-bit 4 calculations Reviewed

Tests tools/bimcraft-test/draw06-test.mjs, tools/bimcraft-test/views2-test.mjs, tools/bimcraft-test/views09-test.mjs

Ceiling tile grid

64-bit

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.

  • views-model.js
  • line 191
  • BimCraft 0.8.1
const views = (S.views || []).filter((x) => SHEET_KINDS.includes(x.kind)).map((x) => ({ kind: x.kind, ref: String(x.ref), x: round(fin(x.x, 0), 2), y: round(fin(x.y, 0), 2), ratio: Math.max(0, Math.round(fin(x.ratio, 0))) }));

Another level drawn under a plan

64-bit

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.

Every policy that takes this approach