Resources Numerical accuracy Calculation policy

Road intersections, striping and signs

Works out curb returns, the pavement outline and area of an intersection, sight distance, stripe and dash layouts, crosswalk bars, sign positions and the quantities of each.

How the precision is kept

The geometry is closed form in 64-bit: tangent lengths, centers and line crossings. Curved curb returns are then sampled into chords every 0.05 rad, and the pavement area and the three-center curb length are measured on those chords. The sight distance uses the published constant 0.278. Dash and sign positions are stepped by repeated addition.

Open item. Report the three-center curb length from the arc angles and radii, as the simple curb return already does, instead of from the chord sum.

Calculations under this policy

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

intersection.js CivilCraft 0.8.1 64-bit 14 calculations Reviewed

Tests bin/test-civilcraft-intersection.mjs

Simple curb return

64-bit

The circular arc joining two pavement edges at a corner: tangent points, center, arc length.

ψ = acos(a · b); T = r ÷ tan(ψ ÷ 2); center = corner + bisector × r ÷ sin(ψ ÷ 2); length = r × |Δ|

The acos argument is clamped to −1…1, and corners with ψ below 0.001 rad or above π − 0.001 rad are refused, which keeps tan and sin away from zero. The arc length is r times the swept angle, not a chord sum; the swept angle comes from two atan2 values. The drawn arc is chords of at most 0.05 rad, each point placed from i ÷ n of the sweep.

  • intersection.js
  • lines 38 to 49
  • CivilCraft 0.8.1
/** A circular fillet between two lines leaving `corner` along unit vectors dirA and dirB. */
export function cornerFillet({ corner, dirA, dirB, radius, step = 0.05 }) {
  const a = unit(dirA), b = unit(dirB), cosPsi = dot(a, b), psi = Math.acos(Math.max(-1, Math.min(1, cosPsi)));
  if (!(radius > 0) || psi < 1e-3 || psi > Math.PI - 1e-3) return null;
  const T = radius / Math.tan(psi / 2), bis = unit({ x: a.x + b.x, y: a.y + b.y }), cd = radius / Math.sin(psi / 2);
  const T1 = { x: corner.x + a.x * T, y: corner.y + a.y * T }, T2 = { x: corner.x + b.x * T, y: corner.y + b.y * T }, centre = { x: corner.x + bis.x * cd, y: corner.y + bis.y * cd };
  const a1 = Math.atan2(T1.y - centre.y, T1.x - centre.x), a2 = Math.atan2(T2.y - centre.y, T2.x - centre.x);
  let d = a2 - a1; while (d > Math.PI) d -= TAU; while (d < -Math.PI) d += TAU;
  const n = Math.max(2, Math.ceil(Math.abs(d) / step)), arc = [];
  for (let i = 0; i <= n; i++) { const t = a1 + (d * i) / n; arc.push({ x: centre.x + radius * Math.cos(t), y: centre.y + radius * Math.sin(t) }); }
  return { T1, T2, centre, arc, tangent: T, angle: Math.abs(d), length: radius * Math.abs(d), radius };
}

Three-center curb return

64-bit

A compound curve of three arcs (radii 2r, r, 2r by default) between two pavement edges.

angles = share·Δ, (1 − 2·share)·Δ, share·Δ; T = p_x − p_y·cos h ÷ sin h; length = Σ chord lengths

The three arcs are walked in a local frame and then turned and moved onto the corner. The reported length is the sum of the chords between sample points, not Σ r × angle, so it is short of the true arc length by up to about θ² ÷ 24 = 1 part in 10,000 for the 0.05 rad step. The division by sin h is kept away from zero by the same angle limits as the simple return.

  • intersection.js
  • lines 51 to 74
  • CivilCraft 0.8.1
