Resources Numerical accuracy Calculation policy

Values typed or picked in the main window

Takes what is typed in a property box or picked in the plan and profile views and stores it in the model: lengths, coordinates, profile points, pad elevations and default curve radii.

How the precision is kept

Typed numbers are read with Number and stored unrounded. Several picked values are rounded before they are stored: profile points to 2 decimals, a new pad's elevation to 1 decimal, a typed sum of lengths to 9 decimals, and a default radius down to a whole number. A box commits its text on Enter even when the text is the rounded display value.

Open item. Commit a box only when its text was edited, so that Enter in an untouched box does not replace the stored value with its rounded display text.

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.

civilcraft.js CivilCraft 0.8.1 64-bit 8 calculations Reviewed

Tests None found by the review

Number box

64-bit

The text of a property box turned into the value handed to the model.

typed sum of lengths → Number(value.toFixed(9))

A plain number is passed on as typed. A length expression such as 4+7/16 is evaluated and rounded to 9 decimals before it is committed. Enter dispatches a change event whether or not the text was edited, so the box's text is committed as it stands; where the box was filled with a rounded display value, that rounded value is what gets stored.

  • civilcraft.js
  • lines 724 to 749
  • CivilCraft 0.8.1
// 0.6 DF.3: every number box is a text box (a number box can't hold "4+7/16"); what is not a plain number is read as a
// length or a sum of lengths in the project's units (units.js readLength) and the box shows the result. `calc: false`
// for boxes that are not lengths (grades in %, arc radius words, point numbers and descriptions), `station: true` for
// stations ("10+00" is a station, never a sum)
function numInput(value, onCommit, { label, id, text = false, width, calc = true, station = false } = {}) {
  void text;   // (kept for the callers: every box is text now)
  const inp = el('input', { type: 'text', className: 'cv-input', value: value ?? '', 'aria-label': label, id, inputMode: 'decimal', style: width ? 'width:' + width : undefined });
  // Enter commits at once (a synthetic change); the browser's own change when the box then loses focus must not commit
  // the same text again — "Raise 2" would raise twice (0.5: found by the WL.20 browser test)
  let entered = null;
  inp.addEventListener('change', (e) => {
    if (e.isTrusted && entered !== null && inp.value === entered) { entered = null; return; }
    entered = e.isTrusted ? null : inp.value;
    let v = inp.value;
    if (calc && !station && v.trim() !== '' && !isNum(v)) {
      const r = readLength(v, units());
      if (!r.ok) { inp.setAttribute('aria-invalid', 'true'); say(r.error); return; }
      v = String(Number(r.value.toFixed(9)));
      inp.value = v;
      if (entered !== null) entered = v;
    }
    if (onCommit(v) === false) inp.setAttribute('aria-invalid', 'true'); else inp.removeAttribute('aria-invalid');
  });
  inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); inp.dispatchEvent(new Event('change')); } });
  return inp;
}

PI coordinates box

64-bit

The easting and northing of an alignment's point of intersection as shown and as stored from the box.

The boxes are filled with the coordinate at 2 decimals. A committed box stores Number(text), so pressing Enter in an untouched box replaces the stored coordinate by its 2-decimal text.

  • civilcraft.js
  • lines 870 to 874
  • CivilCraft 0.8.1
