Resources Numerical accuracy Calculation policy

Plan sheets and plan-and-profile sheets as PDF

Places the plan and the profile on paper at a stated scale and writes the page as a vector PDF.

How the precision is kept

Scales are exact ratios (25.4 ÷ n mm per foot, 1000 ÷ n mm per meter). Positions are computed in 64-bit millimeters and written to the PDF with 4 decimals. Circular arcs are written as cubic Bézier curves of at most 90 degrees each, which approximate the arc. The vertical scale on a profile sheet is the horizontal scale times a whole-number exaggeration.

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.

plot.js CivilCraft 0.8.1 64-bit 8 calculations Reviewed

Tests bin/test-civilcraft.mjs, bin/test-civilcraft-sheets.mjs

Plot scales

Exact

The list of scales and the millimeters on paper per project unit for each.

mm per foot = 25.4 ÷ n (1 in = n ft); mm per meter = 1000 ÷ n (1:n)

25.4 mm per inch and 1000 mm per meter are exact, and n is a whole number, so each factor is the nearest 64-bit number to the true ratio.

  • plot.js
  • lines 38 to 41
  • CivilCraft 0.8.1
export function scalesFor(units) {
  if (units === 'm') return [50, 100, 200, 250, 500, 1000, 2000, 2500, 5000, 10000].map((n) => ({ label: '1:' + n, mmPerUnit: 1000 / n }));
  return [5, 10, 20, 30, 40, 50, 60, 100, 200, 300, 400, 500, 1000].map((n) => ({ label: `1" = ${n}'`, mmPerUnit: 25.4 / n }));
}

Scale that fits

64-bit

The largest standard scale at which the drawing's extents fit the sheet.

Compares extent × mm per unit with the drawing area with no tolerance; an extent that exactly fills the frame may go to the next smaller scale through rounding. The extent is floored at 1e-9.

  • plot.js
  • lines 45 to 53
  • CivilCraft 0.8.1
/** The largest standard scale at which the extents fit inside the sheet's drawing area. */
export function fitScale(units, ext, paper, landscape = true) {
  const { w, h } = orient(paper, landscape);
  const aw = w - 2 * MARGIN, ah = h - 2 * MARGIN - TITLE_H;
  const W = Math.max(ext.maxX - ext.minX, 1e-9), H = Math.max(ext.maxY - ext.minY, 1e-9);
  const list = scalesFor(units);
  for (const s of list.slice().sort((a, b) => b.mmPerUnit - a.mmPerUnit)) if (W * s.mmPerUnit <= aw && H * s.mmPerUnit <= ah) return s;
  return list[list.length - 1];
}

Drawing window in project coordinates

64-bit

The part of the project that a sheet shows at a scale.

half width = frame width ÷ 2 ÷ mm per unit; window = center ± half

One division per side from the frame size in millimeters.

  • plot.js
  • lines 59 to 66
  • CivilCraft 0.8.1
/** 0.4 V8: the drawing window of planSheet (the frame above the title strip) in project coordinates. */
export function sheetWindow(extents, { paper = 'tabloid', landscape = true, scale }) {
  const P = orient(paperBy(paper), landscape);
  const frame = { x0: MARGIN, y0: MARGIN + TITLE_H, x1: P.w - MARGIN, y1: P.h - MARGIN };
  const s = scale.mmPerUnit, cx = (extents.minX + extents.maxX) / 2, cy = (extents.minY + extents.maxY) / 2;
  const hw = (frame.x1 - frame.x0) / 2 / s, hh = (frame.y1 - frame.y0) / 2 / s;
  return { minX: cx - hw, maxX: cx + hw, minY: cy - hh, maxY: cy + hh };
}

Project coordinates to paper

64-bit

Where each point of the plan lands on the sheet, in millimeters.

x_mm = x·s + o_x, o_x = frame middle − c_x·s

The point is scaled first and the offset added after, so two large products are subtracted; with coordinates in the millions this leaves a rounding of about 1e-9 mm on paper, far below the 0.0001 mm the PDF is written to.

  • plot.js
  • lines 71 to 74
  • CivilCraft 0.8.1
const s = scale.mmPerUnit;
const cx = (extents.minX + extents.maxX) / 2, cy = (extents.minY + extents.maxY) / 2;
const ox = (frame.x0 + frame.x1) / 2 - cx * s, oy = (frame.y0 + frame.y1) / 2 - cy * s;
const tx = (p) => ({ x: p.x * s + ox, y: p.y * s + oy });

Scale bar length

64-bit

A round length about 50 mm long on paper for the scale bar.

