816 lines · 34.3 KB
Raw Download
1
/*
2
 * model-viewer.js — a tiny, dependency-free 3D viewer for NestEggCode.
3
 *
4
 * Renders STL (binary + ASCII), 3MF, OBJ, PLY, and G-code files directly in the
5
 * browser with raw WebGL — no external 3D library, no build step. Each
6
 * <div class="model-view" data-src data-format> on the page is fetched, parsed
7
 * client-side, and drawn on a <canvas> with orbit / zoom / pan controls.
8
 *
9
 * On any failure (unsupported format, parse error, no WebGL) it shows a short
10
 * message and a download link rather than a blank canvas.
11
 */
12
(function () {
13
    'use strict';
14
15
    /* ------------------------------------------------------------------ *
16
     * Small mat4 / vec3 helpers (column-major, WebGL convention).
17
     * Just enough linear algebra for a camera — not a math library.
18
     * ------------------------------------------------------------------ */
19
20
    function mIdentity() {
21
        return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
22
    }
23
24
    function mMultiply(a, b) {
25
        // Returns a * b.
26
        var o = new Float32Array(16);
27
        for (var c = 0; c < 4; c++) {
28
            for (var r = 0; r < 4; r++) {
29
                o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] +
30
                               a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
31
            }
32
        }
33
        return o;
34
    }
35
36
    function mPerspective(fovy, aspect, near, far) {
37
        var f = 1.0 / Math.tan(fovy / 2);
38
        var nf = 1 / (near - far);
39
        return new Float32Array([
40
            f / aspect, 0, 0, 0,
41
            0, f, 0, 0,
42
            0, 0, (far + near) * nf, -1,
43
            0, 0, 2 * far * near * nf, 0
44
        ]);
45
    }
46
47
    function mLookAt(eye, center, up) {
48
        var z = vNorm(vSub(eye, center));
49
        var x = vNorm(vCross(up, z));
50
        var y = vCross(z, x);
51
        return new Float32Array([
52
            x[0], y[0], z[0], 0,
53
            x[1], y[1], z[1], 0,
54
            x[2], y[2], z[2], 0,
55
            -vDot(x, eye), -vDot(y, eye), -vDot(z, eye), 1
56
        ]);
57
    }
58
59
    function mRotateX(rad) {
60
        var c = Math.cos(rad), s = Math.sin(rad);
61
        return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]);
62
    }
63
64
    // Normal matrix (mat3, as 9 floats) = inverse-transpose of a mat4's 3x3.
65
    function normalMatrix(m) {
66
        var a00 = m[0], a01 = m[1], a02 = m[2];
67
        var a10 = m[4], a11 = m[5], a12 = m[6];
68
        var a20 = m[8], a21 = m[9], a22 = m[10];
69
        var b01 = a22 * a11 - a12 * a21;
70
        var b11 = -a22 * a10 + a12 * a20;
71
        var b21 = a21 * a10 - a11 * a20;
72
        var det = a00 * b01 + a01 * b11 + a02 * b21;
73
        if (!det) { return new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); }
74
        det = 1.0 / det;
75
        // inverse (of 3x3) then transpose, folded together.
76
        return new Float32Array([
77
            b01 * det,
78
            (-a22 * a01 + a02 * a21) * det,
79
            (a12 * a01 - a02 * a11) * det,
80
            b11 * det,
81
            (a22 * a00 - a02 * a20) * det,
82
            (-a12 * a00 + a02 * a10) * det,
83
            b21 * det,
84
            (-a21 * a00 + a01 * a20) * det,
85
            (a11 * a00 - a01 * a10) * det
86
        ]);
87
    }
88
89
    function vSub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
90
    function vAdd(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; }
91
    function vScale(a, s) { return [a[0] * s, a[1] * s, a[2] * s]; }
92
    function vDot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
93
    function vCross(a, b) {
94
        return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
95
    }
96
    function vNorm(a) {
97
        var l = Math.hypot(a[0], a[1], a[2]) || 1;
98
        return [a[0] / l, a[1] / l, a[2] / l];
99
    }
100
    function vTransform(m, p) {
101
        // Apply a mat4 to a point (w = 1).
102
        return [
103
            m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
104
            m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
105
            m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]
106
        ];
107
    }
108
109
    /* ------------------------------------------------------------------ *
110
     * Geometry helpers.
111
     * ------------------------------------------------------------------ */
112
113
    // Expand an indexed triangle list into a flat "triangle soup" (9 floats/tri).
114
    function indexedToSoup(verts, indices) {
115
        var positions = new Float32Array(indices.length * 3);
116
        for (var i = 0; i < indices.length; i++) {
117
            var vi = indices[i] * 3;
118
            positions[i * 3] = verts[vi];
119
            positions[i * 3 + 1] = verts[vi + 1];
120
            positions[i * 3 + 2] = verts[vi + 2];
121
        }
122
        return positions;
123
    }
