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