Resources Numerical accuracy Calculation policy

Feature line arcs as straight pieces

Cuts the arcs of a feature line into straight pieces for surfaces, grading, linework and DWG or DXF, and follows a line along an alignment for profile views.

How the precision is kept

Everything that needs straight lines takes the arc in pieces whose sagitta is at most 0.01 project units, with at least one piece per 5° and at most 720 pieces. The vertices are on the arc to 64-bit rounding; the chords are within that tolerance. Crossings with alignment transitions are found against chords of the transition.

Calculations under this policy

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

featureline.js CivilCraft 0.8.1 64-bit 7 calculations Reviewed

Tests bin/test-civilcraft-arcs.mjs, bin/test-civilcraft-features.mjs, bin/test-civilcraft-flprofile.mjs

Number of pieces for an arc

64-bit

How many straight pieces draw an arc within the tolerance.

step = min(2·acos(1 − tol ÷ r), 5°); pieces = ⌈|sweep| ÷ step⌉, from 1 to 720

For a radius much larger than the tolerance, 1 − tol ÷ r is close to 1 and the acos of it loses digits; the result is only used to pick a whole number of pieces. 1e-9 is subtracted before rounding up so that an exact multiple does not gain a piece.

  • featureline.js
  • lines 128 to 134
  • CivilCraft 0.8.1
/** How many straight pieces draw an arc within `tol` (the sagitta), at least one per `maxDeg` degrees, at most 720. */
export function arcPieces(A, tol = 0.01, maxDeg = 5) {
  if (!A) return 1;
  const byTol = A.r > tol ? 2 * Math.acos(1 - tol / A.r) : Math.PI;
  const step = Math.min(byTol, (maxDeg * Math.PI) / 180);
  return Math.max(1, Math.min(720, Math.ceil(Math.abs(A.sweep) / step - 1e-9)));
}

Line as straight pieces

64-bit

The feature line with every arc replaced by vertices on the arc.

p = c + r·(cos θ, sin θ), θ = a₀ + sweep × s ÷ k; z = z_a + (z_b − z_a) × s ÷ k

Inner vertices are computed from the arc's center; the arc's end vertices are the stored ones, copied. Angles and elevations are start + range × s ÷ k, not running sums.

  • featureline.js
  • lines 136 to 154
  • CivilCraft 0.8.1
/** The line as straight pieces: [{x, y, z, v}] (v = the vertex index at a real vertex, else −1); arcs in pieces within
 *  `tol`, z along each arc by its length (a grade is constant along a segment). A closed line ends back at its start. */
export function flatten(f, { tol = 0.01, maxDeg = 5 } = {}) {
  const P = f.pts, n = P.length, m = f.closed ? n : n - 1, out = [];
  if (!n) return out;
  out.push({ x: P[0].x, y: P[0].y, z: P[0].z, v: 0 });
  for (let i = 0; i < m; i++) {
    const a = P[i], j = (i + 1) % n, b = P[j], A = arcOf(a, b, a.b);
    if (A) {
      const k = arcPieces(A, tol, maxDeg);
      for (let s = 1; s < k; s++) {
        const th = A.a0 + (A.sweep * s) / k;
        out.push({ x: A.cx + A.r * Math.cos(th), y: A.cy + A.r * Math.sin(th), z: a.z + ((b.z - a.z) * s) / k, v: -1 });
      }
    }
    out.push({ x: b.x, y: b.y, z: b.z, v: j });
  }
  return out;
}

Closed design line as a grading item

64-bit

The outline and the single elevation a closed design feature line passes to grading.

z = Σ vertex z ÷ number of vertices

The elevation is the plain mean of the vertex elevations. For a line with arcs the vertices are the flattened pieces, so an arc counts once per piece and weighs more than a straight side in that mean.

  • featureline.js
  • lines 273 to 284
  • CivilCraft 0.8.1
/** A closed design feature line as a grading item (grading.js gradedSurface): with daylight a sloped pad, without it the
 *  outline itself is the region and its edge the breakline. Open lines and other uses → null. */