124
125
    // Flat (per-face) normals for a triangle soup — gives a clean faceted look.
126
    function computeFlatNormals(positions) {
127
        var normals = new Float32Array(positions.length);
128
        for (var i = 0; i < positions.length; i += 9) {
129
            var ux = positions[i + 3] - positions[i];
130
            var uy = positions[i + 4] - positions[i + 1];
131
            var uz = positions[i + 5] - positions[i + 2];
132
            var vx = positions[i + 6] - positions[i];
133
            var vy = positions[i + 7] - positions[i + 1];
134
            var vz = positions[i + 8] - positions[i + 2];
135
            var nx = uy * vz - uz * vy;
136
            var ny = uz * vx - ux * vz;
137
            var nz = ux * vy - uy * vx;
138
            var len = Math.hypot(nx, ny, nz) || 1;
139
            nx /= len; ny /= len; nz /= len;
140
            for (var k = 0; k < 3; k++) {
141
                normals[i + k * 3] = nx;
142
                normals[i + k * 3 + 1] = ny;
143
                normals[i + k * 3 + 2] = nz;
144
            }
145
        }
146
        return normals;
147
    }
148
149
    function meshFromSoup(positions) {
150
        if (!positions.length) { throw new Error('Model contains no geometry.'); }
151
        return { kind: 'mesh', positions: positions, normals: computeFlatNormals(positions) };
152
    }
153
154
    // Bounding box of a flat position array -> {min, max, center, radius}.
155
    function bounds(positions) {
156
        var min = [Infinity, Infinity, Infinity];
157
        var max = [-Infinity, -Infinity, -Infinity];
158
        for (var i = 0; i < positions.length; i += 3) {
159
            for (var a = 0; a < 3; a++) {
160
                var v = positions[i + a];
161
                if (v < min[a]) { min[a] = v; }
162
                if (v > max[a]) { max[a] = v; }
163
            }
164
        }
165
        var center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
166
        var radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2 || 1;
167
        return { min: min, max: max, center: center, radius: radius };
168
    }
169
170
    /* ------------------------------------------------------------------ *
171
     * Parsers. Each returns a geometry object:
172
     *   { kind:'mesh',   positions, normals }
173
     *   { kind:'lines',  positions, colors }
174
     *   { kind:'points', positions, colors }
175
     * ------------------------------------------------------------------ */
176
177
    function isBinarySTL(buf) {
178
        if (buf.byteLength < 84) { return false; }
179
        var n = new DataView(buf).getUint32(80, true);
180
        return 84 + n * 50 === buf.byteLength;
181
    }
182
183
    function parseStlBinary(buf) {
184
        var dv = new DataView(buf);
185
        var n = dv.getUint32(80, true);
186
        var positions = new Float32Array(n * 9);
187
        var o = 84, p = 0;
188
        for (var i = 0; i < n; i++) {
189
            o += 12; // skip the per-face normal; we recompute flat normals.
190
            for (var v = 0; v < 9; v++) { positions[p++] = dv.getFloat32(o, true); o += 4; }
191
            o += 2;  // attribute byte count
192
        }
193
        return positions;
194
    }
195
196
    function parseStlAscii(text) {
197
        var nums = [];
198
        var re = /vertex\s+(-?[\d.eE+]+)\s+(-?[\d.eE+]+)\s+(-?[\d.eE+]+)/g;
199
        var m;
200
        while ((m = re.exec(text))) { nums.push(+m[1], +m[2], +m[3]); }
201
        return new Float32Array(nums);
202
    }
203
204
    function parseSTL(buf) {
205
        if (isBinarySTL(buf)) { return meshFromSoup(parseStlBinary(buf)); }
206
        var text = new TextDecoder().decode(buf);
207
        if (/facet/i.test(text)) { return meshFromSoup(parseStlAscii(text)); }
208
        return meshFromSoup(parseStlBinary(buf)); // last resort
209
    }
210
211
    function parseOBJ(text) {
212
        var verts = [], idx = [];
213
        var lines = text.split('\n');
214
        for (var i = 0; i < lines.length; i++) {
215
            var t = lines[i].trim();
216
            if (t.charCodeAt(0) === 118 /* v */ && t[1] === ' ') {
217
                var vp = t.split(/\s+/);
218
                verts.push(+vp[1], +vp[2], +vp[3]);
219
            } else if (t.charCodeAt(0) === 102 /* f */ && t[1] === ' ') {
220
                var fp = t.split(/\s+/);
221
                var face = [];
222
                for (var j = 1; j < fp.length; j++) {
223
                    var s = fp[j].split('/')[0];
224
                    var n = parseInt(s, 10);
225
                    if (n < 0) { n = verts.length / 3 + n + 1; }
226
                    face.push(n - 1);
227
                }
228
                for (var k = 1; k < face.length - 1; k++) {
229
                    idx.push(face[0], face[k], face[k + 1]);
230
                }
231
            }
232
        }
233
        return meshFromSoup(indexedToSoup(verts, idx));
234
    }
