Resources Numerical accuracy Calculation policy

Plotting: sheet scale, PDF, SVG and PNG output

Scales the model onto paper and writes the sheet as a PDF, an SVG or a PNG.

How the precision is kept

Sheet geometry is computed in millimeters in 64-bit. Every number written to a PDF or SVG is fixed to 4 decimals, so positions on paper are kept to 0.0001 mm. Arcs go into the PDF as cubic Béziers of at most 90° each, which approximate a circle; the SVG uses true arc commands. PNG size is whole pixels from the dpi.

Open item. Write plot coordinates with enough digits to give back the 64-bit value, instead of four decimals of a millimeter.

Calculations under this policy

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

plot.js CadCraft 0.8.2 64-bit 13 calculations Reviewed

Tests bin/test-cadcraft.mjs

Layout sheet in millimeters

64-bit

The matrix that takes a layout and its viewports to millimeters on the sheet.

toMm = [k 0 0 k −x₀k −y₀k], k = mm per paper unit; view matrix = toMm · viewport matrix

k is the paper unit's size in millimeters from sheets.js (1 for mm, 25.4 for in). The viewport matrix is multiplied in once; nothing is rounded here.

  • plot.js
  • lines 46 to 57
  • CadCraft 0.8.2
export function layoutSheet(doc, layout) {
  const p = layout.paper, k = UNIT_MM[p.unit];
  const toMm = [k, 0, 0, k, -p.x0 * k, -p.y0 * k];
  const views = [];
  for (const e of layout.entities.values()) {
    if (e.type !== 'viewport' || e.on === false) continue;   // a viewport turned off plots its frame only (P.7)
    // the layers frozen in this viewport are left out of its view (VP.1)
    views.push({ clip: viewportCorners(e).map((q) => mApply(toMm, q)), m: mMul(toMm, viewportMatrix(e)), entities: doc.model.values(), frozen: frozenSet(e) });
  }
  views.push({ clip: null, m: toMm, entities: layout.entities.values() });
  return { w: p.w * k, h: p.h * k, views, name: layout.name };
}

Model plot scale and placement

64-bit

The scale and offset that put a window of the model on a paper size, fitted or at 1:n.

fit: s = min((W − 10) ÷ w, (H − 10) ÷ h); offset = (W ÷ 2 − cₓs, H ÷ 2 − c_y·s)

The margin is 5 mm on each side. A window narrower than 1e-9 is treated as 1e-9 wide, which keeps the division finite. The scale label is shown to 4 significant digits; the scale used is not rounded.

  • plot.js
  • lines 59 to 76
  • CadCraft 0.8.2
/**
 * The model on paper. `area` is a model window (bounds); `n` the scale 1:n in real units (the
 * model's units against millimetres), or null to fit the printable area. Centred on the sheet.
 */
export function modelSheet(doc, { area, paper = 'iso-a3', portrait = false, n = null }) {
  const pp = makePaper(paper, portrait), k = UNIT_MM[pp.unit];
  const W = pp.w * k, H = pp.h * k;
  const aw = Math.max(area.maxX - area.minX, 1e-9), ah = Math.max(area.maxY - area.minY, 1e-9);
  const sFit = Math.min((W - 2 * MARGIN) / aw, (H - 2 * MARGIN) / ah);
  const s = n ? sOf(doc, 'mm', n) : sFit;
  const cx = (area.minX + area.maxX) / 2, cy = (area.minY + area.maxY) / 2;
  const m = [s, 0, 0, s, W / 2 - cx * s, H / 2 - cy * s];
  const clip = [{ x: MARGIN, y: MARGIN }, { x: W - MARGIN, y: MARGIN }, { x: W - MARGIN, y: H - MARGIN }, { x: MARGIN, y: H - MARGIN }];
  return {
    w: W, h: H, views: [{ clip, m, entities: doc.model.values() }], name: doc.meta.name || 'Drawing1',
    scale: { s, n: nOf(doc, 'mm', s), label: n ? scaleLabel(doc, 'mm', s) : (nOf(doc, 'mm', s) >= 1 ? '1:' + fmt(nOf(doc, 'mm', s), 4) : fmt(1 / nOf(doc, 'mm', s), 4) + ':1') + ' (fitted)', fits: s <= sFit * (1 + 1e-9) },
  };
}

Millimeters to points in the page

64-bit

The factor that lets the rest of the PDF page be written in millimeters.

72 ÷ 25.4 = 2.834645669 (9 decimals)

