Drives a vehicle with up to three trailers along a path, forward or reversing, and reports its wheel paths, steering and articulation angles and the area its bodies sweep.
How the precision is kept
Positions and headings are 64-bit. Each step moves the leading point along a straight piece and places the follower with the closed-form tractrix, so a step on a straight is exact; on a curve the leader moves along chords between points that lie on the arc, and the step length (1/40 of the shortest wheelbase by default) sets the accuracy. The swept area is counted on a grid of cells 1/40 of the narrowest body width, and its outline is thinned to half a cell.
Calculations under this policy
8 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.
Where a rear axle ends up when the point that pulls it moves along a straight piece.
α₁ = 2·atan(tan(α₀ ÷ 2) × e^(−d ÷ L)); follower = B + L × (−w turned by α₁)
The closed-form solution for one straight move, in 64-bit. The start angle comes from Math.atan2 of a cross and a dot product, so no acos is involved. When the follower is exactly ahead of the leader the angle is moved 1e-9 radians off π to keep the tangent finite.
swept.js
lines 82 to 97
CivilCraft 0.8.1
/** Move a follower at distance L behind a leader that goes in a straight line from A to B: the exact tractrix. */
function tractrix(F, A, B, L) {
const dx = B.x - A.x, dy = B.y - A.y, d = Math.hypot(dx, dy);
if (d < 1e-15) return F;
const wx = dx / d, wy = dy / d;
// α: from the travel direction w to the line leader → follower, measured as the follower trails behind (α = 0 straight behind)
const bx = F.x - A.x, by = F.y - A.y, bl = Math.hypot(bx, by) || 1;
const cosA = -(bx * wx + by * wy) / bl, sinA = (bx * wy - by * wx) / bl; // behind = −w
let a = Math.atan2(sinA, cosA);
const k = Math.exp(-d / L);
if (Math.abs(Math.abs(a) - Math.PI) < 1e-12) a = Math.sign(a || 1) * (Math.PI - 1e-9); // exactly ahead: unstable point
const a1 = 2 * Math.atan(Math.tan(a / 2) * k);
// follower = B + L·(rotation of −w by a1)
const c = Math.cos(a1), s = Math.sin(a1);
return { x: B.x + L * (-wx * c + wy * s), y: B.y + L * (-wy * c - wx * s) };
}
Steady-state turning radii
64-bit
The radius each rear axle and hitch runs on when the front axle follows a circle for ever.
r = √(max(0, R² − L²)); hitch = √(r² + c²)
R² − L² subtracts two squares and loses digits when the radius is close to the wheelbase; the result is floored at zero before the square root, so it cannot go negative.
swept.js
lines 99 to 109
CivilCraft 0.8.1
/** steady state: the front axle on a circle of radius R; each rear axle's radius about the same centre. */
export function steadyRadii(vehicle, R) {
const U = unitDims(vehicle), rear = [], hitch = [];
let lead = R; // radius of the point that pulls the unit
for (let i = 0; i < U.length; i++) {
const r = Math.sqrt(Math.max(0, lead * lead - U[i].L * U[i].L));
rear.push(r);
if (i + 1 < U.length) { const c = U[i + 1].hitch; const h = Math.sqrt(r * r + c * c); hitch.push(h); lead = h; }
}
return { rear, hitch };
}
Stepping along the path
64-bit
Cuts the path into steps no longer than the step length, with a point and travel direction on every line and arc.
step = max(0.001, shortest wheelbase ÷ 40); k = ceil(L ÷ step); s = s₀ + L·j ÷ k
Points come from the feature line's own arc evaluation at j ÷ k of each piece, so they lie on the arcs. The distance along the path at each point is L × j ÷ k from the start of the piece, not a running sum of steps; piece starts are a running sum of piece lengths.
swept.js
lines 122 to 129
CivilCraft 0.8.1
const ds = step > 0 ? step : Math.max(1e-3, Math.min(v.wheelbase, ...v.trailers.map((tr) => tr.wheelbase)) / 40);
const walk = [{ ...pointOnSeg(segs[0].a, segs[0].b, segs[0].a.b, 0), s: 0 }];
let s = 0;
for (const q of segs) {
const k = Math.max(1, Math.ceil(q.L / ds));
for (let j = 1; j <= k; j++) walk.push({ ...pointOnSeg(q.a, q.b, q.a.b, j / k), s: s + (q.L * j) / k });
s += q.L;
}
One step forward or reversing
64-bit
Moves every unit of the vehicle for one step: the tractor behind its front axle, each trailer behind its hitch, or the chain pushed from the last axle when reversing.
64-bit positions carried from step to step, so rounding accumulates over the number of steps. Headings are recomputed from positions with Math.atan2 at every step and are not integrated. A hitch within 1e-6 of the axle is treated as on it.
swept.js
lines 158 to 192
CivilCraft 0.8.1
const prev0 = { ...units[0] };
if (mode === 'forward') {
// the front axle moves A → B; the tractor's rear axle follows it; each hitch drags the next trailer
const r0 = tractrix(units[0], A, B, U[0].L);
const th0 = Math.atan2(B.y - r0.y, B.x - r0.x);
let hOld = null, hNew = null;
const before = units.map((u) => ({ ...u }));
units[0] = { ...r0, th: th0 };
for (let i = 1; i < n; i++) {
const c = U[i].hitch, p = before[i - 1], q = units[i - 1];
hOld = { x: p.x - c * Math.cos(p.th), y: p.y - c * Math.sin(p.th) };
hNew = { x: q.x - c * Math.cos(q.th), y: q.y - c * Math.sin(q.th) };
const r = tractrix(before[i], hOld, hNew, U[i].L);
units[i] = { ...r, th: Math.atan2(hNew.y - r.y, hNew.x - r.x) };
}
} else {
// reversing: the last axle moves A → B (the unit faces backwards); each unit ahead hangs from the hitch it pushes
const before = units.map((u) => ({ ...u }));
units[n - 1] = { x: B.x, y: B.y, th: B.dir + Math.PI };
for (let i = n - 1; i > 0; i--) {
const L = U[i].L, c = U[i].hitch, p = before[i], q = units[i];
const hOld = { x: p.x + L * Math.cos(p.th), y: p.y + L * Math.sin(p.th) }, hNew = { x: q.x + L * Math.cos(q.th), y: q.y + L * Math.sin(q.th) };
let r, th;
if (c < -1e-6 && !said.hitch) { problems.push({ kind: 'hitch', unit: i, s: B.s, value: c, limit: 0 }); said.hitch = true; }
if (c < 1e-6) { // a hitch on or ahead of the axle: reversing, the unit turns with the hitch's path
r = hNew;
th = Math.hypot(hNew.x - hOld.x, hNew.y - hOld.y) > 1e-12 ? Math.atan2(hOld.y - hNew.y, hOld.x - hNew.x) : before[i - 1].th;
} else {
// the unit's rear axle sits c ahead of the hitch (c > 0: the hitch is behind the axle): a tractrix led by the hitch
r = tractrix(before[i - 1], hOld, hNew, c);
th = Math.atan2(r.y - hNew.y, r.x - hNew.x);
}
units[i - 1] = { ...r, th };
}
}
Steering angle and limit checks
64-bit
The steering angle at each step and the largest steering and articulation angles, checked against the vehicle's limits.
δ = atan(wheelbase × Δheading ÷ ±Δlength); articulation = heading of the unit ahead − heading of the unit, wrapped to (−π, π]
The curvature is a finite difference over one step: the change of heading divided by the distance the rear axle moved, skipped when that distance is 1e-12 or less. Limits are compared with a margin of 1e-9 degrees.
The four corners of a unit's body from its rear axle position and heading.
corner = axle + a·(cos θ, sin θ) + b·(−sin θ, cos θ)
One 64-bit sine and cosine of the heading and two multiply-adds per coordinate.
swept.js
lines 220 to 225
CivilCraft 0.8.1
/** The four corners of unit i's body at a frame (anticlockwise from rear right). */
export function bodyOutline(v, frame, i) {
const u = unitDims(v)[i], q = frame.units[i], c = Math.cos(q.th), s = Math.sin(q.th);
const back = -u.rear, fwd = i === 0 ? u.L + u.front : u.L + u.front; // a trailer's front = ahead of its hitch, which is L ahead of its axle
return [[back, -u.width / 2], [fwd, -u.width / 2], [fwd, u.width / 2], [back, u.width / 2]].map(([a, b]) => ({ x: q.x + a * c - b * s, y: q.y + a * s + b * c }));
}
Swept area and outline
64-bit
The area the bodies sweep and its outline.
cell h = narrowest width ÷ 40 (× 1.5 until the grid fits); area = filled cells × h²
A cell is in when its center lies inside a body rectangle at some step, so the area is a whole number of cells times h² and the outline follows cell edges before it is thinned with a tolerance of half a cell. The cell is enlarged by 1.5 until the grid has at most 4 million cells.
swept.js
lines 227 to 261
CivilCraft 0.8.1
/** The area the bodies sweep: every frame's body rectangles filled into a grid of `cell`, the filled cells' outline. */
export function envelope(sim, v, { cell = null, maxCells = 4e6 } = {}) {
if (!sim.frames.length) return { loops: [], area: 0, cell: 0, cells: 0 };
const n = 1 + v.trailers.length;
const polys = [];
for (const f of sim.frames) for (let i = 0; i < n; i++) polys.push(bodyOutline(v, f, i));
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const P of polys) for (const p of P) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }
let h = cell > 0 ? cell : Math.min(v.width, ...v.trailers.map((tr) => tr.width)) / 40;
while (((maxX - minX) / h + 3) * ((maxY - minY) / h + 3) > maxCells) h *= 1.5;
const W = Math.ceil((maxX - minX) / h) + 3, H = Math.ceil((maxY - minY) / h) + 3, x0 = minX - h, y0 = minY - h;
const g = new Uint8Array(W * H);
// a cell is in when its centre is inside a rectangle; consecutive frames overlap, so the sweep has no gaps
for (const P of polys) {
let a = Infinity, b = -Infinity;
for (const p of P) { a = Math.min(a, p.y); b = Math.max(b, p.y); }
const j0 = Math.max(0, Math.ceil((a - y0) / h - 0.5)), j1 = Math.min(H - 1, Math.floor((b - y0) / h - 0.5));
for (let j = j0; j <= j1; j++) {
const yc = y0 + (j + 0.5) * h, xs = [];
for (let k = 0; k < 4; k++) {
const p = P[k], q = P[(k + 1) % 4];
if ((p.y <= yc) !== (q.y <= yc)) xs.push(p.x + ((yc - p.y) * (q.x - p.x)) / (q.y - p.y));
}
if (xs.length < 2) continue;
xs.sort((m, o) => m - o);
const i0 = Math.max(0, Math.ceil((xs[0] - x0) / h - 0.5)), i1 = Math.min(W - 1, Math.floor((xs[xs.length - 1] - x0) / h - 0.5));
for (let i = i0; i <= i1; i++) g[j * W + i] = 1;
}
}
let cells = 0;
for (let i = 0; i < g.length; i++) cells += g[i];
// the outline: every cell edge between in and out, joined into loops (corners kept: a grid polygon), then thinned
const loops = traceLoops(g, W, H).map((L) => simplify(L.map(([i, j]) => ({ x: x0 + i * h, y: y0 + j * h })), h * 0.5));
return { loops, area: cells * h * h, cell: h, cells };
}
Alignment as a path with bulges
64-bit
Turns an alignment's lines and arcs into the path the vehicle follows.
bulge = ± tan(Δ ÷ 4)
Start points are copied and each arc's bulge is one 64-bit tangent of a quarter of its central angle. Spirals arrive as short straight pieces from the alignment.
swept.js
lines 333 to 343
CivilCraft 0.8.1
/** An alignment's lines and arcs as a path with bulges (+ = left). */
export function alignmentPath(al) {
if (!al?.elements?.length) return [];
const out = [];
for (const e of al.pieces()) { // 0.7 D7.35: a spiral comes as short straight pieces
out.push(e.type === 'arc' ? { x: e.p0.x, y: e.p0.y, b: (e.ccw ? 1 : -1) * Math.tan(e.delta / 4) } : { x: e.p0.x, y: e.p0.y });
}
const e = al.pieces()[al.pieces().length - 1];
out.push({ x: e.p1.x, y: e.p1.y });
return out;
}
In detail
Method
Low-speed, no-slip kinematics: tan(α ÷ 2) = tan(α₀ ÷ 2)·e^(−d ÷ L) for a follower at distance L behind a leader that moves d in a straight line (stated in the file header). Steering from the curvature of the rear axle's path; the swept area by filling body rectangles into a grid.
Number types
64-bit numbers; a Uint8Array of in or out flags for the grid.
Tolerances
Path pieces shorter than 1e-12 are dropped; a leader move below 1e-15 leaves the follower in place; a follower exactly ahead of its leader is moved 1e-9 radians off; steering and articulation limits are tested with 1e-9 degrees; a hitch within 1e-6 of the axle counts as on it; outline thinning tolerance is half a cell.
Limits
The steering angle is a finite difference over one step. The swept area is a whole number of cells times the cell area, and the outline is a thinned grid polygon. The metric placeholder vehicles are the feet values times 0.3048 rounded to 0.01 m.
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.