235
236
    function parsePLY(buf) {
237
        var bytes = new Uint8Array(buf);
238
        var dec = new TextDecoder('ascii');
239
        // Read the header line-by-line to find its byte length precisely.
240
        var pos = 0, headerEnd = -1, headerLines = [];
241
        while (pos < bytes.length) {
242
            var e = pos;
243
            while (e < bytes.length && bytes[e] !== 10) { e++; }
244
            var line = dec.decode(bytes.subarray(pos, e)).replace(/\r$/, '');
245
            headerLines.push(line);
246
            pos = e + 1;
247
            if (line.trim() === 'end_header') { headerEnd = pos; break; }
248
            if (headerLines.length > 5000) { break; }
249
        }
250
        if (headerEnd < 0) { throw new Error('Not a valid PLY file.'); }
251
252
        var format = 'ascii', elements = [], cur = null;
253
        for (var h = 0; h < headerLines.length; h++) {
254
            var p = headerLines[h].trim().split(/\s+/);
255
            if (p[0] === 'format') {
256
                format = p[1];
257
            } else if (p[0] === 'element') {
258
                cur = { name: p[1], count: +p[2], props: [] };
259
                elements.push(cur);
260
            } else if (p[0] === 'property' && cur) {
261
                if (p[1] === 'list') {
262
                    cur.props.push({ name: p[4], list: true, countType: p[2], itemType: p[3] });
263
                } else {
264
                    cur.props.push({ name: p[2], type: p[1] });
265
                }
266
            }
267
        }
268
        var vertEl = elements.filter(function (x) { return x.name === 'vertex'; })[0];
269
        var faceEl = elements.filter(function (x) { return x.name === 'face'; })[0];
270
        if (!vertEl) { throw new Error('PLY file has no vertices.'); }
271
272
        var verts = [], idx = [];
273
274
        if (format === 'ascii') {
275
            var toks = dec.decode(bytes.subarray(headerEnd)).split(/\s+/).filter(function (s) { return s.length; });
276
            var ti = 0;
277
            var xi = propIndex(vertEl, 'x'), yi = propIndex(vertEl, 'y'), zi = propIndex(vertEl, 'z');
278
            var np = vertEl.props.length;
279
            for (var vi = 0; vi < vertEl.count; vi++) {
280
                verts.push(+toks[ti + xi], +toks[ti + yi], +toks[ti + zi]);
281
                ti += np;
282
            }
283
            if (faceEl) {
284
                for (var fi = 0; fi < faceEl.count; fi++) {
285
                    var cnt = +toks[ti++], f = [];
286
                    for (var c = 0; c < cnt; c++) { f.push(+toks[ti++]); }
287
                    for (var kk = 1; kk < cnt - 1; kk++) { idx.push(f[0], f[kk], f[kk + 1]); }
288
                }
289
            }
290
        } else {
291
            var le = format === 'binary_little_endian';
292
            var dv = new DataView(buf, headerEnd);
293
            var off = { v: 0 };
294
            for (var bi = 0; bi < vertEl.count; bi++) {
295
                var x = 0, y = 0, z = 0;
296
                for (var pp = 0; pp < vertEl.props.length; pp++) {
297
                    var val = readPlyType(dv, off, vertEl.props[pp].type, le);
298
                    if (vertEl.props[pp].name === 'x') { x = val; }
299
                    else if (vertEl.props[pp].name === 'y') { y = val; }
300
                    else if (vertEl.props[pp].name === 'z') { z = val; }
301
                }
302
                verts.push(x, y, z);
303
            }
304
            if (faceEl) {
305
                for (var bf = 0; bf < faceEl.count; bf++) {
306
                    for (var fp = 0; fp < faceEl.props.length; fp++) {
307
                        var prop = faceEl.props[fp];
308
                        if (prop.list) {
309
                            var n = readPlyType(dv, off, prop.countType, le);
310
                            var fa = [];
311
                            for (var q = 0; q < n; q++) { fa.push(readPlyType(dv, off, prop.itemType, le)); }
312
                            for (var t2 = 1; t2 < n - 1; t2++) { idx.push(fa[0], fa[t2], fa[t2 + 1]); }
313
                        } else {
314
                            readPlyType(dv, off, prop.type, le);
315
                        }
316
                    }
317
                }
318
            }
319
        }
320
321
        if (idx.length) { return meshFromSoup(indexedToSoup(verts, idx)); }
322
        // No faces: render as a point cloud.
323
        var positions = new Float32Array(verts);
324
        var colors = new Float32Array(verts.length);
325
        for (var ci = 0; ci < colors.length; ci += 3) { colors[ci] = 0.6; colors[ci + 1] = 0.7; colors[ci + 2] = 0.85; }
326
        return { kind: 'points', positions: positions, colors: colors };
327
    }
328
329
    function propIndex(el, name) {
330
        for (var i = 0; i < el.props.length; i++) { if (el.props[i].name === name) { return i; } }
331
        return -1;
332
    }
333
334
    function readPlyType(dv, off, type, le) {
335
        var v;
336
        switch (type) {
337
            case 'char': case 'int8': v = dv.getInt8(off.v); off.v += 1; return v;
338
            case 'uchar': case 'uint8': v = dv.getUint8(off.v); off.v += 1; return v;
339
            case 'short': case 'int16': v = dv.getInt16(off.v, le); off.v += 2; return v;
340
            case 'ushort': case 'uint16': v = dv.getUint16(off.v, le); off.v += 2; return v;
341
            case 'int': case 'int32': v = dv.getInt32(off.v, le); off.v += 4; return v;
342
            case 'uint': case 'uint32': v = dv.getUint32(off.v, le); off.v += 4; return v;
343
            case 'double': case 'float64': v = dv.getFloat64(off.v, le); off.v += 8; return v;
344
            default: v = dv.getFloat32(off.v, le); off.v += 4; return v; // float / float32
345
        }
346
    }
347
348
    /* ---- 3MF (a ZIP of XML) ------------------------------------------- */
349
350
    async function parse3MF(buf) {
351
        var xml = await extractModelXml(buf);
352
        var doc = new DOMParser().parseFromString(xml, 'application/xml');
353
        if (doc.getElementsByTagName('parsererror').length) {
354
            throw new Error('Could not parse the 3MF model XML.');
355
        }
356
        var meshes = doc.getElementsByTagNameNS('*', 'mesh');
357
        if (!meshes.length) { throw new Error('3MF file contains no mesh.'); }
358
        var verts = [], idx = [];
359
        for (var m = 0; m < meshes.length; m++) {
360
            var base = verts.length / 3;
361
            var vs = meshes[m].getElementsByTagNameNS('*', 'vertex');
362
            for (var i = 0; i < vs.length; i++) {
363
                verts.push(+vs[i].getAttribute('x'), +vs[i].getAttribute('y'), +vs[i].getAttribute('z'));
364
            }
365
            var ts = meshes[m].getElementsByTagNameNS('*', 'triangle');
366
            for (var j = 0; j < ts.length; j++) {
367
                idx.push(base + +ts[j].getAttribute('v1'),
368
                         base + +ts[j].getAttribute('v2'),
369
                         base + +ts[j].getAttribute('v3'));
370
            }
371
        }
372
        return meshFromSoup(indexedToSoup(verts, idx));
373
    }
374
375
    // Minimal ZIP reader: locate the first "*.model" entry and return its text.
376
    async function extractModelXml(buf) {
377
        var dv = new DataView(buf);
378
        // Find the End Of Central Directory record (scan back for 0x06054b50).
379
        var eocd = -1;
380
        for (var i = buf.byteLength - 22; i >= 0; i--) {
381
            if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
382
        }
383
        if (eocd < 0) { throw new Error('3MF is not a valid ZIP archive.'); }
384
        var count = dv.getUint16(eocd + 10, true);
385
        var cdOffset = dv.getUint32(eocd + 16, true);
386
387
        var best = null, p = cdOffset;
388
        for (var c = 0; c < count; c++) {
389
            if (dv.getUint32(p, true) !== 0x02014b50) { break; }
390
            var method = dv.getUint16(p + 10, true);
391
            var compSize = dv.getUint32(p + 20, true);
392
            var nameLen = dv.getUint16(p + 28, true);
393
            var extraLen = dv.getUint16(p + 30, true);
394
            var commentLen = dv.getUint16(p + 32, true);
395
            var localOffset = dv.getUint32(p + 42, true);
396
            var name = new TextDecoder().decode(new Uint8Array(buf, p + 46, nameLen));
397
            var lower = name.toLowerCase();
398
            if (lower.indexOf('.model') === lower.length - 6) {
399
                var entry = { method: method, compSize: compSize, localOffset: localOffset };
400
                if (lower === '3d/3dmodel.model') { best = entry; break; }
401
                if (!best) { best = entry; }
402
            }
403
            p += 46 + nameLen + extraLen + commentLen;
404
        }
405
        if (!best) { throw new Error('No 3D model found inside the 3MF archive.'); }
406
407
        // Read the local file header to find where the compressed data starts.
408
        var lo = best.localOffset;
409
        if (dv.getUint32(lo, true) !== 0x04034b50) { throw new Error('Corrupt 3MF archive.'); }
410
        var lNameLen = dv.getUint16(lo + 26, true);
411
        var lExtraLen = dv.getUint16(lo + 28, true);
412
        var dataStart = lo + 30 + lNameLen + lExtraLen;
413
        var comp = new Uint8Array(buf, dataStart, best.compSize);
414
415
        if (best.method === 0) { return new TextDecoder().decode(comp); }
416
        if (best.method === 8) {
417
            if (typeof DecompressionStream === 'undefined') {
418
                throw new Error('This browser cannot decompress the 3MF (no DecompressionStream).');
419
            }
420
            var ds = new DecompressionStream('deflate-raw');
421
            var stream = new Blob([comp]).stream().pipeThrough(ds);
422
            var out = await new Response(stream).arrayBuffer();
423
            return new TextDecoder().decode(out);
424
        }
425
        throw new Error('Unsupported compression inside the 3MF archive.');
426
    }
