Resources Numerical accuracy Calculation policy

Plant 3D models for the 3D view

Reads a plant's own 3D model from a GLB file and scales it to a unit box so the 3D view can draw it at the plant's size.

How the precision is kept

The model is geometry for drawing. glTF stores positions as 32-bit floats; the reader applies the node transforms in 64-bit and stores the result in a Float32Array. normalizeModel then scales the model to a unit box and writes 32-bit positions and normals. In the released build normalizeModel is called from graphics-ui.js, as a check on import, and from site3d-landscape.js, to build the shape the 3D view draws.

Calculations under this policy

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

landscape/glb.js CivilCraft 0.8.1 32-bit, display only 3 calculations Reviewed

Tests bin/test-landscape.mjs

Positions and indices from an accessor

32-bit, display only

Reads a primitive's vertex positions and triangle indices from the file's binary chunk.

Positions are accepted only as 32-bit float VEC3 and are copied with getFloat32 into a Float32Array. Every offset, stride and count is checked against the buffer before reading.

  • landscape/glb.js
  • lines 92 to 117
  • CivilCraft 0.8.1
/** One accessor's numbers, checked: positions (VEC3 float) or indices (SCALAR unsigned). */
function readAccessor(json, bin, ai, want) {
  const a = isIdx(ai, json.accessors) ? json.accessors[ai] : fail('bad-glb');
  if (!isObj(a)) fail('bad-glb');
  if (a.sparse !== undefined) fail('unsupported');
  const nc = NCOMP[a.type], cs = SIZE[a.componentType], count = a.count;
  if (!Number.isInteger(count) || count < 0) fail('bad-glb');
  if (want === 'pos' && (a.type !== 'VEC3' || a.componentType !== 5126 || a.normalized)) fail('unsupported');
  if (want === 'idx' && (a.type !== 'SCALAR' || ![5121, 5123, 5125].includes(a.componentType))) fail('bad-glb');
  if (!nc || !cs) fail('bad-glb');
  if (count === 0) return want === 'pos' ? new Float32Array(0) : new Uint32Array(0);
  if (a.bufferView === undefined) fail('bad-glb');   // all zeros: nothing to draw from
  const { bytes, stride: st } = viewBytes(json, bin, a.bufferView);
  const elem = nc * cs, stride = st === undefined ? elem : st, off = a.byteOffset ?? 0;
  if (!Number.isInteger(stride) || stride < elem || stride > 252 || stride % cs) fail('bad-glb');
  if (!Number.isInteger(off) || off < 0 || off % cs || off + stride * (count - 1) + elem > bytes.length) fail('bad-glb');
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
  if (want === 'pos') {
    const out = new Float32Array(count * 3);
    for (let i = 0; i < count; i++) for (let k = 0; k < 3; k++) { const v = dv.getFloat32(off + i * stride + k * 4, true); if (!fin(v)) fail('bad-glb'); out[i * 3 + k] = v; }
    return out;
  }
  const out = new Uint32Array(count), get = cs === 1 ? (p) => dv.getUint8(p) : cs === 2 ? (p) => dv.getUint16(p, true) : (p) => dv.getUint32(p, true);
  for (let i = 0; i < count; i++) out[i] = get(off + i * stride);
  return out;
}

Node transform

32-bit, display only

The 4×4 matrix of a node from its matrix, or from its translation, rotation and scale.

M = T · R(q ÷ |q|) · S

Built in 64-bit numbers. The quaternion is normalized with Math.hypot before the rotation matrix is formed; a zero quaternion is divided by 1.

  • landscape/glb.js
  • lines 126 to 137
  • CivilCraft 0.8.1
function nodeMatrix(n) {
  if (n.matrix !== undefined) { if (!Array.isArray(n.matrix) || n.matrix.length !== 16 || !n.matrix.every(fin)) fail('bad-glb'); return n.matrix.slice(); }
  const T = n.translation ?? [0, 0, 0], R = n.rotation ?? [0, 0, 0, 1], S = n.scale ?? [1, 1, 1];
  if (![T, R, S].every((v, i) => Array.isArray(v) && v.length === (i === 1 ? 4 : 3) && v.every(fin))) fail('bad-glb');
  const [x, y, z, w] = R, L = Math.hypot(x, y, z, w) || 1, qx = x / L, qy = y / L, qz = z / L, qw = w / L;
  const r = [1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy + qz * qw), 2 * (qx * qz - qy * qw), 0,
    2 * (qx * qy - qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz + qx * qw), 0,
    2 * (qx * qz + qy * qw), 2 * (qy * qz - qx * qw), 1 - 2 * (qx * qx + qy * qy), 0, 0, 0, 0, 1];
  for (let c = 0; c < 3; c++) for (let k = 0; k < 3; k++) r[c * 4 + k] *= S[c];
  r[12] = T[0]; r[13] = T[1]; r[14] = T[2];
  return r;
}

Transform to the model's frame, Y up to Z up

32-bit, display only

Applies each node's transform to its vertices and gathers all triangles into one mesh.

