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