Works out a feature line's lengths, grades and arcs, sets elevations by grade, offsets the line, and finds where it crosses an alignment.
How the precision is kept
Closed-form 64-bit expressions on the vertex coordinates. Arcs are defined by a bulge and evaluated from their center. Lengths are compared with absolute 1e-12 and segment parameters with ±1e-12; the collinearity test for a three-point arc is scaled to the lengths involved.
Calculations under this policy
15 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 center, radius, start angle, sweep and length of the arc between two vertices.
k = (1 − b²) ÷ (4b); center = chord middle + k·(−Δy, Δx); r = c·(1 + b²) ÷ (4|b|); sweep = 4·atan(b); L = r·|sweep|
Center and radius come straight from the bulge without a trigonometric call. For a very small bulge k and r are very large: the center is far from the chord, and points later computed as center + r·(cos θ, sin θ) subtract two very large numbers. There is no lower limit on the size of the bulge. The length r·|sweep| is a plain product and is not affected.
featureline.js
lines 96 to 104
CivilCraft 0.8.1
/** The plan arc from a to b with bulge `bulge` (+ = curving left): { cx, cy, r, a0, sweep (signed radians), L } or null. */
export function arcOf(a, b, bulge) {
const bb = okBulge(bulge), dx = b.x - a.x, dy = b.y - a.y, c = Math.hypot(dx, dy);
if (!bb || !(c > 1e-12)) return null;
const k = (1 - bb * bb) / (4 * bb); // centre = chord middle + k × (−dy, dx)
const cx = (a.x + b.x) / 2 - k * dy, cy = (a.y + b.y) / 2 + k * dx;
const r = (c * (1 + bb * bb)) / (4 * Math.abs(bb)), sweep = 4 * Math.atan(bb);
return { cx, cy, r, a0: Math.atan2(a.y - cy, a.x - cx), sweep, L: r * Math.abs(sweep) };
}
Bulge of an arc through three points
64-bit
The bulge of the arc drawn from a start, through a middle point, to an end.
sweep = 2·(π − angle at the middle point); bulge = sign × tan(sweep ÷ 4)
The angle is an acos of a normalized dot product, clamped to [−1, 1]; acos loses digits when the three points are nearly in a line, where the angle is near π. Points are taken as in a line when the cross product is below 1e-12 times the product of the two lengths, a scaled test.
featureline.js
lines 105 to 113
CivilCraft 0.8.1
/** The bulge of the arc from a through m to b (0 when the three are in a line). */
export function bulgeThrough(a, m, b) {
const ax = m.x - a.x, ay = m.y - a.y, bx = b.x - m.x, by = b.y - m.y, cr = ax * by - ay * bx;
if (Math.abs(cr) < 1e-12 * Math.max(1, Math.hypot(ax, ay) * Math.hypot(bx, by))) return 0;
// the inscribed angle at m: the arc's sweep is 2(π − angle a-m-b), turning the way the three points turn
const ang = Math.acos(Math.max(-1, Math.min(1, -(ax * bx + ay * by) / (Math.hypot(ax, ay) * Math.hypot(bx, by)))));
const sweep = 2 * (Math.PI - ang);
return Math.sign(cr) * Math.tan(sweep / 4);
}
Bulge for a typed radius
64-bit
The bulge of the shorter arc of a given radius between two vertices.
Δ = 2·asin(c ÷ 2r); bulge = ±tan(Δ ÷ 4)
The asin argument is capped at 1, so a radius equal to half the chord gives a half circle. A radius smaller than half the chord by more than 1e-9 × max(1, chord) is refused. Near the half circle asin is steep, so the bulge is sensitive to the radius there.
featureline.js
lines 114 to 120
CivilCraft 0.8.1
/** The bulge of the shorter arc of radius r from a to b turning `left` (true) or right; null when r < half the chord. */
export function bulgeForRadius(a, b, r, left = true) {
const c = segLen(a, b);
if (!(r > 0) || !(c > 1e-12) || r < c / 2 - 1e-9 * Math.max(1, c)) return null;
const delta = 2 * Math.asin(Math.min(1, c / (2 * r)));
return (left ? 1 : -1) * Math.tan(delta / 4);
}
Point and direction along a segment
64-bit
The plan point and travel direction at a fraction of a straight or arc segment.
arc: p = c + r·(cos θ, sin θ), θ = a₀ + sweep × u
On an arc the point is evaluated from the center, so at u = 0 and u = 1 it reproduces the stored end vertices only to the rounding of the center and radius. Straight segments interpolate the two ends directly.
featureline.js
lines 121 to 127
CivilCraft 0.8.1
/** Plan point and travel direction (radians) at fraction u (0…1 by length) of the segment from a to b. */
export function pointOnSeg(a, b, bulge, u) {
const A = arcOf(a, b, bulge);
if (!A) return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u, dir: Math.atan2(b.y - a.y, b.x - a.x) };
const th = A.a0 + A.sweep * u;
return { x: A.cx + A.r * Math.cos(th), y: A.cy + A.r * Math.sin(th), dir: th + (A.sweep > 0 ? Math.PI / 2 : -Math.PI / 2) };
}
Segment lengths and grades
64-bit
Each segment's plan length, chord, 3D length, rise and grade.
length = chord or r·|sweep|; 3D length = √(L² + Δz²); grade = Δz ÷ L
Lengths by hypot. The 3D length of an arc segment treats it as a helix piece at one grade. A plan length of 1e-12 or less gives no grade instead of dividing.
featureline.js
lines 161 to 169
CivilCraft 0.8.1
/** [{ i, j, len (plan length, along the arc), chord, len3d, dz, grade, arc }] arc: { cx, cy, r, a0, sweep, L } | null. */
export function segments(f) {
const n = f.pts.length, m = f.closed ? n : n - 1, out = [];
for (let i = 0; i < m; i++) {
const j = (i + 1) % n, a = f.pts[i], chord = segLen(a, f.pts[j]), arc = arcOf(a, f.pts[j], a.b), len = arc ? arc.L : chord, dz = f.pts[j].z - a.z;
out.push({ i, j, len, chord, len3d: Math.hypot(len, dz), dz, grade: len > 1e-12 ? dz / len : null, arc });
}
return out;
}
Stations along a feature line
64-bit
The plan distance from the start to each vertex.
A running sum of segment lengths in order, so rounding accumulates along the line.
featureline.js
lines 156 to 160
CivilCraft 0.8.1
export function stations(f) {
const out = [0];
for (const s of segments(f)) out.push(out[out.length - 1] + s.len);
return out;
}
Elevation from a grade
64-bit
The far vertex of a segment moved to the elevation a typed grade gives.
z₂ = z₁ + grade × length
One multiplication and one addition. Reading the grade back divides the same rise by the same length, so it can differ from the typed grade in the last digit.
featureline.js
lines 199 to 203
CivilCraft 0.8.1
export function setGrade(f, i, grade) {
const s = segments(f)[i];
if (!s || !fin(grade) || !(s.len > 1e-12)) return f;
return setElevation(f, s.j, f.pts[i].z + grade * s.len);
}
One grade between two vertices
64-bit
The vertices between two chosen ones placed on a single grade.
z = z₀ + (z₁ − z₀) × (s − s₀) ÷ L
Linear interpolation by station; the stations are differences of the running sum.
featureline.js
lines 206 to 213
CivilCraft 0.8.1
export function evenGrade(f, i, j) {
const n = f.pts.length;
if (!(i >= 0 && j < n && j > i + 1)) return f;
const st = stations(f), L = st[j] - st[i];
if (!(L > 1e-12)) return f;
const z0 = f.pts[i].z, z1 = f.pts[j].z;
return withPts(f, f.pts.map((p, k) => (k > i && k < j ? { ...p, z: z0 + ((z1 - z0) * (st[k] - st[i])) / L } : p)));
}
Stepped offset
64-bit
A copy of the line moved sideways with mitred corners and raised by a height or a cross slope.
corner shift = bisector × d ÷ cos(half angle); z + Δz, or z + |d| × slope
The cosine of the half angle is a dot product of unit normals; a corner where it is below 0.1 is refused. An arc keeps its bulge, so the offset arc is concentric with the original only where its ends are tangent to their neighbors, as the code comment states. An arc that would shrink to a radius of 1e-9 × max(1, r) or less is refused.
featureline.js
lines 226 to 266
CivilCraft 0.8.1
/** Plan offset to the right of the direction of travel (negative = left), mitred; z + dz (or + |dist| × slope). */
export function offsetFeature(f, dist, { dz = null, slope = null } = {}) {
if (!fin(dist) || dist === 0) return { f: null, problem: t('fl.offset.zero') };
const raiseBy = fin(dz) ? dz : fin(slope) ? Math.abs(dist) * slope : 0;
const P = f.pts, n = P.length;
const right = (dir) => ({ x: Math.sin(dir), y: -Math.cos(dir) }); // right of the direction of travel
// each segment's right-hand normal where it starts (s) and ends (e): the same for a straight one, radial on an arc (0.6 WL.24)
const norm = (a, b) => {
if (!(segLen(a, b) > 1e-12)) return null;
const p0 = pointOnSeg(a, b, a.b, 0), p1 = pointOnSeg(a, b, a.b, 1);
return { s: right(p0.dir), e: right(p1.dir) };
};
const segN = [];
const m = f.closed ? n : n - 1;
for (let i = 0; i < m; i++) segN.push(norm(P[i], P[(i + 1) % n]));
if (segN.some((v) => !v)) return { f: null, problem: t('fl.offset.repeat') };
// an arc curving toward the offset side shrinks by the distance: at zero or below it would turn inside out
for (let i = 0; i < m; i++) { const A = arcOf(P[i], P[(i + 1) % n], P[i].b); if (A && A.r + Math.sign(A.sweep) * dist <= 1e-9 * Math.max(1, A.r)) return { f: null, problem: t('fl.offset.fold') }; }
const out = [];
for (let i = 0; i < n; i++) {
const nIn = (f.closed ? segN[(i - 1 + m) % m] : segN[i - 1])?.e, nOut = (f.closed ? segN[i % m] : segN[i])?.s;
let ox, oy;
if (!nIn) { ox = nOut.x * dist; oy = nOut.y * dist; }
else if (!nOut) { ox = nIn.x * dist; oy = nIn.y * dist; }
else {
const bx = nIn.x + nOut.x, by = nIn.y + nOut.y, c = (bx * nOut.x + by * nOut.y) / Math.hypot(bx, by);
if (Math.hypot(bx, by) < 1e-9 || c < 0.1) return { f: null, problem: t('fl.offset.sharp') }; // a hairpin: the mitre would run away
const L = Math.hypot(bx, by);
ox = (bx / L) * (dist / c); oy = (by / L) * (dist / c);
}
const q = { x: P[i].x + ox, y: P[i].y + oy, z: P[i].z + raiseBy };
if (okBulge(P[i].b)) q.b = P[i].b; // an arc stays an arc with the same sweep (concentric where its ends are tangent)
out.push(q);
}
// the offset must keep every segment's direction (a segment that turns round = the offset folded over a tight bend)
for (let i = 0; i < m; i++) {
const a = P[i], b = P[(i + 1) % n], c = out[i], d = out[(i + 1) % n];
if ((b.x - a.x) * (d.x - c.x) + (b.y - a.y) * (d.y - c.y) <= 0) return { f: null, problem: t('fl.offset.fold') };
}
return { f: { ...f, id: null, pts: out }, problem: null };
}
Straight segment against an alignment element
64-bit
Where a straight feature line segment crosses a line or arc of the alignment, with the station.
line: 2×2 solve by cross products; arc: A·t² + B·t + C = 0, t = (−B ± √(B² − 4AC)) ÷ 2A
The arc case uses the textbook quadratic formula, where one root subtracts nearly equal numbers when B² is much larger than 4AC, and C is the squared distance from the segment's start to the center minus the squared radius, two nearly equal numbers when the segment starts near the arc. A discriminant below zero gives no crossing, so a tangent contact that rounds just negative is missed. The parallel test is an absolute 1e-15.
featureline.js
lines 336 to 361
CivilCraft 0.8.1
/** Where plan segment a→b crosses one alignment element: [{ t (0…1 along the segment), sta }]. */
function crossElement(e, a, b) {
const out = [], dx = b.x - a.x, dy = b.y - a.y;
if (e.type === 'line') {
const ex = e.p1.x - e.p0.x, ey = e.p1.y - e.p0.y, den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-15) return out; // parallel (or along it: no single crossing)
const wx = e.p0.x - a.x, wy = e.p0.y - a.y;
const t = (wx * ey - wy * ex) / den, u = (wx * dy - wy * dx) / den;
if (t >= -1e-12 && t <= 1 + 1e-12 && u >= -1e-12 && u <= 1 + 1e-12) out.push({ t: Math.min(1, Math.max(0, t)), sta: e.sta + Math.min(1, Math.max(0, u)) * e.len });
return out;
}
// arc: |a + t·d − c| = r
const fx = a.x - e.c.x, fy = a.y - e.c.y, A = dx * dx + dy * dy, B = 2 * (fx * dx + fy * dy), C = fx * fx + fy * fy - e.r * e.r;
const disc = B * B - 4 * A * C;
if (A < 1e-24 || disc < 0) return out;
const sq = Math.sqrt(disc);
for (const t of disc === 0 ? [-B / (2 * A)] : [(-B - sq) / (2 * A), (-B + sq) / (2 * A)]) {
if (t < -1e-12 || t > 1 + 1e-12) continue;
const x = a.x + t * dx, y = a.y + t * dy;
let sw = ((Math.atan2(y - e.c.y, x - e.c.x) - e.a0) * (e.ccw ? 1 : -1)) % TAU;
if (sw < 0) sw += TAU;
if (sw > e.delta + 1e-12) continue;
out.push({ t: Math.min(1, Math.max(0, t)), sta: e.sta + Math.min(sw, e.delta) * e.r });
}
return out;
}
Arc segment against an alignment element
64-bit
Where an arc of a feature line crosses a line or arc of the alignment.
two circles: along = (r₁² − r₂² + d²) ÷ 2d; h = √(r₁² − along²)
For two circles the height h is a square root of a difference of squares, clamped at zero, so nearly tangent circles give a crossing with few correct digits across the line of centers. Circles are taken as apart or nested with an absolute 1e-12.
featureline.js
lines 371 to 400
CivilCraft 0.8.1
/** Where a feature line's ARC segment A crosses one alignment element: [{ t (0…1 along the arc), sta }] (0.6 WL.24). */
function crossArcElement(e, A) {
const out = [];
if (e.type === 'line') { // |p0 + w·d − C| = r
const dx = e.p1.x - e.p0.x, dy = e.p1.y - e.p0.y, fx = e.p0.x - A.cx, fy = e.p0.y - A.cy;
const a = dx * dx + dy * dy, b = 2 * (fx * dx + fy * dy), c = fx * fx + fy * fy - A.r * A.r, disc = b * b - 4 * a * c;
if (a < 1e-24 || disc < 0) return out;
const sq = Math.sqrt(disc);
for (const w of disc === 0 ? [-b / (2 * a)] : [(-b - sq) / (2 * a), (-b + sq) / (2 * a)]) {
if (w < -1e-12 || w > 1 + 1e-12) continue;
const x = e.p0.x + w * dx, y = e.p0.y + w * dy, u = arcFrac(A, Math.atan2(y - A.cy, x - A.cx));
if (u !== null) out.push({ t: u, sta: e.sta + Math.min(1, Math.max(0, w)) * e.len });
}
return out;
}
// arc × arc: the two circles' crossings, kept where both arcs pass
const dx = e.c.x - A.cx, dy = e.c.y - A.cy, d = Math.hypot(dx, dy);
if (d < 1e-12 || d > A.r + e.r + 1e-12 || d < Math.abs(A.r - e.r) - 1e-12) return out;
const along = (A.r * A.r - e.r * e.r + d * d) / (2 * d), h = Math.sqrt(Math.max(0, A.r * A.r - along * along));
const mx = A.cx + (along * dx) / d, my = A.cy + (along * dy) / d;
for (const s of h < 1e-12 ? [0] : [-1, 1]) {
const x = mx - (s * h * dy) / d, y = my + (s * h * dx) / d, u = arcFrac(A, Math.atan2(y - A.cy, x - A.cx));
if (u === null) continue;
let sw = ((Math.atan2(y - e.c.y, x - e.c.x) - e.a0) * (e.ccw ? 1 : -1)) % TAU;
if (sw < 0) sw += TAU;
if (sw > e.delta + 1e-12) { if (TAU - sw < 1e-12) sw = 0; else continue; }
out.push({ t: u, sta: e.sta + Math.min(sw, e.delta) * e.r });
}
return out;
}
Feature line read from a file
64-bit
The vertices kept when a feature line is read from a project file or a drawing.
Coordinates are copied unrounded. A vertex with |x| or |y| of 1e9 or more is dropped. An elevation that is missing, or 1e6 or more in size, is replaced by 0. A bulge on a segment shorter than 1e-12 is removed.
featureline.js
lines 64 to 89
CivilCraft 0.8.1
export function cleanFeature(o, units = 'ft') {
if (!o || typeof o !== 'object' || !Array.isArray(o.pts)) return null;
const pts = o.pts.slice(0, MAX_VERTICES).filter((p) => p && fin(p.x) && fin(p.y) && Math.abs(p.x) < 1e9 && Math.abs(p.y) < 1e9).map((p) => {
const q = { x: p.x, y: p.y, z: fin(p.z) && Math.abs(p.z) < 1e6 ? p.z : 0 };
if (okBulge(p.b)) q.b = p.b; // 0.6 WL.24: the segment starting here is an arc
return q;
});
if (pts.length < 2) return null;
const d = FEATURE_DEFAULTS(units);
const closed = o.closed === true && (pts.length >= 3 || (pts.length === 2 && pts.every((p) => p.b))); // two arcs make a circle
// a bulge needs a segment with length after it (and none after the last vertex of an open line)
pts.forEach((p, i) => { const q = pts[(i + 1) % pts.length]; if (p.b && ((!closed && i === pts.length - 1) || !(segLen(p, q) > 1e-12))) delete p.b; });
return {
id: /^f\d{1,9}$/.test(String(o.id)) ? String(o.id) : null,
name: String(o.name ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || t('toolbar.feature'),
pts, closed,
use: USES.includes(o.use) ? o.use : d.use,
daylight: o.daylight === true,
cut: fin(o.cut) && o.cut > 0 && o.cut < 1000 ? o.cut : d.cut,
fill: fin(o.fill) && o.fill > 0 && o.fill < 1000 ? o.fill : d.fill,
// 0.8 WL.26: the team's shared line this one came from or went to (team-lines.js); the project file keeps the link
...(o.shared && typeof o.shared === 'object' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(o.shared.uuid || ''))
? { shared: { uuid: String(o.shared.uuid).toLowerCase(), revision: Number.isInteger(o.shared.revision) && o.shared.revision >= 1 && o.shared.revision < 1e9 ? o.shared.revision : 1 } } : {}),
...(o.link && cleanLink(o.link) ? { link: cleanLink(o.link) } : {}), // 0.8 LA.2: "From a line" made it; it follows its line (landscape/link-ui.js)
};
}
The elevation each vertex of a new feature line gets.
A vertex takes its own z if it has one, else the ground surface elevation at that point. A vertex off the surface takes the previous vertex's elevation, or 0 for the first; those are counted and reported as guessed.
features-ui.js
lines 42 to 60
CivilCraft 0.8.1
function finish(pts, closed) {
const min = closed ? 3 : 2;
if (pts.length < min) { ctx.say(t(closed ? 'fl.need3' : 'fl.need2')); return null; }
const sf = ctx.eg();
let guessed = 0, prev = null;
const out = pts.map((p) => {
let z = Number.isFinite(p.z) ? p.z : sf?.elevationAt(p.x, p.y, 1e-6);
if (z === null || z === undefined) { z = prev ?? 0; guessed++; }
prev = z;
return okB(p.b) ? { x: p.x, y: p.y, z, b: p.b } : { x: p.x, y: p.y, z }; // 0.6 WL.24: an arc drawn with C
});
out.forEach((q, i) => { if (q.b && !closed && i === out.length - 1) delete q.b; });
const p = P(), id = p.nextId('f'), n = (p.state.features || []).length + 1, d = FEATURE_DEFAULTS(U());
const name = t('fl.name', { n });
p.change(t('fl.undo.draw'), (s) => { (s.features ||= []).push({ id, name, pts: out, closed: !!closed, use: d.use, daylight: false, cut: d.cut, fill: d.fill }); });
ctx.select({ kind: 'feature', id });
ctx.say(t('fl.drawn', { name, n: out.length }) + (guessed ? ' ' + t('fl.drawn.guessed', { n: guessed }) : ''));
return id;
}
Elevation and grade boxes
64-bit
A typed vertex elevation or segment grade applied to the line.
grade = typed percent ÷ 100
The boxes are pre-filled with the elevation at 3 decimals and the grade at 0.01 %. numInput in civilcraft.js commits the box's text on Enter even when it was not edited, so pressing Enter in an untouched box applies the rounded text: the elevation is stored at 3 decimals, or the far vertex is moved to the rounded grade.
features-ui.js
lines 185 to 186
CivilCraft 0.8.1
const zIn = ctx.numInput(fixed(p.z, 3).replace(/,/g, ''), (v) => { const z = Number(v); if (!Number.isFinite(z) || v === '') return false; edit(id, t('fl.undo.elev', { n: i + 1 }), (x) => setElevation(x, i, z)); return true; }, { label: t('fl.props.elev.of', { n: i + 1 }), width: '6.5rem' });
const gIn = s ? ctx.numInput(s.grade === null ? '' : (s.grade * 100).toFixed(2), (v) => { const g = Number(String(v).replace('%', '')) / 100; if (!Number.isFinite(g) || v === '') return false; edit(id, t('fl.undo.grade', { n: i + 1 }), (x) => setGrade(x, i, g)); return true; }, { label: t('fl.props.grade.of', { n: i + 1 }), text: true, calc: false, width: '4.25rem' }) : '—';
Offset distance and amount
64-bit
The typed offset distance and height or slope passed to the stepped offset.
slope = typed percent ÷ 100
Both boxes are read with Number, unrounded. A slope is typed in percent and divided by 100.
features-ui.js
lines 224 to 226
CivilCraft 0.8.1
const D = Number(dist.value), A = Number(String(amt.value).replace('%', ''));
if (!Number.isFinite(D) || !Number.isFinite(A)) { ctx.say(t('fl.offset.numbers')); return; }
const r = offsetFeature(find(id), D, how.value === 'dz' ? { dz: A } : { slope: A / 100 });
In detail
Method
Bulge arcs: sweep = 4·atan(bulge), radius = chord·(1 + b²) ÷ (4|b|). Grade = Δz ÷ plan length along the arc. Offsets by mitred normals. Crossings with alignment lines by a 2×2 solve, with arcs by the quadratic for a line and a circle or by the two-circle construction.
Number types
64-bit numbers.
Tolerances
Zero length: 1e-12 (absolute). Segment and sweep parameters: ±1e-12. Parallel lines: denominator below 1e-15 (absolute). Three points in a line: cross product below 1e-12 × the product of the two lengths. Radius too small for the chord: below half the chord by more than 1e-9 × max(1, chord). Mitre refused when the cosine of the half angle is below 0.1. Duplicate crossings merged within 1e-6 in station and elevation.
Limits
A bulge may be any non-zero number below 100 in size; for a very small bulge the arc's center is far away and points computed from it lose digits in proportion to radius ÷ chord. A coordinate of 1e9 or more drops the vertex, and an elevation of 1e6 or more is replaced by 0, when a line is read from a file.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.