The factor is written into the PDF with toFixed(9), 10 significant digits, so the PDF's own scale differs from 72 ÷ 25.4 from the eleventh digit on.

  • plot.js
  • lines 274 to 275
  • CadCraft 0.8.2
out.raw((72 / 25.4).toFixed(9) + ' 0 0 ' + (72 / 25.4).toFixed(9) + ' 0 0 cm');   // everything after this is in mm
for (const v of list.views) {

Numbers written to the PDF and SVG

64-bit

Turns a coordinate, width or size into the text that goes into the file.

text = v.toFixed(4), trailing zeros dropped; |v| < 0.00005 → 0

Every coordinate written to the PDF or SVG passes through here and is fixed to 4 decimals of a millimeter. Positions on paper are therefore kept to 0.0001 mm, not to 15 digits. A value that is not finite is written as 0.

  • plot.js
  • lines 448 to 452
  • CadCraft 0.8.2
function num(v) {
  if (!Number.isFinite(v)) return '0';
  const s = (Math.abs(v) < 5e-5 ? 0 : v).toFixed(4);
  return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
}

Arcs as cubic Béziers

64-bit

The Bézier pieces that draw an arc in the PDF.

n = ⌈|sweep| ÷ 90°⌉; d = sweep ÷ n; k = 4⁄3 · tan(d ÷ 4); handles at r·k along the tangents

Each piece spans at most 90° (the count allows 1e-9 of slack). A cubic Bézier is not an exact circular arc, so the PDF arc approximates the drawing's arc between the piece ends; the piece ends are computed with cosine and sine of the exact angles.

  • plot.js
  • lines 436 to 446
  • CadCraft 0.8.2
/** Cubic Béziers for an arc from angle a to b (either direction), ≤ 90° each: [x1 y1 x2 y2 x3 y3]. */
export function arcBeziers(cx, cy, r, a, b) {
  const sweep = b - a, n = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2) - 1e-9)), d = sweep / n, k = 4 / 3 * Math.tan(d / 4);
  const out = [];
  for (let i = 0; i < n; i++) {
    const t0 = a + d * i, t1 = t0 + d;
    const c0 = Math.cos(t0), s0 = Math.sin(t0), c1 = Math.cos(t1), s1 = Math.sin(t1);
    out.push([cx + r * (c0 - k * s0), cy + r * (s0 + k * c0), cx + r * (c1 + k * s1), cy + r * (s1 - k * c1), cx + r * c1, cy + r * s1]);
  }
  return out;
}

Lines and arcs in the page

64-bit

Writes each line and arc of a stroke group as PDF path operators.

arc start = c + r(cos a, sin a)

A new subpath is started when the next start differs from the last end by more than 1e-9 mm in x or y, an absolute limit. Arc ends are cosine and sine of the stored angles times the radius; every number then goes through num.

  • plot.js
  • lines 359 to 373
  • CadCraft 0.8.2
path(ps) {
  let lx = Infinity, ly = Infinity;
  for (const q of ps) {
    if (q.k === 'L') {
      if (Math.abs(q.x1 - lx) > 1e-9 || Math.abs(q.y1 - ly) > 1e-9) this.raw(num(q.x1) + ' ' + num(q.y1) + ' m');
      this.raw(num(q.x2) + ' ' + num(q.y2) + ' l'); lx = q.x2; ly = q.y2;
    } else {
      const from = q.rev ? q.a1 : q.a0, to = q.rev ? q.a0 : q.a1;
      const sx = q.cx + q.r * Math.cos(from), sy = q.cy + q.r * Math.sin(from);
      if (Math.abs(sx - lx) > 1e-9 || Math.abs(sy - ly) > 1e-9) this.raw(num(sx) + ' ' + num(sy) + ' m');
      for (const c of arcBeziers(q.cx, q.cy, q.r, from, to)) this.raw(c.map(num).join(' ') + ' c');
      lx = q.cx + q.r * Math.cos(to); ly = q.cy + q.r * Math.sin(to);
    }
  }
}

Text placement in the PDF

64-bit

The text matrix of each line of text: size, width factor, rotation, lean and position.

size = h × FONT_EM; width = advance ÷ 1000 × size × wf; lean = size × tan(oblique)

Line width for centered and right-aligned text is the font's advance in thousandths of the size. The six matrix entries are 64-bit products of the size with the cosine and sine of the rotation, then written through num.

  • plot.js
  • lines 375 to 390
  • CadCraft 0.8.2
