Resources Numerical accuracy Calculation policy

Picture backgrounds: scale, placement and print

Sizes a picture placed under a drawing from two picked points and a distance, moves and turns it, places it in sheet views, and works out its size and matrix in a printed PDF.

How the precision is kept

A picture is stored as a center point, a scale in millimeters per pixel and an angle in degrees. Every stored value is rounded to 6 decimals after each change. Corners and matrices are recomputed from those stored values with one cosine and sine.

Open item. Keep the picture's scale in millimeters per pixel at full precision instead of rounding it to 6 decimals.

Calculations under this policy

15 calculations in 3 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.

picture.js BimCraft 0.8.1 64-bit 11 calculations Reviewed

Tests tools/bimcraft-test/picture-test.mjs, tools/bimcraft-test/picture2-test.mjs

Rounding of stored picture values

64-bit

Rounds a position, scale or angle to 6 decimals.

r6(v) = Math.round(v × 10⁶) ÷ 10⁶

Applied to every value the picture record keeps: millimeters, millimeters per pixel and degrees alike.

  • picture.js
  • line 35
  • BimCraft 0.8.1
const r6 = (v) => Math.round(v * 1e6) / 1e6;

New picture record

64-bit

The cleaned numbers of a picture, with defaults.

default scale = r6(20000 ÷ width in pixels); angle = r6(((a mod 360) + 360) mod 360)

Pixel sizes are rounded to whole numbers of at least 1. A picture with no scale is made 20 m wide. Position, scale and angle are rounded to 6 decimals.

  • picture.js
  • lines 72 to 84
  • BimCraft 0.8.1
/** A picture record with clean numbers. Defaults: 20 m wide, centred on (0, 0), not scaled yet, fully opaque, unlocked. */
export function makePicture(o = {}) {
  const w = Math.max(1, Math.round(num(o.w, 1))), h = Math.max(1, Math.round(num(o.h, 1)));
  const mm = num(o.mmPerPx, 0);
  return {
    name: String(o.name || 'picture').slice(0, 120), mime: String(o.mime || 'image/png'), w, h,
    x: r6(num(o.x, 0)), y: r6(num(o.y, 0)), mmPerPx: mm > 0 ? r6(mm) : r6(20000 / w),
    angle: r6(((num(o.angle, 0) % 360) + 360) % 360), opacity: Math.min(1, Math.max(LIMITS.minOpacity, num(o.opacity, 1))),
    locked: !!o.locked, scaled: !!o.scaled, hidden: !!o.hidden,
    ...(o.section ? { section: String(o.section).slice(0, 64) } : {}),   // the section line (a GlobalId) a vertical-plane picture was put on, if it was put on one
    ...(o.plane === 'vertical' && Array.isArray(o.a) && Array.isArray(o.b) ? { plane: 'vertical', a: [r6(num(o.a[0], 0)), r6(num(o.a[1], 0))], b: [r6(num(o.b[0], 0)), r6(num(o.b[1], 0))] } : { plane: 'level' }),
  };
}

Scale by two points and a distance

64-bit

The new scale and center that make two picked points a given distance apart.

k = mm ÷ |b − a|; scale′ = r6(scale × k); center′ = r6(a + (center − a) × k)

The picked distance is one Math.hypot and the factor one division, guarded by a 1e-6 mm minimum. The new scale is the old rounded scale times k, rounded again to 6 decimals.

  • picture.js
  • lines 134 to 142
  • BimCraft 0.8.1
/** The change that makes two points of the picture `mm` apart: it grows or shrinks about the first, which stays where it is. */
export function scaleByPoints(p, a, b, mm) {
  unlocked(p);
  const d = Math.hypot(b[0] - a[0], b[1] - a[1]);
  if (!(d > 1e-6)) refuse('pic.err.samepoint');
  if (!(mm > 0) || !Number.isFinite(mm)) refuse('pic.err.distance');
  const k = mm / d;
  return { mmPerPx: r6(p.mmPerPx * k), x: r6(a[0] + (p.x - a[0]) * k), y: r6(a[1] + (p.y - a[1]) * k), scaled: true };
}

Corners of a placed picture

64-bit

The four corners of the picture in plan millimeters.

corner = center + R(θ) · (± w·scale ÷ 2, ± h·scale ÷ 2), θ = angle × π ÷ 180

Half sizes are pixel counts times the stored scale. One cosine and sine of the stored angle are used for all four corners, and each coordinate is rounded to 6 decimals.

  • picture.js
  • lines 111 to 115
  • BimCraft 0.8.1
/** Lower-left, lower-right, upper-right, upper-left in plan mm. */
export function corners(p) {
  const hw = p.w * p.mmPerPx / 2, hh = p.h * p.mmPerPx / 2, a = p.angle * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
  return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([x, y]) => [r6(p.x + x * c - y * s), r6(p.y + x * s + y * c)]);
}

Pixel to plan matrix

64-bit

The transform from picture pixels to plan millimeters.

[k·c, k·s, k·s, −k·c, x + k(−w/2·c − h/2·s), y + k(−w/2·s + h/2·c)]

