Resources Numerical accuracy Calculation policy

GIS feature geometry

Works out ring areas and winding, tests whether a point is inside a ring, sorts rings into polygons and holes, and moves layers between coordinate systems.

How the precision is kept

Coordinates are 64-bit and are read from and written to WKB as 64-bit numbers. Areas use the shoelace sum on the coordinates as they are, without first moving them to a local origin, so the digits of an area depend on how large the coordinates are.

Open item. Subtract the ring's first vertex from every coordinate before the shoelace sum.

Calculations under this policy

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

gis-geom.js CivilCraft 0.8.1 64-bit 5 calculations Reviewed

Tests bin/test-civilcraft-gisvec.mjs

Signed area of a ring

64-bit

The area of a ring, positive when it runs counter-clockwise.

area = Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ) ÷ 2

Each term multiplies two full-size coordinates, and the area is what is left after the terms nearly cancel. With coordinates near 2,000,000 and 7,000,000 a product is about 1.4e13, where 64-bit numbers are 0.002 apart, so each term carries that rounding into the sum. Coordinates are not reduced to a local origin first.

  • gis-geom.js
  • lines 26 to 30
  • CivilCraft 0.8.1
export function ringArea(r) {
  let a = 0;
  for (let i = 0, n = r.length; i < n; i++) { const p = r[i], q = r[(i + 1) % n]; a += p[0] * q[1] - q[0] * p[1]; }
  return a / 2;
}

Point inside a ring

64-bit

Whether a point lies inside a ring.

Even-odd rule: each edge that straddles the point's y is intersected with the horizontal through it by one division. The comparison is plain 64-bit, so a point on or within rounding of an edge can fall either way.

  • gis-geom.js
  • lines 31 to 38
  • CivilCraft 0.8.1
export function pointInRing(x, y, r) {
  let inside = false;
  for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
    const a = r[i], b = r[j];
    if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside;
  }
  return inside;
}

Rings sorted into polygons and holes

64-bit

Which rings are outlines and which are holes, and which outline each hole belongs to.

The decision is the sign of the shoelace area and a point-in-ring test of the hole's first vertex only. A ring whose area rounds to the wrong sign, or a hole whose first vertex lies on the outline, can be assigned wrongly.

  • gis-geom.js
  • lines 44 to 55
  • CivilCraft 0.8.1
export function polygonsFromRings(rings) {
  const outers = [], holes = [];
  for (const r of rings) { if (r.length < 4) continue; (ringArea(r) < 0 ? outers : holes).push(r); }
  if (!outers.length) return holes.map((h) => [h.slice().reverse()]);   // every ring counter-clockwise: each its own polygon
  const polys = outers.map((o) => ({ o, a: Math.abs(ringArea(o)), holes: [] }));
  for (const h of holes) {
    let best = null;
    for (const p of polys) if (p.a >= Math.abs(ringArea(h)) && pointInRing(h[0][0], h[0][1], p.o) && (!best || p.a < best.a)) best = p;
    if (best) best.holes.push(h); else polys.push({ o: h.slice().reverse(), a: Math.abs(ringArea(h)), holes: [] });
  }
  return polys.map((p) => [p.o, ...p.holes]);
}

Coordinates through a conversion

64-bit

Every vertex of a geometry passed through a function of x and y, keeping z.

Each vertex is replaced by the function's result with no rounding; z is copied unchanged.

  • gis-geom.js
  • lines 71 to 77
  • CivilCraft 0.8.1
export function mapXY(g, fn) {
  if (!g) return g;
  const m = (p) => { const q = fn(p[0], p[1]); return p.length > 2 ? [q[0], q[1], p[2]] : [q[0], q[1]]; };
  if (g.t === 'pt') return { t: 'pt', c: m(g.c) };
  if (g.t === 'ln') return { t: 'ln', c: g.c.map((part) => part.map(m)) };
  return { t: 'pg', c: g.c.map((poly) => poly.map((r) => r.map(m))) };
}

Elevations through a conversion

64-bit

The z of every vertex passed through a function, for unit conversion.

One function call per vertex that has a z; vertices without z stay without.

  • gis-geom.js
  • lines 78 to 85
  • CivilCraft 0.8.1