/**
 * A three-centre compound curve between two lines leaving `corner` along dirA and dirB (the curb return of a turning vehicle: entry arc, a tighter middle arc, exit arc).
 * radii = [R1, R2, R1] (outer, middle, outer; R2 < R1, the usual ratio is 2:1:2) and `share` is the fraction of the total turn each outer arc takes (placeholder 2/9, the
 * authority's table decides). Symmetric, so both tangent lengths are equal. Method: walk the three arcs in a local frame (start on the first line heading to the corner),
 * find where the end line meets the start line (the corner), then rotate and slide so that point lands on `corner`. Plain geometry (Easa, Geometric Design).
 */
export function cornerCompound3({ corner, dirA, dirB, radii, share = 2 / 9, step = 0.05 }) {
  const a = unit(dirA), b = unit(dirB), psi = Math.acos(Math.max(-1, Math.min(1, dot(a, b))));
  const [R1, R2] = radii || [];
  if (!(R1 > 0) || !(R2 > 0) || psi < 1e-3 || psi > Math.PI - 1e-3 || !(share > 0) || share >= 0.5) return null;
  const turn = Math.PI - psi, sg = crs({ x: -a.x, y: -a.y }, b) >= 0 ? 1 : -1, ang = [share * turn, (1 - 2 * share) * turn, share * turn], rad = [R1, R2, R1];
  // local frame: start at the origin heading +x, turning to the left (sg = 1) or right; pts in local (x, y·sg)
  let px = 0, py = 0, h = 0; const local = [{ x: 0, y: 0 }];
  for (let i = 0; i < 3; i++) {
    const cx = px - Math.sin(h) * rad[i], cy = py + Math.cos(h) * rad[i], n = Math.max(2, Math.ceil(ang[i] / step));
    for (let j = 1; j <= n; j++) { const th = h + (ang[i] * j) / n; local.push({ x: cx + Math.sin(th) * rad[i], y: cy - Math.cos(th) * rad[i] }); }
    px = cx + Math.sin(h + ang[i]) * rad[i]; py = cy - Math.cos(h + ang[i]) * rad[i]; h += ang[i];
  }
  // the end line (point p, heading h) meets the x-axis at x = px − py·cos h / sin h
  const T = px - (py * Math.cos(h)) / Math.sin(h), d0 = { x: -a.x, y: -a.y }, T1 = { x: corner.x + a.x * T, y: corner.y + a.y * T };
  const arc = local.map((q) => ({ x: T1.x + d0.x * q.x - sg * d0.y * q.y, y: T1.y + d0.y * q.x + sg * d0.x * q.y }));
  let length = 0; for (let i = 1; i < arc.length; i++) length += Math.hypot(arc[i].x - arc[i - 1].x, arc[i].y - arc[i - 1].y);
  return { T1, T2: arc[arc.length - 1], arc, tangent: T, angle: turn, length, radius: R2, radii: rad, kind: '3c' };
}

Intersection outline, area and curb length

64-bit

The closed pavement edge of an intersection, its area and the total length of its curb returns.

corner: p_a + s·d_a = p_b + t·d_b, s = ((p_b − p_a) × d_b) ÷ (d_a × d_b); area = |shoelace(outline)|

Each corner is the crossing of two edge lines; legs whose unit directions have a cross product below 1e-9 are treated as parallel. The area is the shoelace sum of the outline with curb returns as chords, so it differs from the true area by the slivers between chord and arc at each return. Leg directions are cos and sin of the bearing.

  • intersection.js
  • lines 76 to 118
  • CivilCraft 0.8.1
/**
 * Intersection of two or more legs meeting at `centre`. Each leg is a road of total half width `halfWidth` (to the edge of pavement, both sides alike) leaving the
 * centre along `bearing` (math radians, counter-clockwise from +x). Legs are taken in order around the centre; between neighbours the edge lines are joined by a
 * curb return of the given radius (one number for all, or `radii[i]` for the corner after leg i). The outline is the closed pavement edge.
 */
export function intersection({ centre, legs, radius = 9, radii = null, curb: curbKind = 'simple' }) {
  const problems = [];
  if (!centre || !Array.isArray(legs) || legs.length < 2) return { legs: [], corners: [], pavement: [], area: 0, curbLength: 0, problems: [{ code: 'int.legs' }] };
  const L = legs.map((l, i) => ({ ...l, i, bearing: norm(l.bearing), dir: { x: Math.cos(l.bearing), y: Math.sin(l.bearing) } })).sort((p, q) => p.bearing - q.bearing);
  const out = [], corners = [];
  let curb = 0;
  for (let k = 0; k < L.length; k++) {
    const a = L[k], b = L[(k + 1) % L.length];
    // leg a's left edge (counter-clockwise side) meets leg b's right edge
    const na = { x: -a.dir.y, y: a.dir.x }, nb = { x: b.dir.y, y: -b.dir.x };
    const pa = { x: centre.x + na.x * a.halfWidth, y: centre.y + na.y * a.halfWidth }, pb = { x: centre.x + nb.x * b.halfWidth, y: centre.y + nb.y * b.halfWidth };
    const den = crs(a.dir, b.dir);
    const nra = { x: a.dir.y, y: -a.dir.x };
    out.push({ x: centre.x + nra.x * a.halfWidth + a.dir.x * a.length, y: centre.y + nra.y * a.halfWidth + a.dir.y * a.length });   // far right
    if (Math.abs(den) < 1e-9) {   // legs run parallel (a straight road, two legs): the edge runs straight past
      problems.push({ code: 'int.parallel', leg: [a.i, b.i] });
      corners.push({ between: [a.i, b.i], corner: null, fillet: null });
      out.push({ x: centre.x + na.x * a.halfWidth + a.dir.x * a.length, y: centre.y + na.y * a.halfWidth + a.dir.y * a.length });
      continue;
    }
    // intersection of the two edge lines: pa + s·a.dir = pb + t·b.dir
    const s = crs(sub(pb, pa), b.dir) / den, corner = { x: pa.x + a.dir.x * s, y: pa.y + a.dir.y * s };
    // the leg's far left end, then in to the corner, then the fillet, then out along b
    out.push({ x: centre.x + na.x * a.halfWidth + a.dir.x * a.length, y: centre.y + na.y * a.halfWidth + a.dir.y * a.length });
    const r = Number.isFinite(a.radius) && a.radius >= 0 ? a.radius : radii ? radii[k] ?? radius : radius;   // a leg's own `radius` is the return on its counter-clockwise side
    const toA = unit(sub({ x: pa.x + a.dir.x * a.length, y: pa.y + a.dir.y * a.length }, corner)), toB = unit(sub({ x: pb.x + b.dir.x * b.length, y: pb.y + b.dir.y * b.length }, corner));
    const f = r > 0 ? (curbKind === '3c' ? cornerCompound3({ corner, dirA: toA, dirB: toB, radii: [2 * r, r, 2 * r] }) || cornerFillet({ corner, dirA: toA, dirB: toB, radius: r }) : cornerFillet({ corner, dirA: toA, dirB: toB, radius: r })) : null;
    if (f) {
      if (f.tangent > a.length || f.tangent > b.length) problems.push({ code: 'int.radius.long', leg: [a.i, b.i], tangent: f.tangent });
      out.push(...f.arc); curb += f.length;
    } else out.push(corner);
    corners.push({ between: [a.i, b.i], corner, fillet: f });
  }
  // the walk above lists leg a's far-right, far-left, then the corner/arc; the left end must come before the corner: reorder is already so
  // (far right → far left → arc → next leg's far right …)
  const pavement = out;
  return { centre, legs: L, corners, pavement, area: Math.abs(polyArea(pavement)), curbLength: curb, problems };
}

Polygon area

64-bit

The signed area of an outline.

A = Σ (x_i·y_j − x_j·y_i) ÷ 2

The shoelace sum on the coordinates as given, with no shift to a local origin, so large coordinates cost digits in the area.

  • intersection.js
  • line 36
  • CivilCraft 0.8.1
export const polyArea = (p) => { let s = 0; for (let i = 0; i < p.length; i++) { const a = p[i], b = p[(i + 1) % p.length]; s += a.x * b.y - b.x * a.y; } return s / 2; };

Intersection sight distance

64-bit

The sight distance along the major road for a driver turning out of the minor road, in meters.

