Resources Numerical accuracy Calculation policy

3D site export (GLB, OBJ, USD) and the 3D pointer readout

Writes the 3D site as a GLB, OBJ or USD file, and shows the easting, northing and elevation under the pointer in the 3D view.

How the precision is kept

The exporters and the pointer readout all start from the 3D view's scene, whose positions are Float32Array values relative to a local origin with the elevation multiplied by the vertical exaggeration. The exported coordinates are those 32-bit values with the exaggeration divided back out and converted to meters, stored again in Float32Array. The origin is written to the file in 64-bit. The readout adds the 64-bit origin to a hit point computed from the 32-bit mesh.

Open item. Build the exported positions and the pointer readout from the 64-bit model geometry relative to the origin, and keep the 32-bit arrays for drawing only; write USD points as doubles.

Calculations under this policy

7 calculations in 4 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.

site3d.js CivilCraft 0.8.1 32-bit in part 2 calculations Reviewed

Tests bin/test-civilcraft-3d.mjs

Local origin of the 3D scene

64-bit

The project point every 3D position is measured from.

x, y = the ground surface's own origin; z = floor(lowest elevation)

64-bit. Subtracting this origin before the 32-bit store is what keeps large eastings and northings from losing digits; the elevation origin is rounded down to a whole number. Without a ground surface the origin is the floor of the first point found.

  • site3d.js
  • lines 524 to 532
  • CivilCraft 0.8.1
/** x, y: the existing ground's local origin (else the first thing with coordinates); z: its lowest elevation, rounded down. */
export function siteOrigin(input) {
  const s = input.eg || input.fg;
  if (s) { const st = s.stats(); return { x: s.ox, y: s.oy, z: Math.floor(Math.min(st.minZ, input.fg && input.eg ? input.fg.stats().minZ : Infinity)) }; }
  const first = input.points?.find((p) => Number.isFinite(p.x)) || input.network?.structures?.[0] || input.alignments?.[0]?.alignment?.pis?.[0];
  let z = Infinity;
  for (const p of input.points || []) if (Number.isFinite(p.z) && p.z < z) z = p.z;
  return { x: first ? Math.floor(first.x) : 0, y: first ? Math.floor(first.y) : 0, z: Number.isFinite(z) ? Math.floor(z) : 0 };
}

Point under the pointer in 3D

32-bit in part

The object under the pointer and the project coordinates of the hit, shown in the 3D view's tooltip.

X = x + origin x; Y = y + origin y; Z = z ÷ exaggeration + origin z

The ray is tested against triangles and points read from the scene's Float32Array positions, so the hit point carries 32-bit precision relative to the local origin before the 64-bit origin is added. The tooltip shows that point's easting, northing and elevation to 2 decimals.

  • site3d.js
  • lines 739 to 759
  • CivilCraft 0.8.1