427
428
    /* ---- G-code ------------------------------------------------------- */
429
430
    function gcodeColor(t) {
431
        // Layer-height gradient: blue -> green -> yellow -> red.
432
        t = t < 0 ? 0 : (t > 1 ? 1 : t);
433
        var r = Math.min(1, Math.max(0, 1.4 * t - 0.2));
434
        var g = Math.max(0, Math.sin(Math.PI * t) * 0.85 + 0.15);
435
        var b = Math.max(0, 1.0 - 1.35 * t);
436
        return [r, g, b];
437
    }
438
439
    function parseGcode(text) {
440
        var lines = text.split('\n');
441
        var abs = true, eAbs = true;
442
        var x = 0, y = 0, z = 0, e = 0;
443
        var hasPrev = false, px = 0, py = 0, pz = 0;
444
        var segs = [];
445
        var zmin = Infinity, zmax = -Infinity;
446
447
        for (var li = 0; li < lines.length; li++) {
448
            var line = lines[li];
449
            var sc = line.indexOf(';');
450
            if (sc >= 0) { line = line.slice(0, sc); }
451
            line = line.trim();
452
            if (!line) { continue; }
453
            var parts = line.split(/\s+/);
454
            var cmd = parts[0].toUpperCase();
455
456
            if (cmd === 'G90') { abs = true; continue; }
457
            if (cmd === 'G91') { abs = false; continue; }
458
            if (cmd === 'M82') { eAbs = true; continue; }
459
            if (cmd === 'M83') { eAbs = false; continue; }
460
            if (cmd === 'G92') {
461
                for (var g = 1; g < parts.length; g++) {
462
                    var ga = parts[g][0].toUpperCase(), gv = parseFloat(parts[g].slice(1));
463
                    if (isNaN(gv)) { continue; }
464
                    if (ga === 'X') { x = gv; } else if (ga === 'Y') { y = gv; }
465
                    else if (ga === 'Z') { z = gv; } else if (ga === 'E') { e = gv; }
466
                }
467
                continue;
468
            }
469
            if (cmd === 'G0' || cmd === 'G1' || cmd === 'G00' || cmd === 'G01') {
470
                var nx = x, ny = y, nz = z, eVal = null;
471
                for (var pi = 1; pi < parts.length; pi++) {
472
                    var a = parts[pi][0].toUpperCase(), val = parseFloat(parts[pi].slice(1));
473
                    if (isNaN(val)) { continue; }
474
                    if (a === 'X') { nx = abs ? val : x + val; }
475
                    else if (a === 'Y') { ny = abs ? val : y + val; }
476
                    else if (a === 'Z') { nz = abs ? val : z + val; }
477
                    else if (a === 'E') { eVal = val; }
478
                }
479
                var extruding = false;
480
                if (eVal !== null) {
481
                    if (eAbs) { extruding = eVal > e + 1e-6; e = eVal; }
482
                    else { extruding = eVal > 1e-6; e += eVal; }
483
                }
484
                if (extruding && hasPrev) {
485
                    segs.push([px, py, pz, nx, ny, nz, (pz + nz) * 0.5]);
486
                    if (nz < zmin) { zmin = nz; }
487
                    if (nz > zmax) { zmax = nz; }
488
                }
489
                x = nx; y = ny; z = nz;
490
                px = x; py = y; pz = z; hasPrev = true;
491
            }
492
        }
493
        if (!segs.length) { throw new Error('No extrusion moves found in this G-code.'); }
494
495
        var positions = new Float32Array(segs.length * 6);
496
        var colors = new Float32Array(segs.length * 6);
497
        var span = (zmax - zmin) || 1;
498
        for (var s = 0; s < segs.length; s++) {
499
            var seg = segs[s];
500
            positions.set([seg[0], seg[1], seg[2], seg[3], seg[4], seg[5]], s * 6);
501
            var col = gcodeColor((seg[6] - zmin) / span);
502
            colors.set([col[0], col[1], col[2], col[0], col[1], col[2]], s * 6);
503
        }
504
        return { kind: 'lines', positions: positions, colors: colors };
505
    }