Built from the stored scale, angle and center in one expression per entry and rounded to 6 decimals per entry.

  • picture.js
  • lines 117 to 122
  • BimCraft 0.8.1
/** Canvas transform [a, b, c, d, e, f]: a picture pixel (x right, y down, origin at its top-left) → plan mm. */
export function matrix(p) {
  const k = p.mmPerPx, a = p.angle * Math.PI / 180, c = Math.cos(a), s = Math.sin(a), hw = p.w / 2, hh = p.h / 2;
  // pixel (u, v) → centred (u - hw, hh - v) → scaled → turned → moved
  return [k * c, k * s, k * s, -k * c, p.x + k * (-hw * c - hh * s), p.y + k * (-hw * s + hh * c)].map(r6);
}

Plan point to picture pixel

64-bit

The pixel of the picture under a plan point.

(u, v) = (R(−θ)·(q − center)) ÷ scale; pixel = (u + w ÷ 2, h ÷ 2 − v)

The center is subtracted before the rotation, so the rotation works on local differences. The result is not rounded. The division by the scale is not guarded here; makePicture keeps the scale positive.

  • picture.js
  • lines 124 to 129
  • BimCraft 0.8.1
/** A plan point in picture pixels (x right, y down). */
export function toPicture(p, q) {
  const a = p.angle * Math.PI / 180, c = Math.cos(a), s = Math.sin(a), dx = q[0] - p.x, dy = q[1] - p.y;
  const lx = (dx * c + dy * s) / p.mmPerPx, ly = (-dx * s + dy * c) / p.mmPerPx;
  return [lx + p.w / 2, p.h / 2 - ly];
}

Picture on a vertical plane

64-bit

Puts a picture on the plane standing over a line, as wide as the line.

k = L ÷ w; center = (L ÷ 2, h·k ÷ 2)

The line length is one Math.hypot and must exceed 1 mm. Scale, center and the line's end points are rounded to 6 decimals.

  • picture.js
  • lines 97 to 103
  • BimCraft 0.8.1
export function onLine(p, a, b) {
  unlocked(p);
  const L = lineLength(a, b);
  if (!(L > 1)) refuse('pic.err.samepoint');
  const k = L / p.w;
  return { plane: 'vertical', a: [r6(a[0]), r6(a[1])], b: [r6(b[0]), r6(b[1])], x: r6(L / 2), y: r6(p.h * k / 2), mmPerPx: r6(k), angle: 0, scaled: true };
}

Picture width typed

64-bit

The scale that makes the picture a typed width.

scale = r6(mm ÷ w)

One division by the pixel width, rounded to 6 decimals, so the width read back is w × the rounded scale and can differ from the typed width by up to w × 5e-7 mm.

  • picture.js
  • lines 104 to 108
  • BimCraft 0.8.1
export function setWidth(p, mm) {
  unlocked(p);
  if (!(mm > 0) || !Number.isFinite(mm)) refuse('pic.err.distance');
  return { mmPerPx: r6(mm / p.w), scaled: true };
}

Turning a picture

64-bit

The new angle after a turn about the picture's center.

angle′ = r6(((angle + deg) mod 360 + 360) mod 360)

Angles are kept in degrees in 0 to 360 and rounded to 6 decimals after each turn.

  • picture.js
  • lines 145 to 146
  • BimCraft 0.8.1
/** Turn by `deg` about the picture's centre (use setAngle for an absolute angle). */
export function turn(p, deg) { unlocked(p); return { angle: r6((((p.angle + num(deg, 0)) % 360) + 360) % 360) }; }

Fit in a box

64-bit

Centers an unscaled picture in the view at 90 % of the box.

k = min(box width ÷ w, box height ÷ h) × 0.9

The factor 0.9 is a margin. Center and scale are rounded to 6 decimals; the picture is marked as not scaled yet.

  • picture.js
  • lines 151 to 155
  • BimCraft 0.8.1
export function fitTo(p, box) {
  const [x0, y0, x1, y1] = box, bw = Math.abs(x1 - x0), bh = Math.abs(y1 - y0);
  const k = Math.min(bw / p.w, bh / p.h) * 0.9;
  return { x: r6((x0 + x1) / 2), y: r6((y0 + y1) / 2), mmPerPx: r6(k > 0 ? k : p.mmPerPx), scaled: false };
}

Corners in 3D

64-bit

The picture's corners in level coordinates, for a level or a vertical plane.

p = origin + u·û + v·v̂

The plane's along-axis is the line's direction divided by its Math.hypot length. Each coordinate is rounded to 6 decimals, on top of the rounding already in corners.

  • picture.js
  • lines 92 to 95
  • BimCraft 0.8.1
export function corners3d(p) {
  const f = frame(p);
  return corners(p).map(([u, v]) => [0, 1, 2].map((i) => r6(f.o[i] + u * f.u[i] + v * f.v[i])));
}
picture-sheet.js BimCraft 0.8.1 64-bit 1 calculation Reviewed

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

Vertical picture in an elevation or section

64-bit

Decides whether a standing picture shows in a view and gives its matrix there.