text(e, color) {
  const lay = textLayout(e), fs = e.h * FONT_EM, wf = e.wf || 1;
  if (lay.runs) return this.richText(e, color, lay);   // inline format codes (mtext.js, 0.7 D7.17)
  const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
  let missing = 0;
  this.fillColor(color);
  lay.lines.forEach((line, i) => {
    if (!line) return;
    const enc = this.use ? this.use.encode(line) : winAnsi(line);
    missing += enc.missing;
    if (this.use) this.font2 = true;
    const w = enc.width / 1000 * fs * wf;
    const lx = e.ha === 'center' ? -w / 2 : e.ha === 'right' ? -w : 0, ly = lay.baselines[i];
    const x = e.x + lx * c - ly * s, y = e.y + lx * s + ly * c;
    const k = e.obl ? fs * Math.tan(e.obl) : 0;   // oblique angle (P.7): x leans by tan(angle) per unit of height
    this.raw('BT /F' + (this.use ? 2 : 1) + ' 1 Tf ' + [fs * wf * c, fs * wf * s, k * c - fs * s, k * s + fs * c, x, y].map(num).join(' ') + ' Tm ' + (this.use ? (enc.ops || '<' + enc.hex + '> Tj') : '(' + enc.str + ') Tj') + ' ET');   // i18n-ignore: PDF operators

Page size in points

64-bit

The PDF page's width and height from the sheet's millimeters.

pt = mm × 72 ÷ 25.4, written with 4 decimals

Multiplied by 72 before dividing by 25.4, then fixed to 4 decimals of a point by num.

  • plot.js
  • lines 504 to 505
  • CadCraft 0.8.2
const pt = (mm) => num(mm * 72 / 25.4);
const now = new Date(), pad = (x) => String(x).padStart(2, '0');

Lines and arcs in the SVG

64-bit

Writes each line and arc of a stroke group as SVG path data.

arc pieces of at most 90°: A r r 0 0 flag x y

Arcs are true elliptical-arc commands, not Béziers, so the shape is a circle arc in the viewer; radius and end points are rounded by num to 4 decimals of a millimeter.

  • plot.js
  • lines 621 to 641
  • CadCraft 0.8.2
/** A stroke group's path data (mm, y up: the page flips it once). Arcs go in pieces of at most 90°, as the PDF's Béziers do. */
function svgPath(prims) {
  let d = '', lx = Infinity, ly = Infinity;
  const to = (x, y) => { lx = x; ly = y; };
  for (const q of prims) {
    if (q.k === 'L') {
      if (Math.abs(q.x1 - lx) > 1e-9 || Math.abs(q.y1 - ly) > 1e-9) d += `M${num(q.x1)} ${num(q.y1)}`;
      d += `L${num(q.x2)} ${num(q.y2)}`; to(q.x2, q.y2);
    } else {
      const from = q.rev ? q.a1 : q.a0, end = q.rev ? q.a0 : q.a1, sweep = end - from;
      const n = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2) - 1e-9)), step = sweep / n, flag = sweep >= 0 ? 1 : 0;
      const sx = q.cx + q.r * Math.cos(from), sy = q.cy + q.r * Math.sin(from);
      if (Math.abs(sx - lx) > 1e-9 || Math.abs(sy - ly) > 1e-9) d += `M${num(sx)} ${num(sy)}`;
      for (let i = 1; i <= n; i++) {
        const t = from + step * i, x = q.cx + q.r * Math.cos(t), y = q.cy + q.r * Math.sin(t);
        d += `A${num(q.r)} ${num(q.r)} 0 0 ${flag} ${num(x)} ${num(y)}`; to(x, y);
      }
    }
  }
  return d;
}

Paper size from a printer's paper name

64-bit

The size in millimeters of a named paper such as A3, Letter or 24 x 36 in.

A-series: start 841 × 1189, each step (w, h) → (⌊h ÷ 2⌋, w); inches × 25.4

A sizes come from 841 × 1189 mm by whole-number halving rounded down, which gives 210 × 297 for A4. The named sizes are a table in millimeters. A size typed in inches is multiplied by 25.4 in 64-bit, so 24 x 36 in gives 609.5999999999999 × 914.4 where the table has 609.6.

  • plot.js
  • lines 690 to 702
  • CadCraft 0.8.2