want = 50 ÷ s; length = largest of {1, 2, 5, 10} × 10^⌊log₁₀ want⌋ not over want

The power of ten comes from Math.pow(10, floor(log10)). The bar is drawn length × s millimeters long and labeled with the length itself.

  • plot.js
  • lines 100 to 101
  • CivilCraft 0.8.1
const want = 50 / s, pow = Math.pow(10, Math.floor(Math.log10(want))), len = [1, 2, 5, 10].map((m) => m * pow).filter((v) => v <= want).pop() || pow;
const sb = { x: frame.x1 - 4 - len * s, y: MARGIN + TITLE_H - 6 };

Arcs as Bézier curves

64-bit

Turns a circular arc into cubic Bézier pieces for the PDF.

n = ⌈|sweep| ÷ 90° − 1e-9⌉; d = sweep ÷ n; k = 4 ÷ 3 × tan(d ÷ 4); control = end ± k·r·tangent

Each piece spans at most 90 degrees and starts at a + d × i from the whole-number index. End points lie on the circle; between them the Bézier is an approximation of the arc, not the arc itself.

  • plot.js
  • lines 184 to 194
  • CivilCraft 0.8.1
/** Cubic Béziers for an arc from angle a to b, ≤ 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;
}

A number in the PDF

64-bit

Formats every coordinate and width written to the page.

text = v.toFixed(4) with trailing zeros removed

Page values are in millimeters and are written with 4 decimals; anything below 0.00005 becomes 0.

  • plot.js
  • lines 196 to 200
  • CivilCraft 0.8.1
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;
}

Millimeters to PDF points

64-bit

The page transform that makes the content's units millimeters.

72 ÷ 25.4 points per millimeter

The factor is written with 9 decimals (2.834645669), so the page scale agrees with the exact 72 ÷ 25.4 to about 10 digits.

  • plot.js
  • line 118
  • CivilCraft 0.8.1
out.push((72 / 25.4).toFixed(9) + ' 0 0 ' + (72 / 25.4).toFixed(9) + ' 0 0 cm', '1 J 1 j');
pp-sheets.js CivilCraft 0.8.1 64-bit 6 calculations Reviewed

Tests bin/test-civilcraft-sheets.mjs

Stations per sheet

64-bit

The stretch of alignment each sheet shows and where each sheet starts and ends.

fits = plan window width ÷ mm per unit; length = ⌊fits ÷ round⌋ × round; sheet k starts at start + k × length

The sheet length is rounded down to a whole 50 or 10 ft (20 or 5 m) when the window allows. Sheet starts are reached by repeated addition of the length (a += length), and the loop stops 1e-6 before the end.

  • pp-sheets.js
  • lines 50 to 60
  • CivilCraft 0.8.1
export function sheetPlan(al, { paper = 'tabloid', landscape = true, scale, from, to, units = 'ft' } = {}) {
  const L = layout(paper, landscape);
  const a0 = Math.max(al.start, Number.isFinite(from) ? from : al.start), a1 = Math.min(al.end, Number.isFinite(to) ? to : al.end);
  const fits = (L.plan.x1 - L.plan.x0) / scale.mmPerUnit;
  // whole 50 ft (20 m) on long stretches, whole 10 ft (5 m) on short ones, so little of the window is wasted
  const round = units === 'm' ? (fits > 200 ? 20 : fits > 25 ? 5 : 0) : (fits > 500 ? 50 : fits > 50 ? 10 : 0);
  const length = round ? Math.floor(fits / round) * round : fits;
  const stations = [];
  for (let a = a0; a < a1 - 1e-6 && stations.length < 500; a += length) stations.push([a, Math.min(a1, a + length)]);
  return { length, stations, window: L };
}

Design profile points on a sheet

64-bit

The stations at which the design profile is drawn within a sheet's range.

The profile is sampled at a step of (range ÷ 300, at least 0.05) plus both ends, and each elevation is read from the profile at its station, so a vertical curve is drawn as short straight pieces.

  • pp-sheets.js
  • lines 30 to 36
  • CivilCraft 0.8.1
/** The design profile across [a, b]: its own breakpoints (PVIs, vertical curves) inside, plus both ends. */
function designIn(profile, a, b) {
  if (!profile?.ok) return [];
  const st = new Set([a, b]);
  for (const q of profile.sample(Math.max((b - a) / 300, 0.05))) if (q.sta > a && q.sta < b) st.add(q.sta);
  return [...st].sort((m, n) => m - n).map((sta) => ({ sta, z: profile.elevationAt(sta) })).filter((q) => q.z !== null);
}

Vertical exaggeration

Exact

The largest standard exaggeration (10, 5, 4, 2 or 1) at which every sheet's elevations fit the profile window.

fits when range × mm per unit × exaggeration ≤ available height; vertical scale = s × exaggeration

The exaggeration is one of five whole numbers, the same on every sheet, so the vertical scale is the horizontal scale times a whole number.

  • pp-sheets.js
  • lines 84 to 89
  • CivilCraft 0.8.1
const need = Math.max(1e-9, ...ranges.filter(Boolean).map(([lo, hi]) => hi - lo));
const want = VEXES.includes(vex) ? vex : 10;
const used = VEXES.filter((v) => v <= want).find((v) => need * s * v <= hAvail) ?? 1;
if (used !== want) notes.push(t('pp.vex.lowered', { want, used }));
if (need * s * used > hAvail) notes.push(t('pp.vex.clipped'));
const sv = s * used;

Plan turned along the alignment

64-bit

Where each plan point lands on a plan-and-profile sheet, turned so the stretch runs left to right.

q = R(−θ)·(p − p_a); x_mm = x₀ + q_x·s; y_mm = c_y + (q_y − y_mid)·s

The start point of the stretch is subtracted before rotating and scaling, so the arithmetic works on small numbers. The turn angle is atan2 of the chord between the stretch's two ends.

  • pp-sheets.js
  • lines 93 to 99
  • CivilCraft 0.8.1
const th = rotation(al, a, b), c = Math.cos(-th), sn = Math.sin(-th), pa = al.pointAt(a);
const rot = (p) => ({ x: (p.x - pa.x) * c - (p.y - pa.y) * sn, y: (p.x - pa.x) * sn + (p.y - pa.y) * c });
// centre the stretch vertically in the plan window
let ylo = Infinity, yhi = -Infinity;
for (let i = 0; i <= 40; i++) { const q = rot(al.pointAt(a + ((b - a) * i) / 40)); ylo = Math.min(ylo, q.y); yhi = Math.max(yhi, q.y); }
const ymid = (ylo + yhi) / 2, cy = (PW.y0 + PW.y1) / 2;
const tx = (p) => { const q = rot(p); return { x: PW.x0 + q.x * s, y: cy + (q.y - ymid) * s }; };

Profile grid and scales

64-bit

The station and elevation grid of the profile and the mapping from station and elevation to paper.

X = x₀ + (sta − a)·s; Y = y₀ + (z − z₀)·s·exaggeration; z₀ = ⌊(z_mid − span ÷ 2) ÷ step⌋ × step

The sheet's first station and the grid base are subtracted before scaling. Grid stations and elevations advance by repeated addition of the step from a rounded start; the labels are the formatted values of those sums.

  • pp-sheets.js
  • lines 128 to 131
  • CivilCraft 0.8.1
const zmid = (r[0] + r[1]) / 2, zSpan = (VW.y1 - VW.y0) / sv;
const zStepRaw = 6 / sv, zStep = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100].find((v) => v >= zStepRaw) || 100;
const z0 = Math.floor((zmid - zSpan / 2) / zStep) * zStep;
const X = (sta) => VW.x0 + (sta - a) * s, Y = (z) => VW.y0 + (z - z0) * sv;

Vertical scale label

64-bit

The vertical scale printed in the title strip.

meters: 1:round(1000 ÷ s_v); feet: 1 in = (25.4 ÷ s_v) ft to 1 decimal

The printed vertical scale is rounded to a whole ratio or to 1 decimal of a foot for the label; the drawing itself uses the unrounded scale.

  • pp-sheets.js
  • line 182
  • CivilCraft 0.8.1
const hLabel = scale.label, vLabel = units === 'm' ? '1:' + Math.round(1000 / sv) : '1" = ' + String(+(25.4 / sv).toFixed(1)) + "'";

In detail

Method
Model to paper: scale and shift (plan sheet), or shift, rotate and scale (plan-and-profile sheet). Arcs as Béziers with k = 4 ÷ 3 × tan(sweep ÷ 4).
Number types
64-bit numbers; PDF text with 4 decimals of a millimeter; the millimeter-to-point factor written with 9 decimals.
Tolerances
PDF values below 0.00005 mm are written as 0. Sheet stepping stops 1e-6 before the end. Grid ticks allow 1e-9.
Limits
A Bézier is not an exact circle. Paper resolution is 0.0001 mm. The design profile on a sheet is sampled at its own breakpoints plus (range ÷ 300, at least 0.05).

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