export function pickSite(scene, cam, sx, sy, { tol = 8 } = {}) {
  if (!scene) return null;
  const { o, d } = cam.ray(sx, sy);
  const toProject = (p) => ({ x: p[0] + scene.origin.x, y: p[1] + scene.origin.y, z: p[2] / scene.exag + scene.origin.z });
  // pipes and structures are mostly buried: they are found through the ground, so they are tested first and win
  let best = null;
  // 0.7.1 71.19: a road or hardscape (onGround) lies on the ground: it is tested before the ground and wins a tie with it, but
  // is not found through a hill in front of it the way buried pipes are
  const rank = (m) => (m.owners ? (m.onGround ? 1 : 2) : 0);
  const meshes = [...scene.meshes].sort((a, b) => rank(b) - rank(a));
  for (const m of meshes) {
    if (best && rank(best.m) === 2 && rank(m) < 2) break;
    const P = m.positions, I = m.indices;
    if (best && rank(best.m) === 1 && m.key === 'eg' && scene.meshes.some((q) => q.key === 'fg')) continue;   // the see-through ground over it
    const tie = best && rank(best.m) === 1 && !m.owners ? 1 - 1e-6 : 1;
    for (let t = 0; t < I.length; t += 3) {
      const a = [P[3 * I[t]], P[3 * I[t] + 1], P[3 * I[t] + 2]], b = [P[3 * I[t + 1]], P[3 * I[t + 1] + 1], P[3 * I[t + 1] + 2]], c = [P[3 * I[t + 2]], P[3 * I[t + 2] + 1], P[3 * I[t + 2] + 2]];
      const hit = rayTri(o, d, a, b, c);
      if (hit !== null && (!best || hit < best.t * tie)) best = { t: hit, m, tri: t / 3 };
    }
  }
site-view3d.js CivilCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests bin/test-civilcraft-3d.mjs

Tooltip coordinates in the 3D view

32-bit in part

The name of the object under the pointer with its easting, northing and elevation.

E, N, Z to 2 decimals

The three numbers come from the 3D pick, which works on the 32-bit scene positions, and are rounded to 2 decimals for the text. They are not read from the 64-bit surface.

  • site-view3d.js
  • lines 651 to 659
  • CivilCraft 0.8.1
function showTip(hit, p) {
  if (!hit) { tip.hidden = true; return; }
  const u = project().state.units === 'm' ? 'm' : 'ft';
  tip.replaceChildren(el('strong', {}, hit.name), el('span', {}, t('v3d.tip.at', { e: fixed(hit.at.x, 2), n: fixed(hit.at.y, 2), z: fixed(hit.at.z, 2), u })));
  tip.hidden = false;
  const w = host.clientWidth;
  tip.style.left = Math.min(Math.max(8, p.x + 14), Math.max(8, w - 260)) / 16 + 'rem';
  tip.style.top = (p.y + bar.offsetHeight + 14) / 16 + 'rem';
}
site3d-export.js CivilCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests bin/test-civilcraft-3d.mjs

Scene positions to meters, Y up

32-bit in part

Turns the scene's local positions into the coordinates written to GLB and OBJ files.

x′ = x·k; y′ = (z ÷ exaggeration)·k; z′ = −y·k; k = 1200 ÷ 3937 (US survey feet) or 1

Input and output are both Float32Array: each value is read from the 32-bit scene, scaled in 64-bit and stored back in 32-bit. The elevation was multiplied by the exaggeration before its first 32-bit store and is divided by it here, so it is rounded twice. The meters-per-foot factor is the exact fraction.

  • site3d-export.js
  • lines 21 to 35
  • CivilCraft 0.8.1
/** Positions → meters, Y up, exaggeration undone. Normals turned the same way (and re-normalized). */
function convert(scene, P, N) {
  const k = METERS_PER[scene.units] || 1, e = scene.exag || 1;
  const out = new Float32Array(P.length);
  for (let i = 0; i < P.length; i += 3) { out[i] = P[i] * k; out[i + 1] = (P[i + 2] / e) * k; out[i + 2] = -P[i + 1] * k; }
  let nOut = null;
  if (N) {
    nOut = new Float32Array(N.length);
    for (let i = 0; i < N.length; i += 3) {
      const x = N[i], y = N[i + 1], z = N[i + 2] * e, l = Math.hypot(x, y, z) || 1;
      nOut[i] = x / l; nOut[i + 1] = z / l; nOut[i + 2] = -y / l;
    }
  }
  return { P: out, N: nOut };
}

GLB position bounds

32-bit in part

The smallest and largest coordinate of each mesh, which a GLB file must state.

min, max over the vertices, each passed through Math.fround

The bounds are taken over the 32-bit positions and rounded to 32-bit with Math.fround so they match the stored values exactly, as the glTF format expects for 32-bit float accessors.

  • site3d-export.js
  • lines 55 to 65
  • CivilCraft 0.8.1
const acc = (arr, type, componentType, target, minmax) => {
  const n = type === 'VEC3' ? 3 : 1;
  const a = { bufferView: view(arr, target), componentType, count: arr.length / n, type };
  if (minmax) {
    const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
    for (let i = 0; i < arr.length; i += 3) for (let k = 0; k < 3; k++) { if (arr[i + k] < min[k]) min[k] = arr[i + k]; if (arr[i + k] > max[k]) max[k] = arr[i + k]; }
    a.min = min.map(Math.fround); a.max = max.map(Math.fround);
  }
  accessors.push(a);
  return accessors.length - 1;
};

OBJ vertex text

32-bit in part

Writes the vertices, normals, faces, lines and points of the site as OBJ text.

v x y z with 4 decimals (meters); vn with 5 decimals

Vertex coordinates are the 32-bit converted positions written with toFixed(4), so the file holds 0.1 mm steps of a value that already passed through 32-bit. The origin is written in a comment line at full 64-bit precision.

  • site3d-export.js
  • lines 123 to 159
  • CivilCraft 0.8.1
export function exportOBJ(scene, { name = 'Site' } = {}) {
  const o = scene.origin;
  const f = (v) => { const s = v.toFixed(4); return s === '-0.0000' ? '0.0000' : s; };
  const lines = [
    '# CivilCraft site — ' + String(name).replace(/[\r\n]+/g, ' '),
    // i18n-ignore: OBJ header comments (file content)
    '# Meters, Y up, true scale, relative to the origin below (Z-up project coordinates, ' + (scene.units === 'm' ? 'meters' : 'US survey feet') + ')',
    `# origin easting ${o.x} northing ${o.y} elevation ${o.z}`,
  ];
  let base = 1, nBase = 1;
  for (const m of scene.meshes) {
    if (!m.indices.length) continue;
    const { P, N } = convert(scene, m.positions, m.normals);
    lines.push('o ' + m.name.replace(/\s+/g, '_'));
    for (let i = 0; i < P.length; i += 3) lines.push(`v ${f(P[i])} ${f(P[i + 1])} ${f(P[i + 2])}`);
    for (let i = 0; i < N.length; i += 3) lines.push(`vn ${N[i].toFixed(5)} ${N[i + 1].toFixed(5)} ${N[i + 2].toFixed(5)}`);   // i18n-ignore: OBJ records
    const I = m.indices;
    for (let t = 0; t < I.length; t += 3) { const a = I[t] + base, b = I[t + 1] + base, c = I[t + 2] + base, na = I[t] + nBase, nb = I[t + 1] + nBase, nc = I[t + 2] + nBase; lines.push(`f ${a}//${na} ${b}//${nb} ${c}//${nc}`); }
    base += P.length / 3; nBase += N.length / 3;
  }
  for (const l of scene.lines) {
    if (!l.positions.length) continue;
    const { P } = convert(scene, l.positions, null);
    lines.push('o ' + l.name.replace(/\s+/g, '_'));
    for (let i = 0; i < P.length; i += 3) lines.push(`v ${f(P[i])} ${f(P[i + 1])} ${f(P[i + 2])}`);
    for (let i = 0; i < P.length / 3; i += 2) lines.push(`l ${base + i} ${base + i + 1}`);
    base += P.length / 3;
  }
  if (scene.points?.positions.length) {
    const { P } = convert(scene, scene.points.positions, null);
    lines.push('o ' + t('scene.layer.node').replace(/\s+/g, '_'));
    for (let i = 0; i < P.length; i += 3) lines.push(`v ${f(P[i])} ${f(P[i + 1])} ${f(P[i + 2])}`);
    for (let i = 0; i < P.length / 3; i++) lines.push(`p ${base + i}`);
    base += P.length / 3;
  }
  return lines.join('\n') + '\n';
}
usd-civilcraft.js CivilCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests None found by the review

USD points and the Site transform

32-bit in part

Writes the site's meshes, lines and survey points to USD in meters, with the Site's transform putting them at the project coordinates.

point = (x·k, y·k, (z ÷ exaggeration)·k) as 32-bit; Site translation = origin × k as doubles

Points are converted from the 32-bit scene positions into a new Float32Array and written as point3f. The Site's translation is computed and written in 64-bit, so the large coordinates are not put through 32-bit; the local part of each point is.

  • usd-civilcraft.js
  • lines 26 to 35
  • CivilCraft 0.8.1
const site = b.group(b.root, 'Site_' + (name || 'site'), { kind: 'assembly', displayName: name || 'Site', matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, o.x * k, o.y * k, o.z * k, 1] });
b.attr(site, 'ifc:class', 'token', 'IfcSite', { custom: true, uniform: true });
const code = crs && (crs.code || crs.name || crs.id);
if (code) b.attr(site, 'ifc:crs:name', 'string', String(code), { custom: true, uniform: true });
b.attr(site, 'cadcraft:units', 'token', scene.units === 'ft' ? 'usft' : 'm', { custom: true, uniform: true });
const conv = (P) => {
  const out = new Float32Array(P.length);
  for (let i = 0; i < P.length; i += 3) { out[i] = P[i] * k; out[i + 1] = P[i + 1] * k; out[i + 2] = (P[i + 2] / e) * k; }
  return out;
};

In detail

Method
Local coordinate × meters per unit (1200 ÷ 3937 for US survey feet, exact fraction), elevation ÷ exaggeration; GLB and OBJ turned to Y up; USD keeps Z up and carries the origin in the Site's transform.
Number types
Float32Array for every exported position and normal; 64-bit numbers for the origin and the USD transform. glTF stores vertex positions as 32-bit floats by its own format.
Tolerances
OBJ vertex coordinates are written with 4 decimals (0.1 mm) and normals with 5.
Limits
A 32-bit float holds about 7 significant digits, so the exported position of a vertex is limited by its distance from the local origin, not by the size of the project coordinates. OBJ and USD can hold more digits than are written.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach