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