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.
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.
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] };
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.