p′ = M·p; (x, y, z) → (x, −z, y)

The transform is computed in 64-bit from 32-bit inputs and the result is stored with Float32Array.from. Triangles of a node whose matrix has a negative determinant are reversed.

  • landscape/glb.js
  • lines 210 to 229
  • CivilCraft 0.8.1
// second pass: read, transform, Y up → Z up
const pos = [], ind = [];
for (const { pr, m, mode } of draws) {
  const P = readAccessor(json, bin, pr.attributes.POSITION, 'pos'), nv = P.length / 3;
  const I = pr.indices === undefined ? null : readAccessor(json, bin, pr.indices, 'idx');
  if (I && I.some((v) => v >= nv)) fail('bad-glb');
  const base = pos.length / 3;
  for (let i = 0; i < nv; i++) {
    const x = P[i * 3], y = P[i * 3 + 1], z = P[i * 3 + 2];
    const X = m[0] * x + m[4] * y + m[8] * z + m[12], Y = m[1] * x + m[5] * y + m[9] * z + m[13], Z = m[2] * x + m[6] * y + m[10] * z + m[14];
    if (!fin(X) || !fin(Y) || !fin(Z)) fail('bad-glb');
    pos.push(X, -Z, Y);
  }
  const tri = triangles(mode, I, nv);
  const det = m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2]) + m[8] * (m[1] * m[6] - m[5] * m[2]);
  if (det < 0) for (let i = 0; i < tri.length; i += 3) { const k = tri[i + 1]; tri[i + 1] = tri[i + 2]; tri[i + 2] = k; }   // a mirrored node keeps its faces outward
  for (const v of tri) ind.push(base + v);
}
if (ind.length < 3) fail('empty');
return { positions: Float32Array.from(pos), indices: Uint32Array.from(ind), notes: [...notes] };
landscape/symbols.js CivilCraft 0.8.1 32-bit, display only 1 calculation Reviewed

Tests bin/test-landscape.mjs

Scale a model to the unit box

32-bit, display only

Centers a model in plan, sets its base at zero and scales it to one unit wide and one unit tall, with a normal per triangle.

x′ = (x − cx) ÷ w; z′ = (z − z min) ÷ h; n = (b − a) × (c − a) ÷ |…|

Positions and normals are written to Float32Array for the 3D view. Width and height are scaled separately to the unit box, so the file's own proportions between plan size and height are not kept.

  • landscape/symbols.js
  • lines 126 to 141
  • CivilCraft 0.8.1
export function normalizeModel(positions, indices, { maxTris = SYMBOL_LIMITS.tris } = {}) {
  const P = Array.from(positions || []), I = Array.from(indices || []);
  if (I.length / 3 > maxTris) throw new Error('too-big');
  if (P.length < 9 || I.length < 3 || P.some((v) => !fin(v)) || I.some((v) => !Number.isInteger(v) || v < 0 || v >= P.length / 3)) throw new Error('empty');
  let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
  for (let i = 0; i < P.length; i += 3) { x0 = Math.min(x0, P[i]); x1 = Math.max(x1, P[i]); y0 = Math.min(y0, P[i + 1]); y1 = Math.max(y1, P[i + 1]); z0 = Math.min(z0, P[i + 2]); z1 = Math.max(z1, P[i + 2]); }
  const w = Math.max(x1 - x0, y1 - y0) || 1, h = z1 - z0 || 1, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
  const pos = new Float32Array(I.length * 3), nor = new Float32Array(I.length * 3), idx = new Uint32Array(I.length);
  for (let t = 0; t < I.length; t += 3) {   // flat-shaded: three own corners per triangle
    const c = [0, 1, 2].map((k) => { const v = I[t + k] * 3; return [(P[v] - cx) / w, (P[v + 1] - cy) / w, (P[v + 2] - z0) / h]; });
    const ux = c[1][0] - c[0][0], uy = c[1][1] - c[0][1], uz = c[1][2] - c[0][2], vx = c[2][0] - c[0][0], vy = c[2][1] - c[0][1], vz = c[2][2] - c[0][2];
    let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; const L = Math.hypot(nx, ny, nz) || 1; nx /= L; ny /= L; nz /= L;
    for (let k = 0; k < 3; k++) { pos.set(c[k], (t + k) * 3); nor.set([nx, ny, nz], (t + k) * 3); idx[t + k] = t + k; }
  }
  return { positions: pos, normals: nor, indices: idx };
}

In detail

Method
GLB container, accessors and node transforms read as the file header describes, from the Khronos glTF 2.0 specification. Y-up is turned to Z-up by (x, y, z) → (x, −z, y). Flat shading: one normal per triangle from the cross product of two edges.
Number types
32-bit floats in the file and in the result (Float32Array); 64-bit for the node matrices and the transform.
Limits
Units are not read: the model is scaled to the plant. Compressed or quantized geometry and sparse accessors are refused.

Approach: Display only

Computed to draw the screen, often in 32-bit on the graphics processor. No number a person reads, saves or exports comes from it.

Every policy that takes this approach