506
507
    /* ------------------------------------------------------------------ *
508
     * Format dispatch.
509
     * ------------------------------------------------------------------ */
510
511
    // Formats that are "Z-up" (3D-printing convention): rotate so Z points up.
512
    var Z_UP = { stl: true, '3mf': true, gcode: true };
513
514
    async function parseModel(format, buf) {
515
        switch (format) {
516
            case 'stl': return parseSTL(buf);
517
            case '3mf': return await parse3MF(buf);
518
            case 'obj': return parseOBJ(new TextDecoder().decode(buf));
519
            case 'ply': return parsePLY(buf);
520
            case 'gcode': return parseGcode(new TextDecoder().decode(buf));
521
            default: throw new Error('Unsupported model format: ' + format);
522
        }
523
    }
524
525
    /* ------------------------------------------------------------------ *
526
     * WebGL renderer + orbit camera.
527
     * ------------------------------------------------------------------ */
528
529
    var MESH_VS =
530
        'attribute vec3 aPos; attribute vec3 aNormal;' +
531
        'uniform mat4 uProj; uniform mat4 uView; uniform mat4 uModel; uniform mat3 uNormal;' +
532
        'varying vec3 vNormal;' +
533
        'void main(){ vNormal = uNormal * aNormal; gl_Position = uProj*uView*uModel*vec4(aPos,1.0); }';
534
535
    var MESH_FS =
536
        'precision mediump float; varying vec3 vNormal; uniform vec3 uColor;' +
537
        'void main(){' +
538
        '  vec3 n = normalize(vNormal);' +
539
        '  float d = abs(dot(n, vec3(0.0,0.0,1.0)));' + // headlight (view space)
540
        '  float shade = 0.28 + 0.72*d;' +
541
        '  gl_FragColor = vec4(uColor*shade, 1.0);' +
542
        '}';
543
544
    var LINE_VS =
545
        'attribute vec3 aPos; attribute vec3 aColor;' +
546
        'uniform mat4 uProj; uniform mat4 uView; uniform mat4 uModel;' +
547
        'varying vec3 vColor;' +
548
        'void main(){ vColor = aColor; gl_PointSize = 2.0; gl_Position = uProj*uView*uModel*vec4(aPos,1.0); }';
549
550
    var LINE_FS =
551
        'precision mediump float; varying vec3 vColor;' +
552
        'void main(){ gl_FragColor = vec4(vColor, 1.0); }';
553
554
    function compile(gl, type, src) {
555
        var sh = gl.createShader(type);
556
        gl.shaderSource(sh, src);
557
        gl.compileShader(sh);
558
        if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
559
            throw new Error('Shader error: ' + gl.getShaderInfoLog(sh));
560
        }
561
        return sh;
562
    }
563
564
    function program(gl, vs, fs) {
565
        var p = gl.createProgram();
566
        gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, vs));
567
        gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, fs));
568
        gl.linkProgram(p);
569
        if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
570
            throw new Error('Program link error: ' + gl.getProgramInfoLog(p));
571
        }
572
        return p;
573
    }
574
575
    function buffer(gl, data) {
576
        var b = gl.createBuffer();
577
        gl.bindBuffer(gl.ARRAY_BUFFER, b);
578
        gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
579
        return b;
580
    }
