Resources Numerical accuracy Calculation policy

Project objects exported as GIS layers

Turns survey points, contours, alignments, pads, corridor lines, pipes and structures into GIS features with their attribute values, in grid coordinates.

How the precision is kept

Attribute values are passed on as 64-bit numbers without rounding in this file. Curved alignments are exported as chords of at most 0.5° of turn, and a pad's area is a shoelace sum on project coordinates, so those two values carry the limits of their method.

Calculations under this policy

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

gis-export.js CivilCraft 0.8.1 64-bit 6 calculations Reviewed

Tests bin/test-civilcraft-gisvec.mjs, bin/test-civilcraft-kml.mjs

Pad elevation and area

64-bit

The ELEV and AREA attributes of an exported pad.

AREA = |shoelace area of the pad's corners|

The area is the shoelace sum on the project coordinates as they are (ringArea in gis-geom.js), so with coordinates in the millions it keeps fewer digits than the corners do. The elevation is the pad's stored value passed through Number.

  • gis-export.js
  • lines 101 to 102
  • CivilCraft 0.8.1
case 'pads': return { hasZ: true, fields: [F('NAME', 'text'), F('ELEV', 'number'), F('AREA', 'number')],
  features: s.pads.filter((d) => d.pts.length >= 3).map((d) => feat({ t: 'pg', c: [[close(d.pts.map((p) => [p.x, p.y, Number(d.z)]))]] }, { NAME: d.name, ELEV: Number(d.z), AREA: Math.abs(ringArea(d.pts.map(P2))) })) };

Alignment line and length

64-bit

An alignment's exported linework and its LENGTH, STA_START and STA_END attributes.

chords of at most π ÷ 360 (0.5°) of turn

The LENGTH attribute is the alignment's true length; the exported line is a chain of chords, so the line's own measured length is slightly shorter on curves. Vertices are on the curve.

  • gis-export.js
  • lines 99 to 100
  • CivilCraft 0.8.1
case 'alignments': return { hasZ: false, fields: [F('NAME', 'text'), F('LENGTH', 'number'), F('STA_START', 'number'), F('STA_END', 'number')],
  features: s.alignments.map((a) => { const al = project.alignment(a); return al.ok ? feat({ t: 'ln', c: [al.polyline(Infinity, Math.PI / 360).map(P2)] }, { NAME: a.name, LENGTH: al.length, STA_START: al.start, STA_END: al.end }) : null; }).filter(Boolean) };

Pipe attributes

64-bit

A pipe's exported inverts, slope in percent and length.

SLOPE = slope × 100; LENGTH = checked length, else √(Δx² + Δy²)

Values are passed on unrounded. A missing invert is written as an empty attribute, but the line's vertex takes z = 0 in its place, so the geometry and the attribute disagree for such a pipe.

  • gis-export.js
  • lines 124 to 125
  • CivilCraft 0.8.1
const fin = (v) => (Number.isFinite(v) ? v : null);
return feat({ t: 'ln', c: [[[a.x, a.y, fin(p.invUp) ?? 0], [b.x, b.y, fin(p.invDn) ?? 0]]] }, { NAME: p.name, FROM: a.name, TO: b.name, DIA: p.dia, DIA_UNIT: du, INV_UP: fin(p.invUp), INV_DN: fin(p.invDn), SLOPE: info && Number.isFinite(info.slope) ? info.slope * 100 : null, LENGTH: info ? info.len : Math.hypot(b.x - a.x, b.y - a.y), NETWORK: n.name + ' (' + n.type + ')' }); }).filter(Boolean) };

Surface edge as polygons with holes

64-bit

The surface's outline loops arranged as outer rings and holes for the boundary export.

Loops are sorted by absolute shoelace area and nested by testing each loop's first vertex against the larger ones. The same limits as the ring area and the inside test apply.

  • gis-export.js
  • lines 64 to 75
  • CivilCraft 0.8.1
/** Loops (each [{x,y}]) nested by containment: even depth = outer, odd = hole of the loop around it. */
function nest(loops) {
  const rings = loops.map((l) => close(l.map(P2))).filter((r) => r.length >= 4).map((r) => ({ r, a: Math.abs(ringArea(r)) })).sort((a, b) => b.a - a.a);
  const polys = [];
  for (let i = 0; i < rings.length; i++) {
    let parent = -1;
    for (let j = i - 1; j >= 0; j--) if (pointInRing(rings[i].r[0][0], rings[i].r[0][1], rings[j].r)) { parent = j; break; }
    rings[i].depth = parent < 0 ? 0 : rings[parent].depth + 1;
    if (rings[i].depth % 2 === 0) { rings[i].poly = [rings[i].r]; polys.push(rings[i].poly); } else rings[parent].poly.push(rings[i].r);
  }
  return polys;
}

Features in grid coordinates

64-bit

Every exported feature's coordinates passed through the project's grid transform, with the N and E attributes of survey points set to match.

For survey points the N and E attributes are taken from the transformed coordinates, so attribute and geometry are the same numbers. Lengths, areas and elevations in attributes stay in project units and are not rescaled by the grid transform.

  • gis-export.js
  • lines 153 to 168
  • CivilCraft 0.8.1
export function exportGroups(project, keys, { toGrid = (x, y) => [x, y] } = {}) {
  const kinds = exportKinds(project), used = new Set(), out = [];
  for (const key of keys) {
    const k = kinds.find((x) => x.key === key), b = build(project, key);
    if (!k || !b || !b.features.length) continue;
    const features = b.features.map((f) => {
      const g = mapXY(f.g, toGrid);
      if (!b.coordFields) return { g, p: f.p };
      const p = Object.create(null); Object.assign(p, f.p);
      if (g) { p.E = g.c[0]; p.N = g.c[1]; }
      return { g, p };
    });
    out.push({ key, label: k.label, kind: key.startsWith('gis:') ? safeName(k.file, new Set()) : k.file, file: safeName(k.file, used), geom: k.geom, hasZ: b.hasZ && features.some((f) => f.g && (f.g.t === 'pt' ? f.g.c.length > 2 : true)), fields: b.fields, features });
  }
  return out;
}

Elevations to meters for GeoJSON and KML

64-bit

The factor applied to elevations when the export is in longitude and latitude.

z in meters = z × meters per project unit

One multiplication by the factor from crs.js for the project system's unit (1, 0.3048 or 1200/3937). A unit not in that table is treated as 1.

  • gis-export.js
  • lines 209 to 227
  • CivilCraft 0.8.1
/** GIS › Export in one call (the page and the test both use it): the chosen kinds in grid coordinates, written as
 *  `format`; GeoJSON needs a project system (refused with the reason otherwise); without one, shapefiles and
 *  GeoPackages carry the project's own numbers and no system (a note says so). */
export async function exportProject(project, keys, format, { stem = 'site' } = {}) {
  const crs = project.state.crs;
  if (format === 'geojson' && !crs) throw new Error(t('gexp.geojson.need'));
  if ((format === 'kml' || format === 'kmz') && !crs) throw new Error(t('kml.need.crs'));   // 0.4 V6
  const f = crs ? frame(crs) : null;
  const groups = exportGroups(project, keys, f ? { toGrid: f.toGrid } : {});
  const prj = crs ? await prjFor(crs) : null;
  const toLonLat = crs ? transformer(crs, null) : null;
  const k = crs ? CRS_METRES[crs.unit] || 1 : 1;
  const r = await writeExport(groups, { format, prj, toLonLat, zToMetres: (z) => z * k, stem, srs: srsFor(crs, prj) });
  if (!crs) r.notes.unshift(t('gexp.nocrs'));
  else if (!['geojson', 'kml', 'kmz'].includes(format) && crs.code == null && !prj) r.notes.unshift(t('gexp.nocode'));
  if (crs?.local && !['geojson', 'kml', 'kmz'].includes(format)) r.notes.push(t('gexp.grid', { crs: crs.name }));
  r.groups = groups;
  return r;
}

In detail

Method
Each kind of object is built as features, every x and y goes through the project's grid transform, and the chosen writer (shapefile, GeoJSON, GeoPackage, KML) stores them. Elevations for GeoJSON and KML are multiplied by meters per project unit.
Number types
64-bit numbers.
Limits
The shapefile, GeoJSON, GeoPackage and KML writers are other files and may round on their own; they were not part of this review. A pipe with no invert is given z = 0 in its geometry while its attribute is empty.

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