ISD = 0.278 × V × (t_g + 0.5 × extra lanes)

0.278 is the rounded constant of the published formula; the exact conversion from km/h to m/s is 1 ÷ 3.6 = 0.2778, a difference of 8 parts in 10,000. The default gap of 7.5 s and the 0.5 s per extra lane are settings.

  • intersection.js
  • lines 120 to 121
  • CivilCraft 0.8.1
/** Intersection sight distance, case B1, in metres. */
export function isd({ speed, gap = 7.5, lanes = 0 }) { return 0.278 * speed * (gap + 0.5 * Math.max(0, lanes)); }

Sight triangle

64-bit

The three corners of the sight triangle from the driver's position and the two leg lengths.

Each direction is divided by its length (a zero length is replaced by 1) and multiplied by the leg length.

  • intersection.js
  • lines 123 to 130
  • CivilCraft 0.8.1
/**
 * Sight triangle for a minor-road driver: the minor leg is `minorSetback` back from the major road's near edge, the major leg `major` along the major road
 * to the left (approach side). Points: the driver, the point on the minor road, the point on the major road.
 */
export function sightTriangle({ driver, minorDir, majorDir, minorLeg, majorLeg }) {
  const m = unit(minorDir), M = unit(majorDir);
  return [{ x: driver.x, y: driver.y }, { x: driver.x + m.x * minorLeg, y: driver.y + m.y * minorLeg }, { x: driver.x + M.x * majorLeg, y: driver.y + M.y * majorLeg }];
}

Obstacles in the sight triangle

64-bit

Which obstacle outlines reach into the sight triangle.

Point-in-polygon by crossing count and segment crossing by a parametric solve, in ordinary 64-bit arithmetic. Segments are parallel when the cross product is below 1e-12 (absolute, length squared); touching counts as crossing. The decisions are not exact predicates.

  • intersection.js
  • lines 131 to 143
  • CivilCraft 0.8.1
const pip = (p, poly) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) if ((poly[i].y > p.y) !== (poly[j].y > p.y) && p.x < ((poly[j].x - poly[i].x) * (p.y - poly[i].y)) / (poly[j].y - poly[i].y) + poly[i].x) c = !c; return c; };
const segX = (a, b, c, d) => { const r = sub(b, a), s = sub(d, c), den = crs(r, s); if (Math.abs(den) < 1e-12) return false; const t = crs(sub(c, a), s) / den, u = crs(sub(c, a), r) / den; return t >= 0 && t <= 1 && u >= 0 && u <= 1; };
/** Which polygons (buildings, hedges, signs as small squares) intrude into the triangle. */
export function triangleBlocked(tri, obstacles) {
  const hit = [];
  obstacles.forEach((o, k) => {
    const poly = o.pts || o;
    let blocked = poly.some((p) => pip(p, tri)) || tri.some((p) => pip(p, poly));
    for (let i = 0; !blocked && i < poly.length; i++) for (let j = 0; j < 3 && !blocked; j++) if (segX(poly[i], poly[(i + 1) % poly.length], tri[j], tri[(j + 1) % 3])) blocked = true;
    if (blocked) hit.push(k);
  });
  return hit;
}

Offset line

64-bit

A polyline moved sideways by a distance, with mitred corners.

corner shift = d × min(4, 1 ÷ max(cos(half turn), 0.25)) along the mitre

The mitre direction is the sum of the two unit normals; when that sum is shorter than 1e-9 (a reversal) the first normal is used. The mitre is limited to 4 times the offset, so at sharp corners the offset line is closer than d to the original.

  • intersection.js
  • lines 146 to 159
  • CivilCraft 0.8.1