/** Size in mm of a printer's paper name ("A3", "Letter", "ARCH D", "24 x 36 in", "420 x 297 mm"), or null when it names none we know. */
export function paperNameMm(name) {
  const n = String(name).trim(), key = n.toLowerCase().replace(/[^a-z0-9]/g, '');
  const iso = /^a(\d)(?![0-9])/i.exec(n);
  if (iso && +iso[1] <= 6) { const k = +iso[1]; let w = 841, h = 1189; for (let i = 0; i < k; i++) { [w, h] = [Math.floor(h / 2), w]; } return [Math.min(w, h), Math.max(w, h)]; }
  for (const [k, v] of Object.entries(NAMED_PAPERS)) if (key === k || key.startsWith(k)) return v;
  const m = /(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*(mm|cm|in|inch|")?/i.exec(n);
  if (m) {
    const u = (m[3] || '').toLowerCase(), k = u === 'mm' ? 1 : u === 'cm' ? 10 : (u === 'in' || u === 'inch' || u === '"') ? 25.4 : null;
    if (k) { const a = +m[1] * k, b = +m[2] * k; return [Math.min(a, b), Math.max(a, b)]; }
  }
  return null;
}

Matching a sheet to a printer paper

64-bit

Finds the printer paper that is the sheet's size.

|short − a| ≤ 2 mm and |long − b| ≤ 2 mm

A fixed 2 mm allowance on each side, either way round; the first paper in the printer's list that passes is taken.

  • plot.js
  • lines 703 to 707
  • CadCraft 0.8.2
/** The printer paper that is the sheet's size (within 2 mm, either way round), or '' → the printer's own paper. */
export function matchPaper(names, w, h) {
  const a = Math.min(w, h), b = Math.max(w, h);
  return names.find((nm) => { const d = paperNameMm(nm); return d && Math.abs(d[0] - a) <= 2 && Math.abs(d[1] - b) <= 2; }) || '';
}

PNG size in pixels

64-bit

The pixel width and height of a sheet at a resolution, held under what a canvas can make.

px = round(mm ÷ 25.4 × dpi), at least 1; while too big: dpi = ⌊dpi × 0.9⌋

The dpi is held to 36–1200. While a side exceeds 16000 pixels or the total exceeds 100 million, the dpi is lowered and the result says so. Pixel counts are rounded to whole numbers, so the stated dpi and the pixel size agree only to half a pixel.

  • plot.js
  • lines 709 to 716
  • CadCraft 0.8.2
/** Pixels for a sheet at `dpi`, held under a side and a total the browser's canvas can make (`capped`: the dpi was lowered). */
export function pngSize(w, h, dpi, { maxSide = 16000, maxPixels = 100e6 } = {}) {
  let d = Math.max(36, Math.min(1200, +dpi || 300));
  const px = (k) => [Math.max(1, Math.round(w / 25.4 * k)), Math.max(1, Math.round(h / 25.4 * k))];
  let [pw, ph] = px(d), capped = false;
  while ((Math.max(pw, ph) > maxSide || pw * ph > maxPixels) && d > 36) { d = Math.floor(d * 0.9); [pw, ph] = px(d); capped = true; }
  return { w: pw, h: ph, dpi: d, capped };
}

Plot extents of the model

64-bit

The box round everything the model would plot.

Minimum and maximum over the strokes, fills, text corners and points of the model's display list. Only comparisons are made here; the bounds of each stroke come from primBounds of geom.js.

  • plot.js
  • lines 728 to 737
  • CadCraft 0.8.2
/** The model area a plot covers when asked for its extents. */
export function extentsOf(doc) {
  const b = emptyBounds();
  const list = displayList(doc, { w: 0, h: 0, views: [{ clip: null, m: null, entities: doc.model.values() }] });
  for (const s of list.views[0].strokes) for (const q of s.prims) primBounds(q, b);
  for (const f of list.views[0].fills) for (const l of f.loops) for (let i = 0; i + 1 < l.length; i += 2) growPt(b, l[i], l[i + 1]);
  for (const t of list.views[0].texts) for (const p of textCorners(t.e)) growPt(b, p.x, p.y);
  for (const p of list.views[0].points) growPt(b, p.x, p.y);
  return validBounds(b) ? b : null;
}

In detail

Method
Affine sheet matrix; arc to cubic Bézier with handle length 4⁄3 · tan(sweep ÷ 4) · r; ISO A sizes by halving and rounding down.
Number types
64-bit numbers; output text with 4 decimals (9 decimals for the millimeter-to-point factor, 6 for a picture's matrix).
Tolerances
Values under 0.00005 are written as 0. A new subpath starts when points differ by more than 1e-9 mm (absolute). Arc pieces: count allows 1e-9 of slack. Paper match within 2 mm. Fit check allows a factor 1e-9.
Limits
A cubic Bézier is not an exact circular arc. Output coordinates carry 4 decimals of a millimeter.

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