const setXY = (k) => (v) => { const x = Number(v); if (!Number.isFinite(x) || v === '') return false; project.change(t('cv.undo.pi.move.n', { n: i + 1 }), () => { findAl(a.id).pis[i][k] = x; }); };
tbody.append(el('tr', {},
  el('td', {}, String(i + 1)),
  el('td', {}, numInput(fixed(p.x, 2), setXY('x'), { label: t('cv.al.pi.e', { n: i + 1 }) })),
  el('td', {}, numInput(fixed(p.y, 2), setXY('y'), { label: t('cv.al.pi.n', { n: i + 1 }) })),

First design profile from the ground

64-bit

The two profile points a new design profile starts with, at the ends of the ground profile.

z stored = Number(z.toFixed(2))

The ground elevations at the two ends are rounded to 2 decimals before they are stored as profile points. The stations are kept as they are.

  • civilcraft.js
  • lines 1024 to 1032
  • CivilCraft 0.8.1
function startProfile(a) {
  const al = project.alignment(a);
  const egp = project.egProfile(a).filter((p) => p.z !== null);
  if (!al.ok || egp.length < 2) { say(t('cv.prof.noground')); return; }
  const z0 = egp[0], z1 = egp[egp.length - 1];
  project.change(t('cv.undo.prof.start'), () => { findAl(a.id).pvis = [{ sta: z0.sta, z: Number(z0.z.toFixed(2)), L: 0 }, { sta: z1.sta, z: Number(z1.z.toFixed(2)), L: 0 }]; });
  showProfile(true, a.id);
  say(t('cv.prof.started'));
}

Profile point added by a click

64-bit

The station and elevation of a profile point placed in the profile view.

station = Number(sta.toFixed(2)), clamped to the alignment; z = Number(z.toFixed(2))

Both values are rounded to 2 decimals before they are stored. A point within 0.01 of an existing point's station is refused.

  • civilcraft.js
  • lines 1299 to 1300
  • CivilCraft 0.8.1
const sta = Math.max(al.start, Math.min(al.end, Number(d.sta.toFixed(2)))), z = Number(d.z.toFixed(2));
if (a.pvis.some((v) => Math.abs(v.sta - sta) < 0.01)) { say(t('cv.pvi.exists', { sta: formatStation(sta, units()) })); return; }

Profile point moved by dragging

64-bit

The station and elevation of a profile point while it is dragged, as stored when released.

station and z = Number(v.toFixed(2)); station kept 0.01 inside its neighbors

Both values are rounded to 2 decimals on every move, and the last ones are stored. The station is held at least 0.01 from the neighboring points.

  • civilcraft.js
  • lines 1315 to 1316
  • CivilCraft 0.8.1
v.sta = Math.max(prev ? prev.sta + 0.01 : al.start, Math.min(next ? next.sta - 0.01 : al.end, Number(d.sta.toFixed(2))));
v.z = Number(d.z.toFixed(2));

Elevation of a newly drawn pad

64-bit

The elevation a new pad starts at.

z = Number(ground(mean of corners).toFixed(1))

The ground elevation at the mean of the corner coordinates, rounded to 1 decimal before it is stored. The mean adds each corner divided by the count, so it is the vertex average, not the area centroid. Off the surface the pad starts at 0.

  • civilcraft.js
  • lines 1571 to 1574
  • CivilCraft 0.8.1
const c = pts.reduce((m, p) => ({ x: m.x + p.x / pts.length, y: m.y + p.y / pts.length }), { x: 0, y: 0 });
const z0 = eg().surface?.elevationAt(c.x, c.y);
const id = project.nextId('p'), n = s.pads.length + 1;
project.change(t('cv.undo.pad.draw'), (st) => st.pads.push({ id, name: t('cv.pad.name', { n }), pts, z: Number(((z0 ?? 0)).toFixed(1)), cut: 2, fill: 3 }));

Default curve radius at a new PI

64-bit

The radius given to each bend of a newly drawn alignment.

r = min(R, ⌊half the shorter leg ÷ tan(Δ ÷ 2)⌋)

The radius that fits half of each tangent is rounded down to a whole number on purpose, as the comment says. No curve is given when the deflection is below 1e-6 rad or within 0.001 rad of a reversal.

  • civilcraft.js
  • lines 1644 to 1651
  • CivilCraft 0.8.1
/** The default radius, or the largest whole radius whose curve fits half of each tangent. */
function fitRadius(pts, i, R) {
  const a = pts[i - 1], b = pts[i], c = pts[i + 1];
  const d = Math.abs(normAng(Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x)));
  if (d < 1e-6 || d > Math.PI - 1e-3) return 0;
  const half = Math.min(Math.hypot(b.x - a.x, b.y - a.y), Math.hypot(c.x - b.x, c.y - b.y)) / 2;
  return Math.min(R, Math.floor(half / Math.tan(d / 2)));
}

Ground elevation at a station

64-bit

The existing ground elevation read out at the cursor's station in the profile view.

z = z_a + (z_b − z_a) × (s − s_a) ÷ (s_b − s_a)

Linear interpolation between the two sampled ground profile points round the station. The divisor is floored at 1e-12. The cut or fill shown beside it is the difference between the design elevation and this value.

  • civilcraft.js
  • lines 1342 to 1345
  • CivilCraft 0.8.1
function interpEg(eg, sta) {
  for (let i = 1; i < eg.length; i++) if (sta <= eg[i].sta) { const a = eg[i - 1], b = eg[i]; if (a.z === null || b.z === null) return null; return a.z + (b.z - a.z) * (sta - a.sta) / Math.max(b.sta - a.sta, 1e-12); }
  return null;
}

In detail

Method
Property boxes are text boxes. A plain number is passed on as typed; anything else is read as a length or a sum of lengths in project units and its result is put back into the box rounded to 9 decimals. Picks in the profile view are converted from screen position to station and elevation and rounded to 2 decimals.
Number types
64-bit numbers; rounding by toFixed as listed.
Tolerances
A new profile point is refused within 0.01 of an existing one, and a dragged one is kept 0.01 from its neighbors. Default radius: none when the deflection is below 1e-6 rad or within 0.001 rad of π.
Limits
Rounding to 2 decimals means 0.005 of a project unit in station and elevation for picked profile points. Boxes for PI coordinates show 2 decimals and vertex elevations 3; committing such a box unedited stores the shown text.

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