581
582
    function setupRenderer(canvas, geom) {
583
        var gl = canvas.getContext('webgl', { antialias: true, alpha: true }) ||
584
                 canvas.getContext('experimental-webgl', { antialias: true, alpha: true });
585
        if (!gl) { throw new Error('WebGL is not available in this browser.'); }
586
587
        var isMesh = geom.kind === 'mesh';
588
        var prog = isMesh ? program(gl, MESH_VS, MESH_FS) : program(gl, LINE_VS, LINE_FS);
589
        gl.useProgram(prog);
590
591
        var posBuf = buffer(gl, geom.positions);
592
        var secondBuf = buffer(gl, isMesh ? geom.normals : geom.colors);
593
        var vertexCount = geom.positions.length / 3;
594
595
        var aPos = gl.getAttribLocation(prog, 'aPos');
596
        var aSecond = gl.getAttribLocation(prog, isMesh ? 'aNormal' : 'aColor');
597
        var uProj = gl.getUniformLocation(prog, 'uProj');
598
        var uView = gl.getUniformLocation(prog, 'uView');
599
        var uModel = gl.getUniformLocation(prog, 'uModel');
600
        var uNormal = isMesh ? gl.getUniformLocation(prog, 'uNormal') : null;
601
        var uColor = isMesh ? gl.getUniformLocation(prog, 'uColor') : null;
602
603
        gl.enable(gl.DEPTH_TEST);
604
        gl.clearColor(0, 0, 0, 0);
605
606
        return {
607
            gl: gl, draw: function (proj, view, model) {
608
                gl.useProgram(prog);
609
                gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
610
                gl.enableVertexAttribArray(aPos);
611
                gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0);
612
                gl.bindBuffer(gl.ARRAY_BUFFER, secondBuf);
613
                gl.enableVertexAttribArray(aSecond);
614
                gl.vertexAttribPointer(aSecond, 3, gl.FLOAT, false, 0, 0);
615
616
                gl.uniformMatrix4fv(uProj, false, proj);
617
                gl.uniformMatrix4fv(uView, false, view);
618
                gl.uniformMatrix4fv(uModel, false, model);
619
                if (isMesh) {
620
                    gl.uniformMatrix3fv(uNormal, false, normalMatrix(mMultiply(view, model)));
621
                    gl.uniform3f(uColor, 0.55, 0.62, 0.72);
622
                }
623
624
                gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
625
                var mode = geom.kind === 'mesh' ? gl.TRIANGLES
626
                         : (geom.kind === 'lines' ? gl.LINES : gl.POINTS);
627
                gl.drawArrays(mode, 0, vertexCount);
628
            }
629
        };
630
    }
631
632
    /* ------------------------------------------------------------------ *
633
     * Per-element viewer wiring (camera state, controls, render loop).
634
     * ------------------------------------------------------------------ */
635
636
    var FOV = 45 * Math.PI / 180;
637
638
    function initViewer(container, geom, format) {
639
        var canvas = document.createElement('canvas');
640
        canvas.className = 'model-canvas';
641
        container.innerHTML = '';
642
        container.appendChild(canvas);
643
644
        var hint = document.createElement('div');
645
        hint.className = 'model-hint';
646
        hint.textContent = 'drag to rotate · scroll to zoom · shift-drag to pan';
647
        container.appendChild(hint);
648
649
        var renderer = setupRenderer(canvas, geom);
650
        var gl = renderer.gl;
651
652
        // Model matrix: rotate Z-up formats so Z points up on screen.
653
        var model = Z_UP[format] ? mRotateX(-Math.PI / 2) : mIdentity();
654
655
        // World-space bounds (transform the 8 local-bbox corners by the model).
656
        var b = bounds(geom.positions);
657
        var wmin = [Infinity, Infinity, Infinity], wmax = [-Infinity, -Infinity, -Infinity];
658
        for (var cx = 0; cx < 2; cx++) {
659
            for (var cy = 0; cy < 2; cy++) {
660
                for (var cz = 0; cz < 2; cz++) {
661
                    var corner = vTransform(model, [
662
                        cx ? b.max[0] : b.min[0],
663
                        cy ? b.max[1] : b.min[1],
664
                        cz ? b.max[2] : b.min[2]
665
                    ]);
666
                    for (var a = 0; a < 3; a++) {
667
                        if (corner[a] < wmin[a]) { wmin[a] = corner[a]; }
668
                        if (corner[a] > wmax[a]) { wmax[a] = corner[a]; }
669
                    }
670
                }
671
            }
672
        }
673
        var target = [(wmin[0] + wmax[0]) / 2, (wmin[1] + wmax[1]) / 2, (wmin[2] + wmax[2]) / 2];
674
        var radius = Math.hypot(wmax[0] - wmin[0], wmax[1] - wmin[1], wmax[2] - wmin[2]) / 2 || 1;
675
676
        // Camera state: turntable (yaw/pitch) at `distance` around `target`.
677
        var yaw = -0.6, pitch = 0.5;
678
        var distance = radius / Math.sin(FOV / 2) * 1.15;
679
        var minPitch = -1.5533, maxPitch = 1.5533;
680
681
        function eye() {
682
            var cp = Math.cos(pitch), sp = Math.sin(pitch);
683
            var dir = [cp * Math.sin(yaw), sp, cp * Math.cos(yaw)];
684
            return vAdd(target, vScale(dir, distance));
685
        }
686
687
        var pending = false;
688
        function requestRender() {
689
            if (pending) { return; }
690
            pending = true;
691
            requestAnimationFrame(render);
692
        }
693
694
        function render() {
695
            pending = false;
696
            var w = canvas.width, h = canvas.height;
697
            gl.viewport(0, 0, w, h);
698
            var proj = mPerspective(FOV, w / h || 1, Math.max(0.01, distance - radius) * 0.5, distance + radius * 4);
699
            var view = mLookAt(eye(), target, [0, 1, 0]);
700
            renderer.draw(proj, view, model);
701
        }
702
703
        function resize() {
704
            var dpr = window.devicePixelRatio || 1;
705
            var w = Math.max(1, Math.round(container.clientWidth * dpr));
706
            var h = Math.max(1, Math.round(container.clientHeight * dpr));
707
            if (canvas.width !== w || canvas.height !== h) {
708
                canvas.width = w; canvas.height = h;
709
            }
710
            requestRender();
711
        }
712
713
        // ---- Orbit / zoom / pan controls ----
714
        var dragging = false, panning = false, lastX = 0, lastY = 0;
715
716
        canvas.addEventListener('pointerdown', function (ev) {
717
            dragging = true;
718
            panning = ev.shiftKey || ev.button === 2;
719
            lastX = ev.clientX; lastY = ev.clientY;
720
            canvas.setPointerCapture(ev.pointerId);
721
        });
722
        canvas.addEventListener('pointermove', function (ev) {
723
            if (!dragging) { return; }
724
            var dx = ev.clientX - lastX, dy = ev.clientY - lastY;
725
            lastX = ev.clientX; lastY = ev.clientY;
726
            if (panning) {
727
                var e = eye();
728
                var forward = vNorm(vSub(target, e));
729
                var right = vNorm(vCross(forward, [0, 1, 0]));
730
                var up = vCross(right, forward);
731
                var scale = distance / Math.max(1, container.clientHeight);
732
                target = vAdd(target, vScale(right, -dx * scale));
733
                target = vAdd(target, vScale(up, dy * scale));
734
            } else {
735
                yaw -= dx * 0.01;
736
                pitch += dy * 0.01;
737
                if (pitch < minPitch) { pitch = minPitch; }
738
                if (pitch > maxPitch) { pitch = maxPitch; }
739
            }
740
            requestRender();
741
        });
742
        function endDrag(ev) { dragging = false; if (ev.pointerId != null) { try { canvas.releasePointerCapture(ev.pointerId); } catch (x) {} } }
743
        canvas.addEventListener('pointerup', endDrag);
744
        canvas.addEventListener('pointercancel', endDrag);
745
        canvas.addEventListener('contextmenu', function (ev) { ev.preventDefault(); });
746
        canvas.addEventListener('wheel', function (ev) {
747
            ev.preventDefault();
748
            distance *= Math.exp(ev.deltaY * 0.0015);
749
            distance = Math.max(radius * 0.05, Math.min(radius * 50, distance));
750
            requestRender();
751
        }, { passive: false });
752
753
        if (typeof ResizeObserver !== 'undefined') {
754
            new ResizeObserver(resize).observe(container);
755
        } else {
756
            window.addEventListener('resize', resize);
757
        }
758
        resize();
759
    }
760
761
    /* ------------------------------------------------------------------ *
762
     * Bootstrap: load & render every .model-view on the page.
763
     * ------------------------------------------------------------------ */
764
765
    function fail(container, message) {
766
        var dl = container.getAttribute('data-download') || '';
767
        container.classList.add('model-error');
768
        container.innerHTML = '';
769
        var box = document.createElement('div');
770
        box.className = 'model-status';
771
        box.textContent = message || 'Could not display this 3D file.';
772
        container.appendChild(box);
773
        if (dl) {
774
            var a = document.createElement('a');
775
            a.className = 'btn-secondary';
776
            a.href = dl;
777
            a.textContent = 'Download file';
778
            container.appendChild(a);
779
        }
780
    }
781
782
    async function load(container) {
783
        var src = container.getAttribute('data-src');
784
        var format = container.getAttribute('data-format');
785
        try {
786
            var resp = await fetch(src);
787
            if (!resp.ok) { throw new Error('Could not fetch the file (HTTP ' + resp.status + ').'); }
788
            var buf = await resp.arrayBuffer();
789
            var geom = await parseModel(format, buf);
790
            initViewer(container, geom, format);
791
        } catch (err) {
792
            fail(container, (err && err.message) ? err.message : String(err));
793
        }
794
    }
795
796
    function start() {
797
        var nodes = document.querySelectorAll('.model-view[data-src]');
798
        for (var i = 0; i < nodes.length; i++) { load(nodes[i]); }
799
    }
800
801
    // Expose parsers for debugging / testing without touching browser behaviour.
802
    var api = {
803
        parseSTL: parseSTL, parseOBJ: parseOBJ, parsePLY: parsePLY,
804
        parseGcode: parseGcode, parse3MF: parse3MF, extractModelXml: extractModelXml
805
    };
806
    if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
807
808
    if (typeof document !== 'undefined') {
809
        if (document.readyState === 'loading') {
810
            document.addEventListener('DOMContentLoaded', start);
811
        } else {
812
            start();
813
        }
814
    }
815
})();
816