/** z of every vertex through fn (unit conversion); points without z stay without. */
export function mapZ(g, fn) {
  if (!g) return g;
  const m = (p) => (p.length > 2 ? [p[0], p[1], fn(p[2])] : p);
  if (g.t === 'pt') return { t: 'pt', c: m(g.c) };
  if (g.t === 'ln') return { t: 'ln', c: g.c.map((part) => part.map(m)) };
  return { t: 'pg', c: g.c.map((poly) => poly.map((r) => r.map(m))) };
}
gis-layers.js CivilCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-civilcraft-gisvec.mjs, bin/test-civilcraft-labels.mjs, bin/test-civilcraft-gistable.mjs

Layers moved to a new coordinate system

64-bit

Every GIS layer's coordinates converted when the project's system changes.

project → grid of the old system → grid of the new system → project

Each vertex goes through the old system's transform to the new system and then the new system's grid-to-project step, with no rounding in this file. The accuracy is that of the transforms in crs.js.

  • gis-layers.js
  • lines 84 to 89
  • CivilCraft 0.8.1
/** The project's system changed from `before` to `after` (state.crs shapes): move every layer from where it belongs under
 *  `before` to where it belongs under `after` (project → grid of before → after's grid → after's project). One step. */
export function moveLayers(project, before, after) {
  const fb = frame(before), fa = frame(after), tf = fb.to(after);
  return transformLayers(project, (x, y) => { const g = tf(x, y); return fa.fromGrid(g[0], g[1]); }, t('layers.undo.moveto', { crs: after.name }));
}

Attribute value as a label

64-bit

The text drawn on the plan for a feature's attribute value.

number shown = +v.toFixed(3)

A number is rounded to 3 decimals for the label and trailing zeros are dropped. The attribute itself is not changed.

  • gis-layers.js
  • lines 190 to 195
  • CivilCraft 0.8.1
export function labelText(v) {
  if (v === null || v === undefined || v === '') return '';
  if (typeof v === 'number') return Number.isFinite(v) ? String(+v.toFixed(3)) : '';
  if (typeof v === 'boolean') return v ? t('gis.label.yes') : t('gis.label.no');
  return String(v).replace(/\s+/g, ' ').trim().slice(0, 80);
}

Label point of a polygon

64-bit

The centroid of a polygon's largest ring, where its label is placed.

cx = Σ (xᵢ + xᵢ₊₁)·cᵢ ÷ (3·Σ cᵢ), cᵢ = xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ

The same shoelace terms as the area, on full-size coordinates, so the centroid of a small polygon far from the origin is known less well than its vertices. If the result falls outside the ring, or the area is below 1e-12, the first vertex is used instead. This positions a label only.

  • gis-layers.js
  • lines 231 to 234
  • CivilCraft 0.8.1
let cx = 0, cy = 0, A2 = 0;
for (let k = 0; k + 1 < ring.length; k++) { const c = ring[k][0] * ring[k + 1][1] - ring[k + 1][0] * ring[k][1]; A2 += c; cx += (ring[k][0] + ring[k + 1][0]) * c; cy += (ring[k][1] + ring[k + 1][1]) * c; }
if (Math.abs(A2) > 1e-12) { cx /= 3 * A2; cy /= 3 * A2; } else { cx = ring[0][0]; cy = ring[0][1]; }
if (!pointInRing(cx, cy, ring)) { cx = ring[0][0]; cy = ring[0][1]; }

In detail

Method
Signed area by the shoelace formula. Point in ring by the even-odd crossing rule. Shapefile rule for rings: clockwise is an outer ring, counter-clockwise a hole, and each hole goes to the smallest outer ring that holds its first vertex.
Number types
64-bit numbers; WKB coordinates through DataView.getFloat64 and Float64Array.
Tolerances
Label centroid: twice-area below 1e-12 (absolute) falls back to the first vertex.
Limits
With coordinates near 2,000,000 and 7,000,000 each shoelace product is about 1.4e13 and is rounded at about 0.002 square units, so a small polygon's area keeps far fewer than 15 digits. The winding decision uses the sign of that area and the inside test is not an exact predicate.

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.

Every policy that takes this approach