shown when |û·dir| ≤ sin(2°) and, in a section, |(a − o)·dir| ≤ 100 mm; x = a·right ± picture x, y = level difference + picture y

The facing test compares a dot product of unit vectors with sin(tolerance). The picture's along-axis is taken as exactly +1 or −1 times the view's right direction, so a plane up to 2° off square is drawn without the cosine shortening. Each matrix entry is rounded to 6 decimals.

  • picture-sheet.js
  • lines 20 to 37
  • BimCraft 0.8.1
export function viewPlacement(p, { dir, o = null, guid = '', dz = 0, tolDeg = 2, tolMm = 100 } = {}) {
  if (!p || p.hidden || p.plane !== 'vertical' || !Array.isArray(p.a) || !Array.isArray(p.b) || !Array.isArray(dir)) return null;
  const dx = p.b[0] - p.a[0], dy = p.b[1] - p.a[1], L = Math.hypot(dx, dy);
  if (!(L > 1)) return null;
  const u = [dx / L, dy / L], right = [dir[1], -dir[0]];
  const facing = Math.abs(u[0] * dir[0] + u[1] * dir[1]);          // 0 = the plane is square to the view
  if (facing > Math.sin(tolDeg * Math.PI / 180)) return null;
  const onSection = !!guid && p.section === guid;
  if (o && !onSection) {
    const off = (p.a[0] - o[0]) * dir[0] + (p.a[1] - o[1]) * dir[1];
    if (Math.abs(off) > tolMm) return null;
  }
  const s = u[0] * right[0] + u[1] * right[1];                       // +1 or -1 (to the tolerance)
  const sg = s < 0 ? -1 : 1;
  const A = p.a[0] * right[0] + p.a[1] * right[1];
  const m = matrix(p);
  return [sg * m[0], m[1], sg * m[2], m[3], A + sg * m[4], dz + m[5]].map((v) => Math.round(v * 1e6) / 1e6);
}
picture-pdf.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

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

Number format for picture matrices

64-bit

Turns a matrix entry or opacity into the text written in the PDF.

toFixed(6), trailing zeros removed; |v| < 5e-7 → 0

Six decimals are kept for each entry of the picture's matrix on the page.

  • picture-pdf.js
  • line 19
  • BimCraft 0.8.1
const n = (v) => { const s = (Math.abs(v) < 5e-7 ? 0 : v).toFixed(6); return s.replace(/\.?0+$/, '') || '0'; };

Pixels to print a picture at

64-bit

The pixel size a picture is drawn at on a sheet, limited by dots per inch and total pixels.

paper mm per pixel = scale ÷ ratio; dpi = 25.4 ÷ that; k = min(1, max dpi ÷ dpi, √(max pixels ÷ (w·h)))

25.4 is exact. The pixel counts are rounded to whole numbers of at least 1, so the drawn picture's proportions can differ from the original by up to half a pixel in each direction. The dpi returned is rounded to a whole number and capped at 99999.

  • picture-pdf.js
  • lines 57 to 64
  • BimCraft 0.8.1
/** The pixels to draw a picture at on a sheet at 1:ratio: paper mm per pixel = mmPerPx / ratio, so dpi = 25.4 / that. */
export function printSize({ w, h, mmPerPx, ratio, maxDpi = 200, maxPixels = 16e6 }) {
  const paperMm = mmPerPx / ratio, dpi = paperMm > 0 ? 25.4 / paperMm : Infinity;
  let k = dpi > maxDpi ? maxDpi / dpi : 1;
  if (w * h * k * k > maxPixels) k = Math.sqrt(maxPixels / (w * h));
  k = Math.min(1, k);
  return { w: Math.max(1, Math.round(w * k)), h: Math.max(1, Math.round(h * k)), k, dpi: Math.round(Math.min(dpi, 99999)) };
}

Matrix for the image's unit square

64-bit

Turns the pixel-to-plan matrix into the one PDF needs for an image drawn on a unit square.

[a·w, b·w, −c·h, −d·h, e + c·h, f + d·h]

Products of the 6-decimal matrix entries and the whole pixel sizes, one addition for each offset.

  • picture-pdf.js
  • lines 76 to 79
  • BimCraft 0.8.1
/** pixel (u, v: right, down) = plan mm through m; the unit square (s, t: right, up) has u = s·w, v = (1 − t)·h. */
export function unitMatrix(m, w, h) {
  return [m[0] * w, m[1] * w, -m[2] * h, -m[3] * h, m[4] + m[2] * h, m[5] + m[3] * h];
}

In detail

Method
Scale = true distance ÷ picked distance; similarity transform about the first picked point; rotation about the center.
Number types
64-bit numbers rounded to 6 decimals; PDF numbers written with 6 decimals.
Tolerances
Picked points must be more than 1e-6 mm apart to scale; a vertical picture's line must be longer than 1 mm. A vertical picture shows in a view when its plane is within 2° of square to the view and, in a section, within 100 mm of the cut.
Limits
The scale is kept to 1e-6 mm per pixel, so the far edge of a picture N pixels wide can be up to N × 5e-7 mm from where the unrounded scale would put it. Repeated moves and scalings each round again.

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