/** A polyline moved sideways by d (left of the direction of travel +), corners mitred (limited to 4×d). */
export function offsetLine(line, d) {
  const n = line.length, out = [];
  if (n < 2) return line.slice();
  const dirs = []; for (let i = 0; i + 1 < n; i++) dirs.push(unit(sub(line[i + 1], line[i])));
  for (let i = 0; i < n; i++) {
    const a = dirs[Math.max(0, i - 1)], b = dirs[Math.min(n - 2, i)];
    const na = { x: -a.y, y: a.x }, nb = { x: -b.y, y: b.x }, m = { x: na.x + nb.x, y: na.y + nb.y }, ml = len(m);
    if (ml < 1e-9) { out.push({ x: line[i].x + na.x * d, y: line[i].y + na.y * d }); continue; }
    const cosHalf = dot(unit(m), na), sc = Math.min(4, 1 / Math.max(cosHalf, 0.25));
    const mm = unit(m); out.push({ x: line[i].x + mm.x * d * sc, y: line[i].y + mm.y * d * sc });
  }
  return out;
}

Part of a line between two distances

64-bit

Cuts a polyline to the stretch between two distances along it.

The running distance is a sum of segment lengths. Points are interpolated by s ÷ L; a zero-length segment is skipped because the piece must be longer than 1e-12. A repeated point within 1e-9 is not added twice.

  • intersection.js
  • lines 160 to 173
  • CivilCraft 0.8.1
/** Cut a polyline into [from, to] (distances along it). */
export function sliceLine(line, from, to) {
  const out = []; let run = 0;
  for (let i = 0; i + 1 < line.length; i++) {
    const a = line[i], b = line[i + 1], L = Math.hypot(b.x - a.x, b.y - a.y), s = Math.max(from - run, 0), e = Math.min(to - run, L);
    if (e > s + 1e-12) {
      const p = { x: a.x + ((b.x - a.x) * s) / L, y: a.y + ((b.y - a.y) * s) / L }, q = { x: a.x + ((b.x - a.x) * e) / L, y: a.y + ((b.y - a.y) * e) / L };
      if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) > 1e-9) out.push(p);
      out.push(q);
    }
    run += L;
  }
  return out;
}

Dash positions

64-bit

The painted lengths of a dashed line.

dash k starts at from − phase + k × (dash + gap)

The start of each dash is reached by repeated addition (s += dash + gap), so rounding accumulates with the number of dashes. Ends are tested with 1e-9.

  • intersection.js
  • lines 174 to 179
  • CivilCraft 0.8.1
/** Dashes of length `dash` every `dash + gap` along the line between distances from and to; the last dash is cut short. */
export function dashSegments(line, { dash, gap, from = 0, to = Infinity, phase = 0 }) {
  const total = polyLen(line), end = Math.min(to, total), segs = [];
  for (let s = from - phase; s < end - 1e-9; s += dash + gap) { const a = Math.max(s, from), b = Math.min(s + dash, end); if (b > a + 1e-9) segs.push(sliceLine(line, a, b)); }
  return segs;
}

Striping and its quantities

64-bit

Lays out center, lane and edge lines and totals the painted length and painted area.

length per kind = Σ line lengths; painted area = Σ length × width

Lane line offsets are half the center width plus i × lane width from the whole-number lane index. Quantities are plain sums of polyline lengths in 64-bit.

  • intersection.js
  • lines 192 to 223
  • CivilCraft 0.8.1
/**
 * Striping along a road. `line` is the centreline (a sampled polyline in plan units, in the direction of stationing); plan:
 *   { lanesLeft, lanesRight, laneWidth, shoulder (0 = none), centre: 'double'|'dashed'|'none', edge: true, marking: { kind → overrides }, breaks: [{ from, to }] (distances along the line left unpainted: intersections) }
 * Lanes on the left of the direction are `lanesLeft`. Returns the painted items and the quantities (length per kind, painted length of dashed lines).
 */
export function stripe(line, plan) {
  const total = polyLen(line), brk = (plan.breaks || []).slice().sort((a, b) => a.from - b.from), items = [];
  const mk = (kind) => ({ ...MARKING[kind], ...(plan.marking?.[kind] || {}) });
  const spans = []; let at = 0;
  for (const b of brk) { if (b.from > at) spans.push([at, Math.min(b.from, total)]); at = Math.max(at, b.to); }
  if (at < total) spans.push([at, total]);
  const emit = (kind, off, tag = '') => {
    const m = mk(kind), base = off === 0 ? line : offsetLine(line, off);
    for (const [s, e] of spans) {
      if (m.dashed) for (const seg of dashSegments(base, { dash: m.dash, gap: m.gap, from: s, to: e })) items.push({ kind, tag, pts: seg, width: m.width, colour: m.colour, dashed: true });
      else { const seg = sliceLine(base, s, e); if (seg.length > 1) items.push({ kind, tag, pts: seg, width: m.width, colour: m.colour, dashed: false }); }
    }
  };
  const w = plan.laneWidth, cw = (plan.centreWidth ?? 0);
  if (plan.centre === 'double') { const g = (mk('centre-double').gapBetween + mk('centre-double').width) / 2; emit('centre-double', g, 'L'); emit('centre-double', -g, 'R'); }
  else if (plan.centre === 'dashed') emit('centre-dashed', 0);
  const half = cw / 2;
  for (let i = 1; i < (plan.lanesLeft || 0); i++) emit('lane-dashed', half + i * w, 'L' + i);
  for (let i = 1; i < (plan.lanesRight || 0); i++) emit('lane-dashed', -(half + i * w), 'R' + i);
  if (plan.edge !== false) {
    if (plan.lanesLeft) emit('edge', half + plan.lanesLeft * w - (plan.edgeIn ?? 0), 'L');
    if (plan.lanesRight) emit('edge', -(half + plan.lanesRight * w - (plan.edgeIn ?? 0)), 'R');
  }
  const q = {}; let painted = 0;
  for (const it of items) { const l = polyLen(it.pts); q[it.kind] = (q[it.kind] || 0) + l; painted += l * it.width; }
  return { items, quantities: { length: q, paintedArea: painted, total: Object.values(q).reduce((s, v) => s + v, 0) } };
}

Crosswalk bars

64-bit

The number and position of crosswalk bars across a road.

count = max(1, ⌊(width + gap) ÷ (bar + gap)⌋); bar i starts at −used ÷ 2 + i × (bar + gap)

The count is a floor of a 64-bit quotient with no tolerance, so a width that is an exact multiple can fall one bar short through rounding. Bar positions come from the whole-number index, not a running sum.

  • intersection.js
  • lines 225 to 236
  • CivilCraft 0.8.1
/** Crosswalk bars (continental/ladder: bars along the direction of walking) across a road of `width` centred at `centre`; `dir` is the road direction. */
export function crosswalk(centre, dir, width, { style = 'continental', bar = 0.4, gap = 0.6, length = 3 } = {}) {
  const d = unit(dir), n = { x: -d.y, y: d.x }, bars = [];
  if (style === 'ladder') for (const s of [-1, 1]) { const o = (s * (length - bar)) / 2; bars.push([{ x: centre.x - d.x * width / 2 + n.x * (o - bar / 2), y: centre.y - d.y * width / 2 + n.y * (o - bar / 2) }, { x: centre.x + d.x * width / 2 + n.x * (o - bar / 2), y: centre.y + d.y * width / 2 + n.y * (o - bar / 2) }, { x: centre.x + d.x * width / 2 + n.x * (o + bar / 2), y: centre.y + d.y * width / 2 + n.y * (o + bar / 2) }, { x: centre.x - d.x * width / 2 + n.x * (o + bar / 2), y: centre.y - d.y * width / 2 + n.y * (o + bar / 2) }]); }
  const count = Math.max(1, Math.floor((width + gap) / (bar + gap))), used = count * bar + (count - 1) * gap, start = -used / 2;
  if (style === 'ladder') return bars;
  for (let i = 0; i < count; i++) {
    const s = start + i * (bar + gap), a = { x: centre.x + d.x * s, y: centre.y + d.y * s };
    bars.push([{ x: a.x - n.x * length / 2, y: a.y - n.y * length / 2 }, { x: a.x + d.x * bar - n.x * length / 2, y: a.y + d.y * bar - n.y * length / 2 }, { x: a.x + d.x * bar + n.x * length / 2, y: a.y + d.y * bar + n.y * length / 2 }, { x: a.x + n.x * length / 2, y: a.y + n.y * length / 2 }]);
  }
  return bars;
}

Sign positions

64-bit

The plan position and facing of each sign from its station, side and offset.

position = point at station + normal × side × offset; repeating signs every `every` from `from`

The direction at a station is taken over the next 0.01 length units of the line. Repeating signs step the station by repeated addition (sta += every), with 1e-9 allowed at the end.

  • intersection.js
  • lines 270 to 287
  • CivilCraft 0.8.1
/**
 * Place signs along a road: `plan.signs` = [{ kind, station, side: 'L'|'R', offset, text }] by hand, plus the repeating ones `plan.repeat` = [{ kind, every, side, offset, from, to }].
 * Returns the schedule with the position of each sign in plan and the counts by kind.
 */
export function signsFor(line, plan) {
  const total = polyLen(line), out = [];
  const at = (sta) => { const p = sliceLine(line, 0, Math.max(0, Math.min(total, sta))), q = p[p.length - 1] || line[0], r = sliceLine(line, Math.max(0, Math.min(total, sta)), Math.min(total, sta + 0.01)), d = r.length > 1 ? unit(sub(r[r.length - 1], r[0])) : unit(sub(line[line.length - 1], line[line.length - 2])); return { p: q, d }; };
  const add = (s) => {
    const info = SIGNS[s.kind]; if (!info) return;
    const { p, d } = at(s.station), n = { x: -d.y, y: d.x }, sg = s.side === 'L' ? 1 : -1, off = s.offset ?? 2;
    out.push({ kind: s.kind, code: info.code, text: s.text || '', station: s.station, side: s.side, offset: off, size: s.size ?? info.size, x: p.x + n.x * sg * off, y: p.y + n.y * sg * off, facing: Math.atan2(-d.y * sg, -d.x * sg) });
  };
  for (const s of plan.signs || []) add(s);
  for (const r of plan.repeat || []) for (let sta = r.from ?? 0; sta <= Math.min(r.to ?? total, total) + 1e-9; sta += r.every) add({ ...r, station: sta });
  out.sort((a, b) => a.station - b.station);
  const counts = {}; for (const s of out) counts[s.kind] = (counts[s.kind] || 0) + 1;
  return { signs: out, counts };
}

Road quantities

64-bit

Totals intersection pavement area, curb return length, striping length, painted area and sign count.

Plain 64-bit sums of the values worked out above.

  • intersection.js
  • lines 289 to 292
  • CivilCraft 0.8.1
/** Quantities for a corridor with intersections: pavement areas, curb, striping, signs. */
export function roadQuantities({ intersections = [], stripes = [], signs = [] }) {
  return { pavementIntersections: intersections.reduce((s, i) => s + i.area, 0), curbReturn: intersections.reduce((s, i) => s + i.curbLength, 0), striping: stripes.reduce((s, q) => s + q.quantities.total, 0), paintedArea: stripes.reduce((s, q) => s + q.quantities.paintedArea, 0), signs: signs.reduce((s, g) => s + g.signs.length, 0) };
}

In detail

Method
Simple curb return: T = r ÷ tan(ψ ÷ 2), center r ÷ sin(ψ ÷ 2) from the corner. Three-center return walked in a local frame. Sight distance ISD = 0.278 × V × t (the file cites the AASHTO Green Book, 7th ed., ch. 9). Shoelace area. Mitred offset limited to 4 times the offset.
Number types
64-bit numbers.
Tolerances
Corner angle must be between 0.001 rad and π − 0.001 rad. Legs parallel when |cross product of unit directions| < 1e-9. Arc sampling step 0.05 rad. Segment tests 1e-12; dash and slice ends 1e-9 and 1e-12; tangent points matched to a leg edge within 1e-6. All absolute.
Limits
The pavement area treats each curb return as chords. The three-center curb length is the sum of chords, short of the true arc length by up to about 1 part in 10,000 at the 0.05 rad step. 0.278 stands for 1 ÷ 3.6 = 0.2778. Default widths, gaps and sign sizes are placeholders, as the file header states.

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