export function featureItem(f) {
  if (f.use !== 'design' || !f.closed || f.pts.length < (hasArcs(f) ? 2 : 3)) return null;
  const pts = (hasArcs(f) ? flatten(f).slice(0, -1) : f.pts).map((p) => ({ x: p.x, y: p.y, z: p.z }));
  const z = pts.reduce((s, p) => s + p.z, 0) / pts.length;
  if (f.daylight) return { id: f.id, kind: 'feature', name: f.name, pts, z, cut: f.cut, fill: f.fill };
  return {
    id: f.id, kind: 'feature', name: f.name,
    patch: () => ({ problems: [], region: pts, points: [], breaklines: [{ name: f.name, pts: [...pts, pts[0]] }], info: { poly: pts } }),
  };
}

Feature line written to DWG or DXF

64-bit

The 3D polyline and the extended data that records which runs of pieces were arcs.

Vertices are the flattened pieces at full 64-bit precision; the bulge of each arc is stored as a number in the extended data. Another program sees the chords only.

  • featureline.js
  • lines 294 to 309
  • CivilCraft 0.8.1
/** → { pts: [{x,y,z}], closed, xdata, arcsLeft } (a closed line's last piece ends at the first vertex, not repeated). */
export function cadPolyline(f) {
  const flat = flatten(f), pts = flat.map(({ x, y, z }) => ({ x, y, z }));
  if (f.closed && pts.length > 1) pts.pop();
  const items = [[1000, CAD_TAG], [1000, String(f.name || '').slice(0, 120)], [1000, (USES.includes(f.use) ? f.use : 'eg') + '|' + (f.closed ? 1 : 0)]];
  let arcs = 0, left = 0;
  flat.forEach((p, k) => {
    if (p.v < 0 || !okBulge(f.pts[p.v].b) || k === flat.length - 1) return;
    let m = 0;
    while (flat[k + 1 + m] && flat[k + 1 + m].v < 0) m++;
    if (arcs >= CAD_MAX_ARCS) { left++; return; }
    items.push([1040, k], [1040, m], [1040, f.pts[p.v].b]);
    arcs++;
  });
  return { pts, closed: !!f.closed, xdata: { CADCRAFT: items }, arcsLeft: left };
}

Feature line read back from DWG or DXF

64-bit

The feature line rebuilt from a polyline and its extended data.

Where the extended data names an arc, its inner piece vertices are skipped and the stored bulge is put back on the first vertex, so the arc is the original and not a fit through the pieces. The end vertices are whatever the file holds.

  • featureline.js
  • lines 310 to 330
  • CivilCraft 0.8.1
/** A 3D polyline's vertices + extended data → a feature line (untrusted: checked as a project file is), or null when the
 *  polyline was not written by CivilCraft as a feature line. Vertices: [{x, y, z}] in project units. */
export function featureFromCad(vertices, closed, xdata, units = 'ft') {
  const it = xdata?.CADCRAFT;
  if (!Array.isArray(it) || it[0]?.[0] !== 1000 || it[0][1] !== CAD_TAG || !Array.isArray(vertices) || vertices.length < 2) return null;
  const name = it[1]?.[0] === 1000 ? String(it[1][1]) : '';
  const [use, cl] = it[2]?.[0] === 1000 ? String(it[2][1]).split('|') : [];
  const arcs = new Map();
  for (let q = 3; q + 2 < it.length; q += 3) {
    const [k, m, b] = [it[q], it[q + 1], it[q + 2]].map((x) => (x?.[0] === 1040 ? Number(x[1]) : NaN));
    if (Number.isInteger(k) && Number.isInteger(m) && k >= 0 && m >= 0 && m < 10000 && okBulge(b)) arcs.set(k, { m, b });
  }
  const pts = [];
  for (let k = 0; k < vertices.length; k++) {
    const v = vertices[k], p = { x: v.x, y: v.y, z: v.z };
    const a = arcs.get(k);
    if (a && k + a.m + 1 <= vertices.length) { p.b = a.b; pts.push(p); k += a.m; continue; }   // skip the arc's inner pieces
    pts.push(p);
  }
  return cleanFeature({ name, pts, closed: cl === '1' || (cl === undefined && !!closed), use }, units);
}

Crossings with an alignment

64-bit

Every place a feature line crosses the centerline, with its station and elevation.

z = z_a + t × Δz

Lines and circular arcs of the alignment are crossed exactly; transitions are crossed as chords of at most 0.25° of turn, so a crossing on a transition is on the chord. Two crossings within 1e-6 in both station and elevation are merged into one.

  • featureline.js
  • lines 402 to 414
  • CivilCraft 0.8.1
/** Every crossing of a feature line with an alignment's centerline (see the header). */
export function alignmentCrossings(al, f) {
  if (!al?.elements?.length || !f?.pts?.length) return [];
  const out = [];
  for (const sg of segments(f)) {
    if (!(sg.len > 1e-12)) continue;
    const a = f.pts[sg.i], b = f.pts[sg.j];
    for (const e of al.pieces()) for (const c of sg.arc ? crossArcElement(e, sg.arc) : crossElement(e, a, b)) out.push({ sta: c.sta, z: a.z + c.t * sg.dz, i: sg.i, grade: sg.grade });
  }
  out.sort((p, q) => p.sta - q.sta);
  // a crossing exactly at a shared vertex / element joint is found twice
  return out.filter((c, k) => !k || Math.abs(c.sta - out[k - 1].sta) > 1e-6 || Math.abs(c.z - out[k - 1].z) > 1e-6);
}

Stretches running beside an alignment

64-bit

The parts of a feature line within a given distance of the centerline, as station and elevation for a profile view.

Each vertex is projected to its nearest station and offset. A line with arcs is followed through its flattened pieces. Between vertices the profile joins the projected points with straight lines, so the run is known at the vertices only.

  • featureline.js
  • lines 416 to 438
  • CivilCraft 0.8.1
/** The line's stretches within `within` of the centerline, as profile polylines by station (see the header). */
export function projectedRuns(al, f, within) {
  if (!(within > 0) || !al?.elements?.length || !f?.pts?.length) return [];
  // 0.6 WL.24: a line with arcs is followed through its pieces (within 0.01 of the curve)
  if (hasArcs(f)) return projectedRuns(al, { ...f, pts: flatten(f).map(({ x, y, z }) => ({ x, y, z })), closed: false }, within);
  const n = f.pts.length, idx = [...Array(n).keys()];
  if (f.closed) idx.push(0);
  const cross = alignmentCrossings(al, f);
  const runs = [];
  let run = [];
  // a stretch with no length along the alignment (a line straight across it) is only its crossing: no run
  const flush = () => { if (run.length >= 2 && Math.max(...run.map((q) => q.sta)) - Math.min(...run.map((q) => q.sta)) > 1e-6) runs.push(run); run = []; };
  for (let k = 0; k < idx.length; k++) {
    const p = f.pts[idx[k]], so = al.stationOffset(p.x, p.y);
    const inside = so && !so.outside && Math.abs(so.off) <= within + 1e-9;
    if (!inside) { flush(); continue; }
    if (k > 0 && run.length) for (const c of cross) if (c.i === idx[k - 1]) run.push({ sta: c.sta, z: c.z, off: 0 });
    run.push({ sta: so.sta, z: p.z, off: so.off });
  }
  flush();
  // a run is read along the alignment: keep its points in station order when the line runs backwards
  return runs.map((r) => (r[r.length - 1].sta < r[0].sta ? r.reverse() : r));
}

In detail

Method
Pieces per arc from the sagitta: step = 2·acos(1 − tol ÷ r), capped at 5°. Elevation along an arc changes evenly with length. In a drawing the pieces are written with extended data that records each arc's bulge, so CivilCraft restores the arc itself.
Number types
64-bit numbers.
Tolerances
Sagitta 0.01 project units, the same number in a feet or a meter project. At least one piece per 5°, at most 720 pieces per arc. A run within the band needs a station range above 1e-6. Band width + 1e-9.
Limits
With the 720-piece cap an arc of very large radius and long sweep can exceed the 0.01 sagitta. Extended data holds at most 500 arcs per line; arcs past that stay as pieces when read back.

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