Turns a read glTF scene into mesh definitions and placements with Z up: welds vertices, applies skins and morph weights, drops empty triangles and marks sharp edges.
How the precision is kept
Plain meshes keep the file's 32-bit positions and are copied to 64-bit points with the axes swapped. A skinned mesh is posed with 64-bit joint matrices, but the posed positions are written into a Float32Array before they become the mesh's points; morphed positions are added up inside a copy of the file's Float32Array. Node matrices stay 64-bit and are changed to Z up by a swap of entries.
Open item. Write the skinned and morphed positions into a Float64Array, so a posed mesh keeps the 64-bit result of the joint matrices.
Calculations under this policy
9 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.
io/gltf-import.jsModelCraft 0.8.1 32-bit in part9 calculationsReviewed
Tests tools/modelcraft-test/gltf.mjs
Morph weights and the skin
32-bit in part
A primitive's positions and normals as the scene shows them: morph targets added by their weights, then moved by the skin's joints.
morph: p += w × target; skin: p′ = Σ wᵢ (Jᵢ × IBMᵢ × p) ÷ Σ wᵢ
The joint matrices are 64-bit products, but the posed positions are stored in a new Float32Array, and the morph sums are accumulated in a copy of the file's Float32Array. The result is rounded to 32-bit after it was computed in 64-bit, so a posed coordinate keeps about 7 digits whatever the joint matrices are. A vertex whose weights add to 1e-9 or less keeps its position.
io/gltf-import.js
lines 325 to 375
ModelCraft 0.8.1
/** A primitive's positions (and normals) as the scene shows them: morph weights, then the skin; in glTF space. */
function posed(p, weights, skin) {
let P = p.positions;
let Nm = p.normals;
if (weights && p.targets.length) {
P = P.slice();
Nm = Nm ? Nm.slice() : null;
p.targets.forEach((tg, k) => {
const w = weights[k] || 0;
if (!w) return;
if (tg.positions) for (let i = 0; i < P.length; i++) P[i] += w * tg.positions[i];
if (tg.normals && Nm) for (let i = 0; i < Nm.length; i++) Nm[i] += w * tg.normals[i];
});
}
if (skin && p.joints.length) {
const { skin: s, world } = skin;
const mats = s.joints.map((j, k) => {
const ibm = s.ibm ? Array.from(s.ibm.subarray(k * 16, k * 16 + 16)) : ident();
return mmul(world[j] ?? ident(), ibm);
});
const J = p.joints[0]; const W = p.weights[0];
const out = new Float32Array(P.length);
const outN = Nm ? new Float32Array(Nm.length) : null;
for (let i = 0; i < P.length / 3; i++) {
let x = 0; let y = 0; let z = 0; let nx = 0; let ny = 0; let nz = 0; let ws = 0;
for (let k = 0; k < 4; k++) {
const w = W[i * 4 + k];
const m = mats[J[i * 4 + k]];
if (!w || !m) continue;
const q = apply(m, [P[i * 3], P[i * 3 + 1], P[i * 3 + 2]]);
x += w * q[0]; y += w * q[1]; z += w * q[2]; ws += w;
if (Nm) { const d = applyDir(m, [Nm[i * 3], Nm[i * 3 + 1], Nm[i * 3 + 2]]); nx += w * d[0]; ny += w * d[1]; nz += w * d[2]; }
}
if (ws > 1e-9) { out[i * 3] = x / ws; out[i * 3 + 1] = y / ws; out[i * 3 + 2] = z / ws; } else { out[i * 3] = P[i * 3]; out[i * 3 + 1] = P[i * 3 + 1]; out[i * 3 + 2] = P[i * 3 + 2]; }
if (outN) { const l = Math.hypot(nx, ny, nz) || 1; outN[i * 3] = nx / l; outN[i * 3 + 1] = ny / l; outN[i * 3 + 2] = nz / l; }
}
P = out;
Nm = outN ?? Nm;
}
// normals into Z up, unit length (the mesh layer is the source of shading)
let N = null;
if (Nm) {
N = new Float32Array(Nm.length);
for (let i = 0; i < Nm.length / 3; i++) {
const x = Nm[i * 3]; const y = Nm[i * 3 + 1]; const z = Nm[i * 3 + 2];
const l = Math.hypot(x, y, z) || 1;
N[i * 3] = x / l; N[i * 3 + 1] = -z / l; N[i * 3 + 2] = y / l;
}
}
return { P, N };
}
Welding vertices
64-bit
Makes one mesh vertex for every distinct point of a primitive and swaps the axes to Z up.
same vertex when x, y and z (and joints and weights) are equal as text; stored as (x, −z, y)
The key is the three coordinates printed as numbers, so only exactly equal values weld; there is no distance tolerance. The stored point is a swap and a sign change of the value read.
io/gltf-import.js
lines 193 to 213
ModelCraft 0.8.1
// weld: one mesh vertex per distinct point (and skin data, when there is any)
const keyOf = new Map();
const vp = [];
const vSrc = []; // [prim index, vertex] that made each mesh vertex
const cornerV = new Int32Array(corners);
const cornerSrc = new Int32Array(corners * 2);
let c = 0;
prims.forEach((pr, pi) => {
const P = pr.pos;
const J = pr.p.joints[0];
const W = pr.p.weights[0];
const ids = new Int32Array(pr.p.count).fill(-1);
for (const i of pr.tri) {
if (ids[i] < 0) {
const x = P[i * 3]; const y = P[i * 3 + 1]; const z = P[i * 3 + 2];
let k = x + ',' + y + ',' + z;
if (J) k += '|' + J[i * 4] + ',' + J[i * 4 + 1] + ',' + J[i * 4 + 2] + ',' + J[i * 4 + 3] + '|' + W[i * 4] + ',' + W[i * 4 + 1] + ',' + W[i * 4 + 2] + ',' + W[i * 4 + 3];
let v = keyOf.get(k);
if (v === undefined) { v = vp.length / 3; keyOf.set(k, v); vp.push(x, -z, y); vSrc.push(pi, i); }
ids[i] = v;
}
Triangles with no area
64-bit
Drops triangles that repeat a vertex or whose corners lie on one line.
kept when |(b − a) × (c − a)| > 1e-18
The limit is absolute: the cross product's length is twice the area in square meters, so only triangles far below any drawn size are dropped. It does not scale with the model.
io/gltf-import.js
lines 220 to 229
ModelCraft 0.8.1
// drop triangles with a repeated vertex or no area
const nv = vp.length / 3;
const keepTri = new Uint8Array(corners / 3);
let nf = 0;
for (let f = 0; f < corners / 3; f++) {
const a = cornerV[f * 3]; const b = cornerV[f * 3 + 1]; const d = cornerV[f * 3 + 2];
if (a === b || b === d || a === d) continue;
const A = [vp[a * 3], vp[a * 3 + 1], vp[a * 3 + 2]];
const n = cross(sub([vp[b * 3], vp[b * 3 + 1], vp[b * 3 + 2]], A), sub([vp[d * 3], vp[d * 3 + 1], vp[d * 3 + 2]], A));
if (!(len(n) > 1e-18)) continue;
Sharp edges
64-bit
Marks an edge sharp where the file's normals break across it, or where the two faces bend.
with normals: sharp unless n₁ · n₂ > 0.9995 at both ends; without: sharp when the face normals' dot product < 0.99996
0.9995 is the cosine of about 1.8° and 0.99996 of about 0.5°. The corner normals compared are the mesh's 32-bit 'normal' layer; the face normals are 64-bit.
io/gltf-import.js
lines 302 to 312
ModelCraft 0.8.1
const same = (a, b) => N[a * 3] * N[b * 3] + N[a * 3 + 1] * N[b * 3 + 1] + N[a * 3 + 2] * N[b * 3 + 2] > 0.9995;
for (let h = 0; h < mesh.nh; h++) {
const g = hTw[h];
if (g < 0 || g < h) continue;
let sharp;
const fa = hF[h]; const fb = hF[g];
// h starts at vertex a in one face; the twin's next starts at a in the other (and the same for b)
if (has(h) && has(g) && has(hNx[h]) && has(hNx[g])) sharp = !same(h, hNx[g]) || !same(hNx[h], g);
else sharp = fnorm[fa * 3] * fnorm[fb * 3] + fnorm[fa * 3 + 1] * fnorm[fb * 3 + 1] + fnorm[fa * 3 + 2] * fnorm[fb * 3 + 2] < 0.99996;
if (sharp) { hFl[h] |= FL_SHARP; hFl[g] |= FL_SHARP; }
}
Node matrix into Z up
64-bit
Changes a node's matrix from Y up to Z up.
M′ = C × M × C⁻¹
C and its inverse hold only 0, 1 and −1, so the products only move and negate entries of the 64-bit matrix.
io/gltf-import.js
line 38
ModelCraft 0.8.1
const conj = (m) => mmul(C, mmul(m, CI));
Instanced placements
64-bit
One placement per instance from the file's per-instance translation, rotation and scale.
The per-instance values are the file's 32-bit floats; the matrix built from them and its change to Z up are 64-bit.
Vertices welded where position (and skin data) are exactly equal; skin = Σ weight × (joint world × inverse bind) × point, divided by the sum of the weights; Y up to Z up by (x, y, z) → (x, −z, y).
Number types
Float32Array for positions as read, and for skinned and morphed positions; Float64Array for the mesh's points; 64-bit numbers for matrices.
Tolerances
A triangle is dropped when the length of its edge cross product is not above 1e-18 (absolute). Edges are sharp where corner normals differ by a dot product of 0.9995 or less, or, without normals, where face normals have a dot product under 0.99996.
Limits
Welding needs exactly equal coordinates; nearly equal points stay apart. Skinned and morphed points are rounded to 32-bit after they are computed.
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.