@omrips/viewstl 2.0.0-rc.4 → 2.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +19 -173
  2. package/build/chunks/archive-QK7XZSVT.js +1 -0
  3. package/build/{v2/chunks/cad-VY42CMP4.js → chunks/cad-QGVO6UGR.js} +1 -1
  4. package/build/chunks/chunk-6AW5U52H.js +1 -0
  5. package/build/{v2/chunks/chunk-R3HNTB5A.js → chunks/chunk-CVGQTUJ5.js} +2 -2
  6. package/build/chunks/chunk-FUXP2W57.js +1 -0
  7. package/build/chunks/chunk-LFTKNL2V.js +1 -0
  8. package/build/{v2/chunks/chunk-ZMK3UZZE.js → chunks/chunk-T6PPRDOE.js} +1 -1
  9. package/build/chunks/chunk-V6KYGP62.js +1 -0
  10. package/build/{v2/chunks/export-7OP4ZGGJ.js → chunks/export-2CFMTPJ3.js} +3 -3
  11. package/build/{v2/chunks/export-cad-QPBT756V.js → chunks/export-cad-XOXIE726.js} +1 -1
  12. package/build/{v2/chunks/export-zip-EDCWH5PX.js → chunks/export-zip-I2ADLFJI.js} +1 -1
  13. package/build/chunks/measure-3LDALPCN.js +1 -0
  14. package/build/chunks/print-check-WWHTT5PW.js +1 -0
  15. package/build/{v2/chunks/scene-export-KQBS7VRJ.js → chunks/scene-export-FPPL466I.js} +1 -1
  16. package/build/chunks/scene-reader-WXTRELY3.js +1 -0
  17. package/build/{v2/node → node}/analysis-worker.js +13 -12
  18. package/build/{v2/node → node}/index.cjs +7 -7
  19. package/build/{v2/node → node}/index.js +6 -6
  20. package/build/stl_viewer.min.js +51 -0
  21. package/build/{v2/viewstl.cjs → viewstl.cjs} +1641 -272
  22. package/build/{v2/viewstl.d.cts → viewstl.d.cts} +98 -0
  23. package/build/{v2/viewstl.d.ts → viewstl.d.ts} +98 -0
  24. package/build/{v2/viewstl.js → viewstl.js} +1641 -272
  25. package/build/viewstl.min.js +51 -0
  26. package/build/viewstl.worker.js +1 -0
  27. package/docs/releases/2.0.0-rc.6.md +7 -0
  28. package/docs/v2-compatibility.md +5 -5
  29. package/docs/v2-usage.md +152 -21
  30. package/docs/v2-verification.md +169 -0
  31. package/legacy/README.md +14 -8
  32. package/package.json +18 -35
  33. package/build/v2/README.md +0 -15
  34. package/build/v2/chunks/archive-VGS4J4Z7.js +0 -1
  35. package/build/v2/chunks/chunk-JH3H3JWA.js +0 -1
  36. package/build/v2/chunks/chunk-U33KANV7.js +0 -1
  37. package/build/v2/chunks/print-check-2PU7CSL3.js +0 -1
  38. package/build/v2/chunks/scene-reader-APUJOPUR.js +0 -1
  39. package/build/v2/stl_viewer.min.js +0 -51
  40. package/build/v2/viewstl.min.js +0 -51
  41. package/build/v2/viewstl.worker.js +0 -1
  42. /package/build/{v2/THIRD-PARTY-NOTICES.txt → THIRD-PARTY-NOTICES.txt} +0 -0
  43. /package/build/{v2/cad → cad}/occt-import-js.js +0 -0
  44. /package/build/{v2/cad → cad}/occt-import-js.source.txt +0 -0
  45. /package/build/{v2/cad → cad}/occt-import-js.wasm +0 -0
  46. /package/build/{v2/cad → cad}/prepare-cad.source.txt +0 -0
  47. /package/build/{v2/chunks/chunk-JS56KC3I.js → chunks/chunk-W5KVLGVR.js} +0 -0
  48. /package/build/{v2/chunks → chunks}/chunk-ZSV4PTIA.js +0 -0
  49. /package/build/{v2/node → node}/index.d.cts +0 -0
  50. /package/build/{v2/node → node}/index.d.ts +0 -0
@@ -16,7 +16,7 @@ var __copyProps = (to, from, except, desc) => {
16
16
  };
17
17
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
18
 
19
- // src/v2/commonjs.js
19
+ // src/commonjs.js
20
20
  var commonjs_exports = {};
21
21
  __export(commonjs_exports, {
22
22
  StlViewer: () => StlViewer,
@@ -25,55 +25,7 @@ __export(commonjs_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(commonjs_exports);
27
27
 
28
- // src/v2/appearance.js
29
- var DISPLAYS = [
30
- "flat",
31
- "smooth",
32
- "wireframe",
33
- "edges",
34
- "mesh",
35
- "xray",
36
- "studio",
37
- "orientation"
38
- ];
39
- function validateView(display, smooth) {
40
- if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
41
- if (typeof smooth !== "boolean")
42
- throw new Error("smooth_shading must be a boolean");
43
- return { display, smooth_shading: display === "smooth" || smooth };
44
- }
45
- function viewFeatures(view) {
46
- return {
47
- smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
48
- edges: view.view_edges || view.display === "edges",
49
- wire: view.display === "wireframe",
50
- edgeAngle: view.display === "edges" ? 30 : 1
51
- };
52
- }
53
- function validateSection(section) {
54
- if (section == null) return null;
55
- if (!["x", "y", "z"].includes(section.axis))
56
- throw new Error("Section axis must be x, y or z");
57
- if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
58
- throw new Error("Section position must be between 0 and 1");
59
- if (section.flip !== void 0 && typeof section.flip !== "boolean")
60
- throw new Error("Section flip must be a boolean");
61
- return {
62
- axis: section.axis,
63
- position: section.position,
64
- flip: section.flip ?? false
65
- };
66
- }
67
- function sectionPlane(bounds, section) {
68
- if (!section) return [0, 0, 0, 0];
69
- const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
70
- const plane = [0, 0, 0, 0];
71
- plane[axis] = sign;
72
- plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
73
- return plane;
74
- }
75
-
76
- // src/v2/math.js
28
+ // src/math.js
77
29
  var add = (a, b) => a.map((v, i) => v + b[i]);
78
30
  var sub = (a, b) => a.map((v, i) => v - b[i]);
79
31
  var mul = (a, s) => a.map((v) => v * s);
@@ -225,7 +177,178 @@ function rayTriangle(o, d, p, i) {
225
177
  return t >= 0 ? t : Infinity;
226
178
  }
227
179
 
228
- // src/v2/memory.js
180
+ // src/animation.js
181
+ var animationKeys = [
182
+ "x",
183
+ "y",
184
+ "z",
185
+ "rotationx",
186
+ "rotationy",
187
+ "rotationz",
188
+ "scalex",
189
+ "scaley",
190
+ "scalez"
191
+ ];
192
+ var isScale = (key) => key.startsWith("scale");
193
+ var number = (value, name, positive2 = false) => {
194
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
195
+ throw new Error(`Invalid animation ${name}`);
196
+ return value;
197
+ };
198
+ var object = (value, name) => {
199
+ if (!value || typeof value !== "object" || Array.isArray(value))
200
+ throw new Error(`Invalid animation ${name}`);
201
+ return value;
202
+ };
203
+ function animationPose(model) {
204
+ return Object.fromEntries(
205
+ animationKeys.map((key) => [
206
+ key,
207
+ number(
208
+ model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
209
+ key,
210
+ isScale(key)
211
+ )
212
+ ])
213
+ );
214
+ }
215
+ function normalizeAnimation(input) {
216
+ object(input, "definition");
217
+ const result = {}, claimed = /* @__PURE__ */ new Set();
218
+ for (const mode of ["delta", "exact"]) {
219
+ if (input[mode] == null) continue;
220
+ const source = object(input[mode], mode), values = {};
221
+ const msec = number(source.msec ?? 300, "duration", true);
222
+ if (source.loop !== void 0 && typeof source.loop !== "boolean")
223
+ throw new Error("Animation loop must be a boolean");
224
+ const loop = mode === "delta" && !!source.loop;
225
+ for (const key of animationKeys) {
226
+ const value = source[key] ?? (isScale(key) ? source.scale : void 0);
227
+ if (value === void 0) continue;
228
+ if (claimed.has(key))
229
+ throw new Error(`Animation delta/exact conflict: ${key}`);
230
+ number(value, key, mode === "exact" && isScale(key));
231
+ if (loop && isScale(key) && value < 0)
232
+ throw new Error("Looping scale deltas cannot be negative");
233
+ values[key] = value;
234
+ claimed.add(key);
235
+ }
236
+ if (!Object.keys(values).length)
237
+ throw new Error("Animation track needs a transform");
238
+ result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
239
+ }
240
+ if (!Object.keys(result).length)
241
+ throw new Error("Animation needs delta or exact");
242
+ return result;
243
+ }
244
+ function normalizePlaybackState(input) {
245
+ object(input, "playback state");
246
+ if (input.version !== 1 || typeof input.paused !== "boolean")
247
+ throw new Error("Invalid animation playback state version or paused flag");
248
+ const elapsed_ms = number(input.elapsed_ms, "elapsed time");
249
+ if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
250
+ object(input.origin, "origin");
251
+ for (const key of animationKeys) number(input.origin[key], key, isScale(key));
252
+ if (!Array.isArray(input.center) || input.center.length !== 3)
253
+ throw new Error("Invalid animation pivot");
254
+ return {
255
+ version: 1,
256
+ elapsed_ms,
257
+ paused: input.paused,
258
+ origin: animationPose(input.origin),
259
+ center: input.center.map((v) => number(v, "pivot"))
260
+ };
261
+ }
262
+ function createPlayback(definition, model, saved) {
263
+ const animation = normalizeAnimation(definition);
264
+ const state = saved == null ? null : normalizePlaybackState(saved);
265
+ const origin = state?.origin ?? animationPose(model);
266
+ const tracks = Object.entries(animation).map(([mode, a]) => {
267
+ const values = Object.entries(a).filter(
268
+ ([key]) => animationKeys.includes(key)
269
+ );
270
+ if (mode === "delta")
271
+ for (const [key, value] of values)
272
+ number(origin[key] + value, `${key} endpoint`, isScale(key));
273
+ return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
274
+ });
275
+ const playback = {
276
+ animation,
277
+ origin,
278
+ tracks,
279
+ elapsed_ms: state?.elapsed_ms ?? 0,
280
+ paused: state?.paused ?? false,
281
+ last_time: null
282
+ };
283
+ evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
284
+ return playback;
285
+ }
286
+ function evaluatePlayback(playback, model) {
287
+ const updates = [];
288
+ for (const track of playback.tracks) {
289
+ if (track.done) continue;
290
+ const fraction = playback.elapsed_ms / track.duration;
291
+ const p = track.loop ? fraction : Math.min(fraction, 1);
292
+ for (const [key, value] of track.values) {
293
+ const from = playback.origin[key];
294
+ const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
295
+ updates.push([key, number(to, `${key} result`, isScale(key))]);
296
+ }
297
+ }
298
+ let scaleChanged = false;
299
+ for (const [key, value] of updates) {
300
+ scaleChanged ||= isScale(key) && model[key] !== value;
301
+ model[key] = value;
302
+ }
303
+ for (const track of playback.tracks)
304
+ if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
305
+ if (scaleChanged) model._stats = null;
306
+ return playback.tracks.some((track) => !track.done);
307
+ }
308
+ function advancePlayback(playback, model, time) {
309
+ if (playback.paused) return true;
310
+ if (playback.last_time !== null)
311
+ playback.elapsed_ms = number(
312
+ playback.elapsed_ms + Math.max(0, time - playback.last_time),
313
+ "elapsed time"
314
+ );
315
+ playback.last_time = time;
316
+ return evaluatePlayback(playback, model);
317
+ }
318
+ function playbackState(playback, suspended = false) {
319
+ return {
320
+ status: playback.paused ? "paused" : "running",
321
+ elapsed_ms: playback.elapsed_ms,
322
+ suspended,
323
+ tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
324
+ mode: t.mode,
325
+ msec: t.duration,
326
+ loop: t.loop,
327
+ progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
328
+ }))
329
+ };
330
+ }
331
+ function savePlayback(model) {
332
+ const p = model._animation;
333
+ if (!p) return null;
334
+ const factor = model.unitScale ?? 1;
335
+ const animation = structuredClone(p.animation), origin = { ...p.origin };
336
+ for (const key of animationKeys.filter(isScale)) {
337
+ origin[key] *= factor;
338
+ for (const track of Object.values(animation))
339
+ if (track[key] !== void 0) track[key] *= factor;
340
+ }
341
+ const animation_state = normalizePlaybackState({
342
+ version: 1,
343
+ origin,
344
+ elapsed_ms: p.elapsed_ms,
345
+ paused: p.paused,
346
+ center: [...model.center]
347
+ });
348
+ return { animation: normalizeAnimation(animation), animation_state };
349
+ }
350
+
351
+ // src/memory.js
229
352
  var MEMORY_OVERHEAD = 64 * 1024 * 1024;
230
353
  var TRIANGLE_MEMORY = 224;
231
354
  function geometryMemory(g) {
@@ -242,7 +365,420 @@ function derivedMemory(triangles, features) {
242
365
  return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
243
366
  }
244
367
 
245
- // src/v2/geometry.js
368
+ // src/print-options.js
369
+ function printOptions(input = {}) {
370
+ const level = input.level ?? "basic";
371
+ if (!["basic", "detailed"].includes(level))
372
+ throw new Error("Choose basic or detailed print checks");
373
+ const number2 = (v, name, max = 1e9) => {
374
+ if (!Number.isFinite(v) || v <= 0 || v > max)
375
+ throw new Error(`Invalid ${name}`);
376
+ return v;
377
+ };
378
+ let profile = null;
379
+ if (input.profile != null) {
380
+ if (input.profile.technology !== "fdm")
381
+ throw new Error("Only FDM profiles are supported");
382
+ profile = { technology: "fdm" };
383
+ for (const key of ["min_wall_mm", "line_width_mm"])
384
+ if (input.profile[key] != null)
385
+ profile[key] = number2(input.profile[key], key);
386
+ if (input.profile.overhang_degrees != null)
387
+ profile.overhang_degrees = number2(
388
+ input.profile.overhang_degrees,
389
+ "overhang angle",
390
+ 89.9
391
+ );
392
+ if (input.profile.build_volume_mm != null) {
393
+ if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
394
+ throw new Error("Build volume requires X, Y and Z");
395
+ profile.build_volume_mm = input.profile.build_volume_mm.map(
396
+ (v) => number2(v, "build volume")
397
+ );
398
+ }
399
+ }
400
+ const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
401
+ return {
402
+ level,
403
+ profile,
404
+ apply_transforms: input.apply_transforms !== false,
405
+ units_confirmed: input.units_confirmed === true,
406
+ max_time_ms: time
407
+ };
408
+ }
409
+ var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
410
+ var printSignature = (model) => JSON.stringify([
411
+ model.x,
412
+ model.y,
413
+ model.z,
414
+ model.rotationx,
415
+ model.rotationy,
416
+ model.rotationz,
417
+ model.scaleVector,
418
+ model.center,
419
+ model.units,
420
+ model.unitScale
421
+ ]);
422
+
423
+ // src/model-tools.js
424
+ var axes = ["x", "y", "z"];
425
+ function validateUnits(units) {
426
+ if (!["mm", "inch"].includes(units)) throw new Error("Choose mm or inch");
427
+ return units;
428
+ }
429
+ function validateSizeState(value) {
430
+ if (!value || value.version !== 1 || !Array.isArray(value.baseline_mm) || value.baseline_mm.length !== 3 || value.baseline_mm.some((v) => !Number.isFinite(v) || v < 0 || v > 1e12) || typeof value.editable !== "boolean" || value.unit_factor !== void 0 && (!Number.isFinite(value.unit_factor) || value.unit_factor <= 0 || value.unit_factor > 1e12))
431
+ throw new Error("Invalid saved model size");
432
+ return {
433
+ version: 1,
434
+ baseline_mm: [...value.baseline_mm],
435
+ file_units: validateUnits(value.file_units),
436
+ editable: value.editable,
437
+ unit_factor: value.unit_factor ?? 1
438
+ };
439
+ }
440
+ function sizeState(model) {
441
+ return model.size_state ??= {
442
+ version: 1,
443
+ baseline_mm: model.geometry.bounds.max.map(
444
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.unitScale
445
+ ),
446
+ file_units: "mm",
447
+ editable: !model.geometry.units,
448
+ unit_factor: 1
449
+ };
450
+ }
451
+ function modelSize(model) {
452
+ if (!model.geometry) throw new Error("Wait for the model to finish opening");
453
+ const state = sizeState(model);
454
+ return {
455
+ ...structuredClone(state),
456
+ dimensions_mm: model.geometry.bounds.max.map(
457
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.scaleVector[i]
458
+ )
459
+ };
460
+ }
461
+ function resizeModel(id, factors) {
462
+ const m = this._model(id);
463
+ modelSize(m);
464
+ if (!Array.isArray(factors) || factors.length !== 3 || factors.some((v) => !Number.isFinite(v) || v <= 0))
465
+ throw new Error("Scale must be a positive finite number");
466
+ const next = axes.map((a, i) => m["scale" + a] * factors[i]);
467
+ if (next.some((v) => !Number.isFinite(v) || v < 1e-12 || v > 1e12))
468
+ throw new Error("Scale is outside the supported range");
469
+ const center = m.geometry.bounds.min.map(
470
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
471
+ );
472
+ const before = transform(modelMatrix(m), center);
473
+ const cached = m._measurements;
474
+ const oldKey = m.scaleVector.join(",");
475
+ this._stopAnimation(m, "manual");
476
+ axes.forEach((a, i) => m["scale" + a] = next[i]);
477
+ const after = transform(modelMatrix(m), center);
478
+ axes.forEach((a, i) => m[a] += before[i] - after[i]);
479
+ m._stats = null;
480
+ if (cached?.key === oldKey && cached.positions === m.geometry.positions && factors.every((v) => v === factors[0])) {
481
+ m._measurements = {
482
+ ...cached,
483
+ key: m.scaleVector.join(","),
484
+ result: {
485
+ ...cached.result,
486
+ area_mm2: cached.result.area_mm2 * factors[0] ** 2,
487
+ volume_mm3: cached.result.volume_mm3 == null ? null : cached.result.volume_mm3 * factors[0] ** 3
488
+ }
489
+ };
490
+ }
491
+ this._invalidate();
492
+ return modelSize(m);
493
+ }
494
+ function setFileUnits(id, units) {
495
+ validateUnits(units);
496
+ const m = this._model(id), state = sizeState(m);
497
+ if (!state.editable)
498
+ throw new Error(
499
+ "This model has explicit file units, already converted to millimetres"
500
+ );
501
+ if (state.file_units === units) return modelSize(m);
502
+ const factor = units === "inch" ? 25.4 : 1 / 25.4;
503
+ const result = resizeModel.call(this, id, [factor, factor, factor]);
504
+ state.baseline_mm = state.baseline_mm.map((v) => v * factor);
505
+ state.file_units = units;
506
+ state.unit_factor = (state.unit_factor ?? 1) * factor;
507
+ return { ...result, ...structuredClone(state) };
508
+ }
509
+ function resetModelSize(id) {
510
+ const size = modelSize(this._model(id));
511
+ return resizeModel.call(
512
+ this,
513
+ id,
514
+ size.dimensions_mm.map((v, i) => v ? size.baseline_mm[i] / v : 1)
515
+ );
516
+ }
517
+ function duplicateModel(id) {
518
+ const source = this._model(id);
519
+ modelSize(source);
520
+ if (source._sceneImportPending)
521
+ throw new Error("Wait for the scene to finish opening");
522
+ if (this._models.size + 1 > this.limits.max_archive_entries || [...this._models.values()].reduce(
523
+ (n, m) => n + (m.geometry?.triangles || 0),
524
+ source.geometry.triangles
525
+ ) > this.limits.max_triangles)
526
+ throw new Error("Scene model or triangle limit exceeded");
527
+ assertMemory(
528
+ this.limits,
529
+ this._occupiedMemory() + geometryMemory(source.geometry),
530
+ "duplicate"
531
+ );
532
+ const copy = {};
533
+ for (const key of [
534
+ "filename",
535
+ "orig_filename",
536
+ "local_file",
537
+ "x",
538
+ "y",
539
+ "z",
540
+ "rotationx",
541
+ "rotationy",
542
+ "rotationz",
543
+ "opacity",
544
+ "units",
545
+ "display",
546
+ "view_edges",
547
+ "smooth_shading",
548
+ "color"
549
+ ])
550
+ copy[key] = source[key];
551
+ copy.id = this.max_model_id + 1;
552
+ if (!Number.isSafeInteger(copy.id))
553
+ throw new Error("Model ID limit exceeded");
554
+ copy.display_name = (source.display_name || source.orig_filename || source.filename || "Model").split(/[\\/]/).pop() + " \u2014 copy";
555
+ copy.center = [...source.center];
556
+ copy.unitScale = 1;
557
+ axes.forEach((a, i) => copy["scale" + a] = source.scaleVector[i]);
558
+ Object.defineProperty(copy, "scaleVector", {
559
+ get: () => axes.map((a) => copy["scale" + a] * copy.unitScale)
560
+ });
561
+ copy.geometry = { ...source.geometry };
562
+ copy.section = source.section ? { ...source.section } : null;
563
+ copy.size_state = structuredClone(sizeState(source));
564
+ copy.size_state.unit_factor = (copy.size_state.unit_factor ?? 1) * source.unitScale;
565
+ const saved = savePlayback(source);
566
+ this.max_model_id = copy.id;
567
+ this._models.set(copy.id, copy);
568
+ this.models_ref[copy.id] = this.models.length;
569
+ this.models.push(copy);
570
+ this.models_count = this._models.size;
571
+ if (saved)
572
+ this._startAnimation(copy, saved.animation, {
573
+ ...saved.animation_state,
574
+ paused: true
575
+ });
576
+ this._invalidate();
577
+ return copy.id;
578
+ }
579
+ var cameraDirections = {
580
+ front: [
581
+ [0, -1, 0],
582
+ [0, 0, 1]
583
+ ],
584
+ back: [
585
+ [0, 1, 0],
586
+ [0, 0, 1]
587
+ ],
588
+ left: [
589
+ [-1, 0, 0],
590
+ [0, 0, 1]
591
+ ],
592
+ right: [
593
+ [1, 0, 0],
594
+ [0, 0, 1]
595
+ ],
596
+ top: [
597
+ [0, 0, 1],
598
+ [0, 1, 0]
599
+ ],
600
+ bottom: [
601
+ [0, 0, -1],
602
+ [0, -1, 0]
603
+ ],
604
+ isometric: [
605
+ [1, -1, 1],
606
+ [0, 0, 1]
607
+ ]
608
+ };
609
+ function cameraView(name) {
610
+ const view = cameraDirections[name];
611
+ if (!view) throw new Error("Unknown camera view");
612
+ this.camera.position = this.camera.target.map((v, i) => v + view[0][i]);
613
+ this.camera.up = [...view[1]];
614
+ this.set_auto_zoom();
615
+ }
616
+ function validateBookmarks(bookmarks) {
617
+ if (!Array.isArray(bookmarks) || bookmarks.length > 32)
618
+ throw new Error("Save up to 32 camera views");
619
+ const names = /* @__PURE__ */ new Set();
620
+ return bookmarks.map((b) => {
621
+ const name = typeof b?.name === "string" ? b.name.trim() : "";
622
+ if (!name || name.length > 80 || /[\x00-\x1f]/.test(name) || names.has(name))
623
+ throw new Error("Use a unique view name of 1\u201380 characters");
624
+ names.add(name);
625
+ const state = {};
626
+ for (const key of ["position", "target", "up"]) {
627
+ if (!b.camera?.[key] || axes.some(
628
+ (a) => !Number.isFinite(b.camera[key][a]) || Math.abs(b.camera[key][a]) > 1e15
629
+ ))
630
+ throw new Error("Invalid saved camera");
631
+ state[key] = Object.fromEntries(axes.map((a) => [a, b.camera[key][a]]));
632
+ }
633
+ const d = axes.map((a) => state.position[a] - state.target[a]), u = axes.map((a) => state.up[a]);
634
+ if (Math.hypot(
635
+ d[1] * u[2] - d[2] * u[1],
636
+ d[2] * u[0] - d[0] * u[2],
637
+ d[0] * u[1] - d[1] * u[0]
638
+ ) < 1e-12)
639
+ throw new Error("Degenerate saved camera");
640
+ return { name, camera: state };
641
+ });
642
+ }
643
+ function mergeBookmarks(existing, incoming) {
644
+ const result = validateBookmarks(existing), names = new Set(result.map((b) => b.name));
645
+ for (const b of validateBookmarks(incoming)) {
646
+ let name = b.name, n = 2;
647
+ while (names.has(name)) name = b.name.slice(0, 70) + ` (${n++})`;
648
+ names.add(name);
649
+ result.push({ ...b, name });
650
+ }
651
+ return validateBookmarks(result);
652
+ }
653
+ async function measureModel(id, options = {}) {
654
+ await this.when_ready;
655
+ const m = this._model(id), g = m.geometry;
656
+ if (!g || m._sceneImportPending)
657
+ throw new Error("Wait for the model to finish opening");
658
+ const scale = m.scaleVector, key = scale.join(","), cached = m._measurements;
659
+ if (options.signal?.aborted)
660
+ throw new DOMException("Measurement cancelled", "AbortError");
661
+ if (cached?.positions === g.positions && cached.key === key)
662
+ return { ...cached.result };
663
+ const working = printMemory(g.triangles), occupied = this._occupiedMemory();
664
+ assertMemory(this.limits, occupied + working, "measurements");
665
+ const release = this._reserveMemory(
666
+ m,
667
+ working + geometryMemory(g),
668
+ "measurements"
669
+ );
670
+ const controller = new AbortController(), cancel = () => controller.abort();
671
+ this._aborters.add(controller);
672
+ (m._measureJobs ??= /* @__PURE__ */ new Set()).add(controller);
673
+ options.signal?.addEventListener("abort", cancel, { once: true });
674
+ let job, callbackError;
675
+ const check = () => {
676
+ if (controller.signal.aborted || this.killsign)
677
+ throw new DOMException("Measurement cancelled", "AbortError");
678
+ if (this._models.get(id) !== m || m.geometry.positions !== g.positions || m.scaleVector.join(",") !== key)
679
+ throw new Error("Model size changed. Calculate again.");
680
+ };
681
+ try {
682
+ const positions = new Float32Array(g.positions.length);
683
+ for (let start = 0; start < positions.length; start += 262144) {
684
+ check();
685
+ positions.set(g.positions.subarray(start, start + 262144), start);
686
+ if (start + 262144 < positions.length)
687
+ await new Promise((r) => setTimeout(r, 0));
688
+ }
689
+ job = this._pool.run(
690
+ {
691
+ kind: "measure",
692
+ positions,
693
+ scale,
694
+ area: g.area,
695
+ max_time_ms: options.max_time_ms ?? 3e4,
696
+ limits: {
697
+ ...this.limits,
698
+ max_memory_bytes: this.limits.max_memory_bytes - occupied
699
+ }
700
+ },
701
+ (progress) => {
702
+ try {
703
+ options.on_progress?.(progress);
704
+ } catch (error) {
705
+ callbackError = error;
706
+ controller.abort();
707
+ }
708
+ },
709
+ [positions.buffer]
710
+ );
711
+ controller.signal.addEventListener("abort", () => job.cancel(), {
712
+ once: true
713
+ });
714
+ check();
715
+ let result;
716
+ try {
717
+ result = await job;
718
+ } catch (error) {
719
+ throw callbackError || error;
720
+ }
721
+ check();
722
+ m._measurements = { positions: g.positions, key, result };
723
+ return { ...result };
724
+ } finally {
725
+ job?.cancel?.();
726
+ release();
727
+ this._aborters.delete(controller);
728
+ m._measureJobs.delete(controller);
729
+ options.signal?.removeEventListener("abort", cancel);
730
+ }
731
+ }
732
+
733
+ // src/appearance.js
734
+ var DISPLAYS = [
735
+ "flat",
736
+ "smooth",
737
+ "wireframe",
738
+ "edges",
739
+ "mesh",
740
+ "xray",
741
+ "studio",
742
+ "orientation"
743
+ ];
744
+ function validateView(display, smooth) {
745
+ if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
746
+ if (typeof smooth !== "boolean")
747
+ throw new Error("smooth_shading must be a boolean");
748
+ return { display, smooth_shading: display === "smooth" || smooth };
749
+ }
750
+ function viewFeatures(view) {
751
+ return {
752
+ smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
753
+ edges: view.view_edges || view.display === "edges",
754
+ wire: view.display === "wireframe",
755
+ edgeAngle: view.display === "edges" ? 30 : 1
756
+ };
757
+ }
758
+ function validateSection(section) {
759
+ if (section == null) return null;
760
+ if (!["x", "y", "z"].includes(section.axis))
761
+ throw new Error("Section axis must be x, y or z");
762
+ if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
763
+ throw new Error("Section position must be between 0 and 1");
764
+ if (section.flip !== void 0 && typeof section.flip !== "boolean")
765
+ throw new Error("Section flip must be a boolean");
766
+ return {
767
+ axis: section.axis,
768
+ position: section.position,
769
+ flip: section.flip ?? false
770
+ };
771
+ }
772
+ function sectionPlane(bounds, section) {
773
+ if (!section) return [0, 0, 0, 0];
774
+ const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
775
+ const plane = [0, 0, 0, 0];
776
+ plane[axis] = sign;
777
+ plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
778
+ return plane;
779
+ }
780
+
781
+ // src/geometry.js
246
782
  var DEFAULT_LIMITS = Object.freeze({
247
783
  max_file_bytes: 256 * 1024 * 1024,
248
784
  max_triangles: 5e6,
@@ -390,7 +926,7 @@ function exportSTL(g, scale = [1, 1, 1], center = [0, 0, 0]) {
390
926
  return buffer;
391
927
  }
392
928
 
393
- // src/v2/infinite-grid.js
929
+ // src/infinite-grid.js
394
930
  var vertex = `#version 300 es
395
931
  precision highp float;
396
932
  out vec2 screen;
@@ -482,7 +1018,7 @@ var InfiniteGrid = class {
482
1018
  }
483
1019
  };
484
1020
 
485
- // src/v2/selection-frame.js
1021
+ // src/selection-frame.js
486
1022
  function selectionFrame(bounds) {
487
1023
  const padding = Math.max(
488
1024
  Math.max(...bounds.max.map((v, i) => v - bounds.min[i])) * 0.015,
@@ -500,7 +1036,7 @@ function selectionFrame(bounds) {
500
1036
  return new Float32Array(lines);
501
1037
  }
502
1038
 
503
- // src/v2/renderer.js
1039
+ // src/renderer.js
504
1040
  var vertex2 = `#version 300 es
505
1041
  precision highp float;
506
1042
  layout(location=0) in vec3 position;
@@ -536,6 +1072,7 @@ var Renderer = class {
536
1072
  this.canvas = canvas;
537
1073
  this.domElement = canvas;
538
1074
  this.resources = /* @__PURE__ */ new Map();
1075
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
539
1076
  this.frames = 0;
540
1077
  this.lost = false;
541
1078
  this.gl = canvas.getContext("webgl2", {
@@ -552,6 +1089,7 @@ var Renderer = class {
552
1089
  e.preventDefault();
553
1090
  this.lost = true;
554
1091
  this.resources.clear();
1092
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
555
1093
  this.gridResource = null;
556
1094
  this.infiniteGrid = null;
557
1095
  this.selectionResource = null;
@@ -659,7 +1197,12 @@ var Renderer = class {
659
1197
  if (this.diagnostic?.model === model) this.setDiagnostic(null);
660
1198
  const resources = this.resources.get(model);
661
1199
  if (resources)
662
- for (const resource of Object.values(resources)) this.free(resource);
1200
+ for (const resource of Object.values(resources)) {
1201
+ if (--resource.users === 0) {
1202
+ this.sharedResources.get(resource.source)?.delete(resource);
1203
+ this.free(resource);
1204
+ }
1205
+ }
663
1206
  this.resources.delete(model);
664
1207
  }
665
1208
  clear() {
@@ -673,8 +1216,10 @@ var Renderer = class {
673
1216
  }
674
1217
  get liveBuffers() {
675
1218
  let n = (this.gridResource?.buffers.length || 0) + (this.selectionResource?.buffers.length || 0) + (this.diagnosticResource?.buffers.length || 0);
1219
+ const unique = /* @__PURE__ */ new Set();
676
1220
  for (const r of this.resources.values())
677
- for (const b of Object.values(r)) n += b.buffers.length;
1221
+ for (const b of Object.values(r)) unique.add(b);
1222
+ for (const b of unique) n += b.buffers.length;
678
1223
  return n;
679
1224
  }
680
1225
  setDiagnostic(value) {
@@ -740,12 +1285,23 @@ var Renderer = class {
740
1285
  this.resources.set(model, r);
741
1286
  }
742
1287
  if (!r[mode]) {
743
- const g = model.geometry;
744
- r[mode] = mode === "edges" || mode === "wire" ? this.upload(g[mode]) : this.upload(
745
- g.positions,
746
- mode === "smooth" ? g.smoothNormals : g.normals,
747
- g.colors
1288
+ const g = model.geometry, lines = mode === "edges" || mode === "wire", source = lines ? g[mode] : g.positions, normals = lines ? void 0 : mode === "smooth" ? g.smoothNormals : g.normals, colors = lines ? void 0 : g.colors;
1289
+ let entries = this.sharedResources.get(source);
1290
+ if (!entries) this.sharedResources.set(source, entries = /* @__PURE__ */ new Set());
1291
+ let resource = [...entries].find(
1292
+ (r2) => r2.normals === normals && r2.colors === colors
748
1293
  );
1294
+ if (!resource) {
1295
+ resource = Object.assign(this.upload(source, normals, colors), {
1296
+ source,
1297
+ normals,
1298
+ colors,
1299
+ users: 0
1300
+ });
1301
+ entries.add(resource);
1302
+ }
1303
+ resource.users++;
1304
+ r[mode] = resource;
749
1305
  }
750
1306
  return r[mode];
751
1307
  }
@@ -921,7 +1477,7 @@ var Renderer = class {
921
1477
  }
922
1478
  };
923
1479
 
924
- // src/v2/controls.js
1480
+ // src/controls.js
925
1481
  var Controls = class {
926
1482
  constructor(canvas, camera, invalidate, pick, type = 0) {
927
1483
  this.enabled = true;
@@ -1032,7 +1588,7 @@ var Controls = class {
1032
1588
  }
1033
1589
  };
1034
1590
 
1035
- // src/v2/worker-pool.js
1591
+ // src/worker-pool.js
1036
1592
  var WorkerPool = class {
1037
1593
  constructor(url, { count = 2, timeout = 12e4, factory } = {}) {
1038
1594
  this.url = url;
@@ -1117,7 +1673,7 @@ var WorkerPool = class {
1117
1673
  }
1118
1674
  };
1119
1675
 
1120
- // src/v2/scene-import.js
1676
+ // src/scene-import.js
1121
1677
  var sceneFormat = (name) => String(name || "").match(/\.(vsb|vsj)(?:[?#]|$)/i)?.[1].toLowerCase() || "";
1122
1678
  function localSceneFile(reference, files) {
1123
1679
  let path;
@@ -1151,7 +1707,7 @@ function sceneURL(reference, base) {
1151
1707
  return url.href;
1152
1708
  }
1153
1709
 
1154
- // src/v2/scene-import-controller.js
1710
+ // src/scene-import-controller.js
1155
1711
  function importScene(source, options = {}) {
1156
1712
  const controller = new AbortController(), signal = controller.signal;
1157
1713
  const generation = this._generation, ids = [], added = /* @__PURE__ */ new Map(), usedFiles = /* @__PURE__ */ new Set();
@@ -1176,8 +1732,8 @@ function importScene(source, options = {}) {
1176
1732
  await this.when_ready;
1177
1733
  check();
1178
1734
  oldCamera = this.get_camera_state();
1179
- const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1180
- if (!["vsb", "vsj"].includes(format))
1735
+ const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1736
+ if (!["vsb", "vsj"].includes(format2))
1181
1737
  throw new Error("Choose VSB or VSJ for a scene");
1182
1738
  let remote, relay;
1183
1739
  if (typeof source === "string") {
@@ -1210,8 +1766,8 @@ function importScene(source, options = {}) {
1210
1766
  source,
1211
1767
  remote,
1212
1768
  relay,
1213
- format,
1214
- name: `scene.${format}`,
1769
+ format: format2,
1770
+ name: `scene.${format2}`,
1215
1771
  limits
1216
1772
  },
1217
1773
  (p) => {
@@ -1232,6 +1788,10 @@ function importScene(source, options = {}) {
1232
1788
  job = null;
1233
1789
  check();
1234
1790
  const { scene, files, base } = documentData;
1791
+ const bookmarks = mergeBookmarks(
1792
+ this.get_camera_bookmarks(),
1793
+ scene.camera_bookmarks || []
1794
+ );
1235
1795
  this._sceneBytes -= held;
1236
1796
  held = Object.values(files || {}).reduce((n, b) => n + b.byteLength, 0) + JSON.stringify(scene).length * 2;
1237
1797
  this._sceneBytes += held;
@@ -1266,7 +1826,7 @@ function importScene(source, options = {}) {
1266
1826
  progress({ phase: "opening-model", loaded: index, total: models.length });
1267
1827
  check();
1268
1828
  const input = models[index], id = this.max_model_id + 1;
1269
- this.add_model({ ...input, id });
1829
+ this.add_model({ ...input, id, animation: null, animation_state: null });
1270
1830
  const model = this._models.get(id);
1271
1831
  if (!model) throw new Error(this.error || "Invalid scene model");
1272
1832
  ids.push(id);
@@ -1291,7 +1851,9 @@ function importScene(source, options = {}) {
1291
1851
  }),
1292
1852
  delta
1293
1853
  );
1294
- ["x", "y", "z"].forEach((axis, i) => model[axis] += shift[i]);
1854
+ if (input.animation)
1855
+ model.center = [...input.animation_state?.center ?? desired];
1856
+ else ["x", "y", "z"].forEach((axis, i) => model[axis] += shift[i]);
1295
1857
  if ([...this._models.values()].reduce(
1296
1858
  (n, m) => n + (m.geometry?.triangles || 0),
1297
1859
  0
@@ -1304,7 +1866,12 @@ function importScene(source, options = {}) {
1304
1866
  total: models.length
1305
1867
  });
1306
1868
  check();
1307
- for (const model of added.values()) model._sceneImportPending = false;
1869
+ for (let index = 0; index < ids.length; index++) {
1870
+ const model = added.get(ids[index]), input = models[index];
1871
+ if (input.animation)
1872
+ this._startAnimation(model, input.animation, input.animation_state);
1873
+ model._sceneImportPending = false;
1874
+ }
1308
1875
  if (scene.bg_color !== void 0) this.set_bg_color(scene.bg_color);
1309
1876
  if (scene.infinite_grid)
1310
1877
  this.set_infinite_grid(
@@ -1316,6 +1883,8 @@ function importScene(source, options = {}) {
1316
1883
  if (scene.auto_rotate !== void 0)
1317
1884
  this.set_auto_rotate(scene.auto_rotate);
1318
1885
  this._restoreCamera = scene.camera_state || (hadModels ? oldCamera : null);
1886
+ this.camera_bookmarks = bookmarks;
1887
+ if (scene.display_units) this.display_units = scene.display_units;
1319
1888
  committed = true;
1320
1889
  return { model_ids: ids, scene, used_files: [...usedFiles] };
1321
1890
  })().catch((error) => {
@@ -1336,62 +1905,7 @@ function importScene(source, options = {}) {
1336
1905
  return promise;
1337
1906
  }
1338
1907
 
1339
- // src/v2/print-options.js
1340
- function printOptions(input = {}) {
1341
- const level = input.level ?? "basic";
1342
- if (!["basic", "detailed"].includes(level))
1343
- throw new Error("Choose basic or detailed print checks");
1344
- const number = (v, name, max = 1e9) => {
1345
- if (!Number.isFinite(v) || v <= 0 || v > max)
1346
- throw new Error(`Invalid ${name}`);
1347
- return v;
1348
- };
1349
- let profile = null;
1350
- if (input.profile != null) {
1351
- if (input.profile.technology !== "fdm")
1352
- throw new Error("Only FDM profiles are supported");
1353
- profile = { technology: "fdm" };
1354
- for (const key of ["min_wall_mm", "line_width_mm"])
1355
- if (input.profile[key] != null)
1356
- profile[key] = number(input.profile[key], key);
1357
- if (input.profile.overhang_degrees != null)
1358
- profile.overhang_degrees = number(
1359
- input.profile.overhang_degrees,
1360
- "overhang angle",
1361
- 89.9
1362
- );
1363
- if (input.profile.build_volume_mm != null) {
1364
- if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
1365
- throw new Error("Build volume requires X, Y and Z");
1366
- profile.build_volume_mm = input.profile.build_volume_mm.map(
1367
- (v) => number(v, "build volume")
1368
- );
1369
- }
1370
- }
1371
- const time = number(input.max_time_ms ?? 3e4, "check time limit", 12e4);
1372
- return {
1373
- level,
1374
- profile,
1375
- apply_transforms: input.apply_transforms !== false,
1376
- units_confirmed: input.units_confirmed === true,
1377
- max_time_ms: time
1378
- };
1379
- }
1380
- var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
1381
- var printSignature = (model) => JSON.stringify([
1382
- model.x,
1383
- model.y,
1384
- model.z,
1385
- model.rotationx,
1386
- model.rotationy,
1387
- model.rotationz,
1388
- model.scaleVector,
1389
- model.center,
1390
- model.units,
1391
- model.unitScale
1392
- ]);
1393
-
1394
- // src/v2/print-controller.js
1908
+ // src/print-controller.js
1395
1909
  var reports = /* @__PURE__ */ new WeakMap();
1396
1910
  function reportCurrent(viewer, report) {
1397
1911
  const saved = reports.get(report);
@@ -1488,7 +2002,9 @@ async function checkPrintability(options = {}) {
1488
2002
  rotationy: 0,
1489
2003
  rotationz: 0,
1490
2004
  center: [0, 0, 0],
1491
- scaleVector: [model.unitScale, model.unitScale, model.unitScale]
2005
+ scaleVector: [1, 1, 1].map(
2006
+ () => model.unitScale * (model.size_state?.unit_factor ?? 1)
2007
+ )
1492
2008
  };
1493
2009
  progress({
1494
2010
  phase: "Starting",
@@ -1604,7 +2120,7 @@ function showPrintability(report, { issue, model_id = report.model_ids[0] } = {}
1604
2120
  return lines.length / 6;
1605
2121
  }
1606
2122
 
1607
- // src/v2/export-options.js
2123
+ // src/export-options.js
1608
2124
  function exportFilename(filename, extension) {
1609
2125
  const name = String(filename || "scene").split(/[\\/]/).pop().split(/[?#]/)[0].replace(/[<>:"|*\x00-\x1f\x7f]/g, "_").replace(/\.(?:stl|obj|3mf|step|stp|iges|igs|zip|vsb|vsj)$/i, "").replace(/[. ]+$/g, "").slice(0, 160) || "scene";
1610
2126
  return `${name}.${extension}`;
@@ -1612,8 +2128,8 @@ function exportFilename(filename, extension) {
1612
2128
  function exportOptions(options = {}) {
1613
2129
  const aliases = { stp: "step", igs: "iges" };
1614
2130
  const requested = String(options.format || "").toLowerCase();
1615
- const format = aliases[requested] || requested;
1616
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2131
+ const format2 = aliases[requested] || requested;
2132
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
1617
2133
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
1618
2134
  const units = options.units ?? "mm";
1619
2135
  if (!["mm", "inch"].includes(units))
@@ -1623,10 +2139,10 @@ function exportOptions(options = {}) {
1623
2139
  throw new Error(`${key} must be a boolean`);
1624
2140
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
1625
2141
  throw new Error("on_progress must be a function");
1626
- const colors = options.colors !== false && ["obj", "3mf"].includes(format);
1627
- const extension = format === "obj" && colors ? "zip" : format;
2142
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2143
+ const extension = format2 === "obj" && colors ? "zip" : format2;
1628
2144
  return {
1629
- format,
2145
+ format: format2,
1630
2146
  units,
1631
2147
  colors,
1632
2148
  apply_transforms: options.apply_transforms !== false,
@@ -1645,7 +2161,7 @@ function exportMemory(models, options) {
1645
2161
  return MEMORY_OVERHEAD + triangles * perTriangle[options.format] + models.length * 4096;
1646
2162
  }
1647
2163
 
1648
- // src/v2/io.js
2164
+ // src/io.js
1649
2165
  async function readSource(source, limit, progress = () => {
1650
2166
  }, signal) {
1651
2167
  let stream, total = 0;
@@ -1707,7 +2223,7 @@ async function readSource(source, limit, progress = () => {
1707
2223
  return data.buffer;
1708
2224
  }
1709
2225
 
1710
- // src/v2/three-bridge.js
2226
+ // src/three-bridge.js
1711
2227
  function fromThreeMesh(mesh, limits) {
1712
2228
  if (!mesh?.isMesh && !mesh?.geometry)
1713
2229
  throw new Error("Expected a Three.js mesh");
@@ -1780,7 +2296,7 @@ function toThreeMesh(model, THREE) {
1780
2296
  return mesh;
1781
2297
  }
1782
2298
 
1783
- // src/v2/viewer.js
2299
+ // src/viewer.js
1784
2300
  var transformKeys = [
1785
2301
  "x",
1786
2302
  "y",
@@ -1818,7 +2334,10 @@ var modelKeys = [
1818
2334
  "smooth_shading",
1819
2335
  "section",
1820
2336
  "units",
1821
- "animation"
2337
+ "animation",
2338
+ "animation_state",
2339
+ "size_state",
2340
+ "display_name"
1822
2341
  ];
1823
2342
  var finite = (v, name) => {
1824
2343
  if (typeof v !== "number" || !Number.isFinite(v))
@@ -1850,7 +2369,7 @@ function modelOptions(input) {
1850
2369
  return out;
1851
2370
  }
1852
2371
  var StlViewer = class _StlViewer {
1853
- static version = "2.0.0-rc.4";
2372
+ static version = "2.0.0-rc.6";
1854
2373
  static workerURL = null;
1855
2374
  constructor(parent, options = {}) {
1856
2375
  if (!parent?.appendChild)
@@ -1879,6 +2398,8 @@ var StlViewer = class _StlViewer {
1879
2398
  this._sessionActive = false;
1880
2399
  this._frame = 0;
1881
2400
  this._lastTime = 0;
2401
+ this.camera_bookmarks = [];
2402
+ this.display_units = "mm";
1882
2403
  this.grid = null;
1883
2404
  this._gridAuto = false;
1884
2405
  this.camera = {
@@ -1900,7 +2421,9 @@ var StlViewer = class _StlViewer {
1900
2421
  "model_loaded_callback",
1901
2422
  "load_error_callback",
1902
2423
  "loading_progress_callback",
1903
- "on_model_drop"
2424
+ "on_model_drop",
2425
+ "animation_state_callback",
2426
+ "animation_complete_callback"
1904
2427
  ])
1905
2428
  this[name] = options[name] ?? null;
1906
2429
  this.onmousedown_callback = options.on_model_mousedown ?? options.on_model_mouseclick ?? null;
@@ -1939,11 +2462,15 @@ var StlViewer = class _StlViewer {
1939
2462
  this.parent_element.appendChild(this.canvas);
1940
2463
  this.renderer = new Renderer(
1941
2464
  this.canvas,
1942
- () => this._message(
1943
- "Graphics interrupted. Waiting for the browser to restore rendering\u2026"
1944
- ),
2465
+ () => {
2466
+ this._suspendAnimations(true);
2467
+ this._message(
2468
+ "Graphics interrupted. Waiting for the browser to restore rendering\u2026"
2469
+ );
2470
+ },
1945
2471
  () => {
1946
2472
  this._removeMessage();
2473
+ this._suspendAnimations(this.canvas.ownerDocument.hidden);
1947
2474
  this._invalidate();
1948
2475
  },
1949
2476
  (e) => this._error(e)
@@ -2008,6 +2535,14 @@ var StlViewer = class _StlViewer {
2008
2535
  }
2009
2536
  };
2010
2537
  this.canvas.addEventListener("keydown", this._key);
2538
+ this._visibility = () => this._suspendAnimations(
2539
+ this.canvas.ownerDocument.hidden || this.renderer.lost
2540
+ );
2541
+ this.canvas.ownerDocument.addEventListener(
2542
+ "visibilitychange",
2543
+ this._visibility
2544
+ );
2545
+ this._animationSuspended = this.canvas.ownerDocument.hidden;
2011
2546
  this.ready = true;
2012
2547
  this._readyResolve(this);
2013
2548
  this._callback("ready_callback");
@@ -2022,6 +2557,11 @@ var StlViewer = class _StlViewer {
2022
2557
  this.controls?.dispose();
2023
2558
  this.renderer?.dispose();
2024
2559
  this._resizeObserver?.disconnect();
2560
+ if (this._visibility)
2561
+ this.canvas.ownerDocument.removeEventListener(
2562
+ "visibilitychange",
2563
+ this._visibility
2564
+ );
2025
2565
  }
2026
2566
  }
2027
2567
  _callback(name, ...args) {
@@ -2070,7 +2610,7 @@ var StlViewer = class _StlViewer {
2070
2610
  const diagnostic = this.renderer?.diagnostic;
2071
2611
  if (diagnostic && printSignature(diagnostic.model) !== diagnostic.signature)
2072
2612
  this.renderer.setDiagnostic(null);
2073
- if (this.killsign || !this.renderer || this.renderer.lost || this._frame)
2613
+ if (this.killsign || !this.renderer || this.renderer.lost || this._frame || this._animationSuspended)
2074
2614
  return;
2075
2615
  this._frame = requestAnimationFrame((t) => {
2076
2616
  this._frame = 0;
@@ -2082,12 +2622,15 @@ var StlViewer = class _StlViewer {
2082
2622
  this._lastTime = time;
2083
2623
  let animated = false;
2084
2624
  for (const m of this._models.values())
2085
- if (m.geometry && m._animation) {
2625
+ if (m.geometry && !m._sceneImportPending && m._animation && !m._animation.paused) {
2086
2626
  this._animate(m, time);
2087
- animated ||= !!m._animation;
2627
+ animated ||= !!m._animation && !m._animation.paused;
2088
2628
  }
2089
2629
  if (this.auto_rotate && this.controls.enabled)
2090
2630
  this.controls.orbit(elapsed * 0.3, 0);
2631
+ const diagnostic = this.renderer.diagnostic;
2632
+ if (diagnostic && printSignature(diagnostic.model) !== diagnostic.signature)
2633
+ this.renderer.setDiagnostic(null);
2091
2634
  this._clip();
2092
2635
  try {
2093
2636
  this.renderer.draw(
@@ -2193,6 +2736,13 @@ var StlViewer = class _StlViewer {
2193
2736
  finite(m[key], key);
2194
2737
  if (key.startsWith("scale")) positive(m[key], key);
2195
2738
  }
2739
+ if (supplied.size_state)
2740
+ m.size_state = validateSizeState(supplied.size_state);
2741
+ if (supplied.display_name != null) {
2742
+ if (typeof supplied.display_name !== "string" || supplied.display_name.length > 1024)
2743
+ throw new Error("Invalid model name");
2744
+ m.display_name = supplied.display_name;
2745
+ }
2196
2746
  m.opacity = supplied.opacity ?? supplied.mesh?.material?.opacity ?? 1;
2197
2747
  finite(m.opacity, "opacity");
2198
2748
  if (m.opacity < 0 || m.opacity > 1)
@@ -2211,6 +2761,11 @@ var StlViewer = class _StlViewer {
2211
2761
  m.color = supplied.color ?? (supplied.mesh?.material?.color?.getHexString ? "#" + supplied.mesh.material.color.getHexString() : null);
2212
2762
  if (m.color && m.color !== "transparent") colorRGB(m.color);
2213
2763
  m.view_edges = !!supplied.view_edges;
2764
+ m.animation = supplied.animation == null ? null : normalizeAnimation(supplied.animation);
2765
+ m.animation_state = supplied.animation_state == null ? null : normalizePlaybackState(supplied.animation_state);
2766
+ if (m.animation_state && !m.animation)
2767
+ throw new Error("Playback state needs an animation");
2768
+ if (m.animation) createPlayback(m.animation, m, m.animation_state);
2214
2769
  Object.defineProperty(m, "scaleVector", {
2215
2770
  get: () => [
2216
2771
  m.scalex * m.unitScale,
@@ -2339,7 +2894,7 @@ var StlViewer = class _StlViewer {
2339
2894
  format: supplied.remote_format,
2340
2895
  relay,
2341
2896
  source: typeof source === "string" ? new URL(source, document.baseURI).href : source,
2342
- name: m.filename,
2897
+ name: m.display_name || m.filename,
2343
2898
  limits,
2344
2899
  features: features.smooth || features.edges || features.wire ? features : null
2345
2900
  },
@@ -2363,7 +2918,10 @@ var StlViewer = class _StlViewer {
2363
2918
  );
2364
2919
  if ((supplied.mesh || supplied.data) && (features.smooth || features.edges || features.wire))
2365
2920
  await this._derive(m, features);
2366
- if (supplied.animation) this.animate_model(m.id, supplied.animation);
2921
+ if (m.animation_state) m.center = [...m.animation_state.center];
2922
+ if (m.animation && !m._animation)
2923
+ this._startAnimation(m, m.animation, m.animation_state);
2924
+ delete m.animation_state;
2367
2925
  delete m.mesh;
2368
2926
  delete m.data;
2369
2927
  this._invalidate();
@@ -2385,6 +2943,8 @@ var StlViewer = class _StlViewer {
2385
2943
  this._invalidate();
2386
2944
  }
2387
2945
  _deleteModel(m) {
2946
+ this._stopAnimation(m, "removed");
2947
+ for (const controller of m._measureJobs || []) controller.abort();
2388
2948
  for (const [controller, models] of this._printJobs || [])
2389
2949
  if (models.has(m)) controller.abort();
2390
2950
  this._printCache = null;
@@ -2412,9 +2972,12 @@ var StlViewer = class _StlViewer {
2412
2972
  if (this.killsign) return;
2413
2973
  this._generation++;
2414
2974
  this._printCache = null;
2975
+ this.camera_bookmarks = [];
2976
+ this.display_units = "mm";
2415
2977
  this._pool?.clear();
2416
2978
  for (const c of this._aborters) c.abort();
2417
2979
  this._aborters.clear();
2980
+ for (const m of this._models.values()) this._stopAnimation(m, "clean");
2418
2981
  this._models.clear();
2419
2982
  this._pending.clear();
2420
2983
  this._jobs.clear();
@@ -2446,6 +3009,11 @@ var StlViewer = class _StlViewer {
2446
3009
  window.removeEventListener("resize", this._windowResize);
2447
3010
  this.renderer?.dispose();
2448
3011
  if (this.canvas) {
3012
+ if (this._visibility)
3013
+ this.canvas.ownerDocument.removeEventListener(
3014
+ "visibilitychange",
3015
+ this._visibility
3016
+ );
2449
3017
  this.canvas.removeEventListener("drop", this._drop);
2450
3018
  this.canvas.removeEventListener("dragover", this._drag);
2451
3019
  this.canvas.removeEventListener("keydown", this._key);
@@ -2584,6 +3152,10 @@ var StlViewer = class _StlViewer {
2584
3152
  set_position(id, x, y, z) {
2585
3153
  return this._guard(() => {
2586
3154
  const m = this._model(id);
3155
+ [x, y, z].forEach((v) => {
3156
+ if (v !== void 0) finite(v, "position");
3157
+ });
3158
+ this._stopAnimation(m, "manual");
2587
3159
  [x, y, z].forEach((v, i) => {
2588
3160
  if (v !== void 0) m[["x", "y", "z"][i]] = finite(v, "position");
2589
3161
  });
@@ -2597,8 +3169,12 @@ var StlViewer = class _StlViewer {
2597
3169
  if (keepPrevious) {
2598
3170
  if (x / m.scalex !== y / m.scaley || x / m.scalex !== z / m.scalez)
2599
3171
  throw new Error("keep_prev_scale requires a uniform geometry factor");
3172
+ this._stopAnimation(m, "manual");
2600
3173
  m.unitScale *= x / m.scalex;
2601
- } else [m.scalex, m.scaley, m.scalez] = [x, y, z];
3174
+ } else {
3175
+ this._stopAnimation(m, "manual");
3176
+ [m.scalex, m.scaley, m.scalez] = [x, y, z];
3177
+ }
2602
3178
  m._stats = null;
2603
3179
  this._invalidate();
2604
3180
  });
@@ -2612,6 +3188,10 @@ var StlViewer = class _StlViewer {
2612
3188
  set_rotation(id, x, y, z, increment = false) {
2613
3189
  return this._guard(() => {
2614
3190
  const m = this._model(id);
3191
+ [x, y, z].forEach((v) => {
3192
+ if (v !== void 0) finite(v, "rotation");
3193
+ });
3194
+ this._stopAnimation(m, "manual");
2615
3195
  [x, y, z].forEach((v, i) => {
2616
3196
  const k = ["rotationx", "rotationy", "rotationz"][i];
2617
3197
  if (v !== void 0)
@@ -2665,6 +3245,7 @@ var StlViewer = class _StlViewer {
2665
3245
  if (!["mm", "inch"].includes(units))
2666
3246
  throw new Error("Units must be mm or inch");
2667
3247
  const m = this._model(id);
3248
+ this._stopAnimation(m, "manual");
2668
3249
  if (scale && m.units !== units)
2669
3250
  m.unitScale *= units === "inch" ? 25.4 : 1 / 25.4;
2670
3251
  m.units = units;
@@ -2832,12 +3413,42 @@ var StlViewer = class _StlViewer {
2832
3413
  type
2833
3414
  );
2834
3415
  }
2835
- get_model_info(id) {
3416
+ get_model_size(id) {
3417
+ return modelSize(this._model(id));
3418
+ }
3419
+ resize_model(id, factors) {
3420
+ return resizeModel.call(this, id, factors);
3421
+ }
3422
+ set_file_units(id, units) {
3423
+ return setFileUnits.call(this, id, units);
3424
+ }
3425
+ reset_model_size(id) {
3426
+ return resetModelSize.call(this, id);
3427
+ }
3428
+ duplicate_model(id) {
3429
+ return duplicateModel.call(this, id);
3430
+ }
3431
+ set_camera_view(name) {
3432
+ return cameraView.call(this, name);
3433
+ }
3434
+ set_display_units(units) {
3435
+ this.display_units = validateUnits(units);
3436
+ }
3437
+ get_camera_bookmarks() {
3438
+ return structuredClone(this.camera_bookmarks || []);
3439
+ }
3440
+ set_camera_bookmarks(bookmarks) {
3441
+ this.camera_bookmarks = validateBookmarks(bookmarks);
3442
+ }
3443
+ measure_model(id, options) {
3444
+ return measureModel.call(this, id, options);
3445
+ }
3446
+ get_model_info(id, options = {}) {
2836
3447
  return this._guard(() => {
2837
3448
  const m = this._model(id), g = m.geometry;
2838
3449
  if (!g) return null;
2839
3450
  const s = m.scaleVector, key = s.join(",");
2840
- if (!m._stats || m._stats.key !== key)
3451
+ if (options.measurements !== false && (!m._stats || m._stats.key !== key))
2841
3452
  m._stats = { key, ...scaledStats(g, s) };
2842
3453
  const factor = m.units === "inch" ? 1 / 25.4 : 1;
2843
3454
  return {
@@ -2853,8 +3464,8 @@ var StlViewer = class _StlViewer {
2853
3464
  section: m.section ? { ...m.section } : null,
2854
3465
  color: m.color ?? (g.colors ? "#ffffff" : "#909090"),
2855
3466
  scale: { x: m.scalex, y: m.scaley, z: m.scalez },
2856
- volume: m._stats.volume * factor ** 3,
2857
- area: m._stats.area * factor ** 2,
3467
+ volume: options.measurements === false ? null : m._stats.volume * factor ** 3,
3468
+ area: options.measurements === false ? null : m._stats.area * factor ** 2,
2858
3469
  triangles: g.triangles,
2859
3470
  bounds: { min: [...g.bounds.min], max: [...g.bounds.max] },
2860
3471
  has_colors: !!g.colors,
@@ -2942,7 +3553,9 @@ var StlViewer = class _StlViewer {
2942
3553
  rotationx: 0,
2943
3554
  rotationy: 0,
2944
3555
  rotationz: 0,
2945
- scaleVector: [m.unitScale, m.unitScale, m.unitScale]
3556
+ scaleVector: [1, 1, 1].map(
3557
+ () => m.unitScale * (m.size_state?.unit_factor ?? 1)
3558
+ )
2946
3559
  };
2947
3560
  const matrix = modelMatrix(source), factor = settings.units === "inch" ? 1 / 25.4 : 1;
2948
3561
  for (let c = 0; c < 4; c++)
@@ -3010,8 +3623,8 @@ var StlViewer = class _StlViewer {
3010
3623
  );
3011
3624
  }
3012
3625
  async export_scene(options = {}) {
3013
- const format = String(options.format || "").toLowerCase();
3014
- if (!["vsb", "vsj"].includes(format))
3626
+ const format2 = String(options.format || "").toLowerCase();
3627
+ if (!["vsb", "vsj"].includes(format2))
3015
3628
  throw new Error("Scene format must be vsb or vsj");
3016
3629
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
3017
3630
  throw new Error("force_basename must be a boolean");
@@ -3034,7 +3647,7 @@ var StlViewer = class _StlViewer {
3034
3647
  if (!models.length) throw new Error("No models to export");
3035
3648
  if (models.some((m) => !m.geometry))
3036
3649
  throw new Error("A model has not finished loading");
3037
- if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3650
+ if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3038
3651
  throw new Error("Scene model count limit exceeded");
3039
3652
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3040
3653
  if (triangles > this.limits.max_triangles)
@@ -3042,18 +3655,26 @@ var StlViewer = class _StlViewer {
3042
3655
  const scene = this.get_vsj(
3043
3656
  true,
3044
3657
  !!options.force_basename,
3045
- format === "vsb"
3658
+ format2 === "vsb"
3046
3659
  );
3047
3660
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3048
3661
  if (manifestBytes > this.limits.max_file_bytes)
3049
3662
  throw new Error("Scene manifest size limit exceeded");
3050
- if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3663
+ if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3051
3664
  throw new Error("Scene archive limit exceeded");
3052
- const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3665
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3053
3666
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3054
3667
  this._exportBytes += working;
3055
3668
  let job, cancel, callbackError;
3056
3669
  try {
3670
+ const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3671
+ id: m.id,
3672
+ triangles: m.geometry.triangles,
3673
+ positions: m.geometry.positions.slice(),
3674
+ colors: m.geometry.colors?.slice() || null,
3675
+ scale: scene.models[index].animation_state ? [1, 1, 1] : m.scaleVector,
3676
+ center: scene.models[index].animation_state ? [0, 0, 0] : [...m.center]
3677
+ })) : null;
3057
3678
  options.on_progress?.({
3058
3679
  loaded: 0,
3059
3680
  total: models.length,
@@ -3061,7 +3682,7 @@ var StlViewer = class _StlViewer {
3061
3682
  });
3062
3683
  checkAbort();
3063
3684
  let blob;
3064
- if (format === "vsj") {
3685
+ if (format2 === "vsj") {
3065
3686
  blob = new Blob([json], { type: "application/json" });
3066
3687
  options.on_progress?.({
3067
3688
  loaded: models.length,
@@ -3069,14 +3690,6 @@ var StlViewer = class _StlViewer {
3069
3690
  phase: "complete"
3070
3691
  });
3071
3692
  } else {
3072
- const snapshot = models.map((m) => ({
3073
- id: m.id,
3074
- triangles: m.geometry.triangles,
3075
- positions: m.geometry.positions.slice(),
3076
- colors: m.geometry.colors?.slice() || null,
3077
- scale: m.scaleVector,
3078
- center: [...m.center]
3079
- }));
3080
3693
  job = this._pool.run(
3081
3694
  {
3082
3695
  kind: "export-scene",
@@ -3109,8 +3722,8 @@ var StlViewer = class _StlViewer {
3109
3722
  checkAbort();
3110
3723
  return {
3111
3724
  blob,
3112
- format,
3113
- filename: exportFilename(options.filename, format),
3725
+ format: format2,
3726
+ filename: exportFilename(options.filename, format2),
3114
3727
  model_ids: models.map((m) => m.id),
3115
3728
  triangles,
3116
3729
  representation: "scene"
@@ -3132,6 +3745,8 @@ var StlViewer = class _StlViewer {
3132
3745
  canvas_height: this.canvas_height,
3133
3746
  bg_color: this.bg_color,
3134
3747
  camera_state: this.get_camera_state(),
3748
+ camera_bookmarks: this.get_camera_bookmarks(),
3749
+ display_units: this.display_units || "mm",
3135
3750
  auto_rotate: this.auto_rotate,
3136
3751
  mouse_zoom: this.mouse_zoom,
3137
3752
  auto_resize: this.auto_resize,
@@ -3152,6 +3767,11 @@ var StlViewer = class _StlViewer {
3152
3767
  id: for_vsb ? -1 : m.id,
3153
3768
  filename: for_vsb ? `${m.id}.stl` : force_basename ? basename(m.filename) : m.filename,
3154
3769
  units: m.units,
3770
+ size_state: {
3771
+ ...structuredClone(sizeState(m)),
3772
+ unit_factor: for_vsb && !m._animation ? 1 : m.unitScale * (sizeState(m).unit_factor ?? 1)
3773
+ },
3774
+ ...m.display_name ? { display_name: m.display_name } : {},
3155
3775
  display: m.display,
3156
3776
  opacity: m.opacity,
3157
3777
  view_edges: m.view_edges,
@@ -3162,13 +3782,21 @@ var StlViewer = class _StlViewer {
3162
3782
  model.orig_filename = basename(m.orig_filename || m.filename);
3163
3783
  for (const key of transformKeys)
3164
3784
  if (!key.startsWith("scale")) model[key] = m[key];
3165
- if (!for_vsb) {
3785
+ if (!for_vsb || m._animation) {
3166
3786
  model.scalex = m.scalex * m.unitScale;
3167
3787
  model.scaley = m.scaley * m.unitScale;
3168
3788
  model.scalez = m.scalez * m.unitScale;
3169
3789
  }
3170
3790
  if (m.color) model.color = m.color;
3171
- if (m.animation) model.animation = structuredClone(m.animation);
3791
+ const playback = savePlayback(m);
3792
+ if (playback) Object.assign(model, playback);
3793
+ else if (!for_vsb) {
3794
+ const center = this.center_models ? m.geometry.bounds.min.map(
3795
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
3796
+ ) : [0, 0, 0];
3797
+ const position = transform(modelMatrix(m), center);
3798
+ ["x", "y", "z"].forEach((axis, i) => model[axis] = position[i]);
3799
+ }
3172
3800
  data.models.push(model);
3173
3801
  }
3174
3802
  return as_js_obj ? data : JSON.stringify(data);
@@ -3182,7 +3810,10 @@ var StlViewer = class _StlViewer {
3182
3810
  get_vsb() {
3183
3811
  return {
3184
3812
  vsj: this.get_vsj(true, true, true),
3185
- files: [...this._models.values()].filter((m) => m.geometry).map((m) => ({ id: m.id, bin: this.get_stl_bin(m.id) }))
3813
+ files: [...this._models.values()].filter((m) => m.geometry).map((m) => ({
3814
+ id: m.id,
3815
+ bin: m._animation ? exportSTL(m.geometry, [1, 1, 1], [0, 0, 0]) : this.get_stl_bin(m.id)
3816
+ }))
3186
3817
  };
3187
3818
  }
3188
3819
  async get_vsb_blob() {
@@ -3263,20 +3894,66 @@ var StlViewer = class _StlViewer {
3263
3894
  );
3264
3895
  else if (scene.grid) this.set_grid(true);
3265
3896
  if (scene.camera_state) this._restoreCamera = scene.camera_state;
3266
- for (const input of scene.models) {
3267
- if (!input || typeof input !== "object")
3268
- throw new Error("Invalid scene model");
3269
- const model = modelOptions(input);
3270
- delete model.mesh;
3271
- delete model.data;
3272
- delete model.local_file;
3273
- if (files) {
3274
- if (typeof model.filename !== "string" || !Object.hasOwn(files, model.filename))
3275
- throw new Error("VSB model file is missing");
3276
- model.local_file = new File([files[model.filename]], model.filename);
3277
- } else if (model.filename)
3278
- model.filename = new URL(model.filename, base).href;
3279
- this.add_model(model);
3897
+ const bookmarks = mergeBookmarks(
3898
+ this.get_camera_bookmarks(),
3899
+ scene.camera_bookmarks || []
3900
+ );
3901
+ const displayUnits = validateUnits(
3902
+ scene.display_units || this.display_units || "mm"
3903
+ );
3904
+ const incoming = [];
3905
+ try {
3906
+ for (const input of scene.models) {
3907
+ if (!input || typeof input !== "object")
3908
+ throw new Error("Invalid scene model");
3909
+ const model = modelOptions(input);
3910
+ delete model.mesh;
3911
+ delete model.data;
3912
+ delete model.local_file;
3913
+ if (files) {
3914
+ if (typeof model.filename !== "string" || !Object.hasOwn(files, model.filename))
3915
+ throw new Error("VSB model file is missing");
3916
+ model.local_file = new File(
3917
+ [files[model.filename]],
3918
+ model.filename
3919
+ );
3920
+ } else if (model.filename)
3921
+ model.filename = new URL(model.filename, base).href;
3922
+ const id = model.id === void 0 || model.id === -1 ? this.max_model_id + 1 : model.id;
3923
+ if (this._models.has(id))
3924
+ throw new Error(`Invalid or duplicate model id: ${id}`);
3925
+ this.add_model({
3926
+ ...model,
3927
+ id,
3928
+ animation: null,
3929
+ animation_state: null
3930
+ });
3931
+ const m = this._models.get(id);
3932
+ if (!m) throw new Error(this.error || "Invalid scene model");
3933
+ m._sceneImportPending = true;
3934
+ incoming.push({ m, model, pending: this._pending.get(id) });
3935
+ }
3936
+ await Promise.all(incoming.map((item) => item.pending));
3937
+ if (generation !== this._generation || this.killsign) return;
3938
+ for (const { m, model } of incoming) {
3939
+ if (this._models.get(m.id) !== m || !m.geometry)
3940
+ throw new Error("A scene model could not be opened");
3941
+ if (model.animation)
3942
+ createPlayback(model.animation, m, model.animation_state);
3943
+ else if (model.animation_state)
3944
+ throw new Error("Playback state needs an animation");
3945
+ }
3946
+ for (const { m, model } of incoming) {
3947
+ if (model.animation)
3948
+ this._startAnimation(m, model.animation, model.animation_state);
3949
+ m._sceneImportPending = false;
3950
+ }
3951
+ this.camera_bookmarks = bookmarks;
3952
+ this.display_units = displayUnits;
3953
+ } catch (error) {
3954
+ for (const { m } of incoming)
3955
+ if (this._models.get(m.id) === m) this.remove_model(m.id);
3956
+ throw error;
3280
3957
  }
3281
3958
  })().catch((e) => {
3282
3959
  if (generation === this._generation && e.name !== "AbortError")
@@ -3298,56 +3975,101 @@ var StlViewer = class _StlViewer {
3298
3975
  return this._guard(() => {
3299
3976
  const m = this._model(id);
3300
3977
  if (!animation) {
3301
- m.animation = null;
3302
- m._animation = null;
3978
+ this._stopAnimation(m, "stopped");
3979
+ this._invalidate();
3303
3980
  return;
3304
3981
  }
3305
- const copy = structuredClone(animation), tracks = [];
3306
- for (const mode of ["delta", "exact"])
3307
- if (copy[mode]) {
3308
- const a = copy[mode];
3309
- positive(a.msec ?? 300, "animation duration");
3310
- const values = {};
3311
- for (const key of transformKeys) {
3312
- const value = a[key] ?? (key.startsWith("scale") ? a.scale : void 0);
3313
- if (value !== void 0) {
3314
- finite(value, key);
3315
- if (mode === "exact" && key.startsWith("scale"))
3316
- positive(value, key);
3317
- values[key] = value;
3318
- }
3319
- }
3320
- tracks.push({
3321
- mode,
3322
- values,
3323
- duration: a.msec ?? 300,
3324
- loop: mode === "delta" && !!a.loop,
3325
- from: Object.fromEntries(transformKeys.map((k) => [k, m[k]]))
3326
- });
3327
- }
3328
- if (!tracks.length) throw new Error("Animation needs delta or exact");
3329
- m.animation = copy;
3330
- m._animation = { start: null, tracks };
3982
+ this._startAnimation(m, animation);
3331
3983
  this._invalidate();
3332
3984
  });
3333
3985
  }
3986
+ _startAnimation(m, definition, saved) {
3987
+ const playback = createPlayback(definition, m, saved);
3988
+ if (m._animation) this._stopAnimation(m, "replaced");
3989
+ if (saved) m.center = [...saved.center];
3990
+ m.animation = playback.animation;
3991
+ m._animation = playback;
3992
+ delete m.animation_state;
3993
+ m._animationResult = null;
3994
+ const running = evaluatePlayback(playback, m);
3995
+ if (running) this._animationEvent(m, saved ? "restored" : "started");
3996
+ else this._completeAnimation(m);
3997
+ }
3998
+ _animationEvent(m, reason) {
3999
+ const state = this._animationState(m);
4000
+ queueMicrotask(() => {
4001
+ this._callback("animation_state_callback", m.id, state, reason);
4002
+ if (reason === "completed")
4003
+ this._callback("animation_complete_callback", m.id, state);
4004
+ });
4005
+ }
4006
+ _animationState(m) {
4007
+ return m._animation ? playbackState(m._animation, !!this._animationSuspended) : {
4008
+ ...m._animationResult ?? { status: "idle", elapsed_ms: 0 },
4009
+ suspended: false,
4010
+ tracks: []
4011
+ };
4012
+ }
4013
+ _stopAnimation(m, reason) {
4014
+ if (!m._animation && !m.animation) return;
4015
+ m._animationResult = {
4016
+ status: "stopped",
4017
+ elapsed_ms: m._animation?.elapsed_ms ?? 0
4018
+ };
4019
+ m._animation = null;
4020
+ m.animation = null;
4021
+ delete m.animation_state;
4022
+ this._animationEvent(m, reason);
4023
+ }
4024
+ _completeAnimation(m) {
4025
+ m._animationResult = {
4026
+ status: "completed",
4027
+ elapsed_ms: m._animation.elapsed_ms
4028
+ };
4029
+ m._animation = null;
4030
+ m.animation = null;
4031
+ this._animationEvent(m, "completed");
4032
+ }
4033
+ pause_model_animation(id) {
4034
+ return this._guard(() => {
4035
+ const m = this._model(id);
4036
+ if (!m._animation || m._animation.paused) return;
4037
+ m._animation.paused = true;
4038
+ m._animation.last_time = null;
4039
+ this._animationEvent(m, "paused");
4040
+ this._invalidate();
4041
+ });
4042
+ }
4043
+ resume_model_animation(id) {
4044
+ return this._guard(() => {
4045
+ const m = this._model(id);
4046
+ if (!m._animation || !m._animation.paused) return;
4047
+ m._animation.paused = false;
4048
+ m._animation.last_time = null;
4049
+ this._animationEvent(m, "resumed");
4050
+ this._invalidate();
4051
+ });
4052
+ }
4053
+ get_model_animation_state(id) {
4054
+ return this._guard(() => this._animationState(this._model(id)));
4055
+ }
4056
+ _suspendAnimations(suspended) {
4057
+ this._animationSuspended = !!suspended;
4058
+ this._lastTime = 0;
4059
+ for (const m of this._models.values())
4060
+ if (m._animation) m._animation.last_time = null;
4061
+ if (suspended) {
4062
+ cancelAnimationFrame(this._frame);
4063
+ this._frame = 0;
4064
+ } else this._invalidate();
4065
+ }
3334
4066
  _animate(m, time) {
3335
- const animation = m._animation;
3336
- animation.start ??= time;
3337
- let running = false;
3338
- for (const t of animation.tracks) {
3339
- const fraction = (time - animation.start) / t.duration, p = t.loop ? fraction : Math.min(fraction, 1);
3340
- running ||= t.loop || fraction < 1;
3341
- for (const [k, v] of Object.entries(t.values))
3342
- m[k] = k.startsWith("scale") ? Math.max(
3343
- 1e-6,
3344
- t.mode === "delta" ? t.from[k] + v * p : t.from[k] + (v - t.from[k]) * p
3345
- ) : t.mode === "delta" ? t.from[k] + v * p : t.from[k] + (v - t.from[k]) * p;
3346
- }
3347
- m._stats = null;
3348
- if (!running) {
3349
- m._animation = null;
3350
- m.animation = null;
4067
+ if (this._animationSuspended || !m._animation) return;
4068
+ try {
4069
+ if (!advancePlayback(m._animation, m, time)) this._completeAnimation(m);
4070
+ } catch (error) {
4071
+ this._stopAnimation(m, "error");
4072
+ this._error(error);
3351
4073
  }
3352
4074
  }
3353
4075
  async get_image_blob(type = "image/png") {
@@ -3371,7 +4093,300 @@ var StlViewer = class _StlViewer {
3371
4093
  };
3372
4094
  var viewer_default = StlViewer;
3373
4095
 
3374
- // src/v2/memory-policy.js
4096
+ // src/app-tools.js
4097
+ var toolsControls = [
4098
+ "size-details",
4099
+ "size-fields",
4100
+ "size-x",
4101
+ "size-y",
4102
+ "size-z",
4103
+ "size-lock",
4104
+ "size-percent",
4105
+ "size-apply",
4106
+ "size-cancel",
4107
+ "size-reset",
4108
+ "size-error",
4109
+ "file-units",
4110
+ "file-units-note",
4111
+ "display-units",
4112
+ "size-unit-label",
4113
+ "duplicate-model",
4114
+ "camera-preset",
4115
+ "bookmark-name",
4116
+ "bookmark-list",
4117
+ "bookmark-save",
4118
+ "bookmark-restore",
4119
+ "bookmark-rename",
4120
+ "bookmark-delete",
4121
+ "camera-error",
4122
+ "info-area",
4123
+ "info-volume",
4124
+ "measure-model",
4125
+ "measure-cancel",
4126
+ "measure-status"
4127
+ ];
4128
+ var axes2 = ["x", "y", "z"];
4129
+ var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4130
+ function bindAppTools({
4131
+ $,
4132
+ listen,
4133
+ window: window2,
4134
+ getViewer,
4135
+ getState,
4136
+ changeSize,
4137
+ duplicate,
4138
+ refresh,
4139
+ announce
4140
+ }) {
4141
+ let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4142
+ const error = (id, message = "") => {
4143
+ $(id).textContent = message;
4144
+ $(id).hidden = !message;
4145
+ };
4146
+ const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4147
+ const value = (v) => Number(v.toPrecision(10));
4148
+ function syncInfo() {
4149
+ const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4150
+ const s = m?.scaleVector, cached = m?._measurements;
4151
+ const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4152
+ const unit = factor() === 1 ? "mm" : "in";
4153
+ const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4154
+ $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4155
+ $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4156
+ if (!measurement)
4157
+ $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4158
+ $("measure-model").disabled = busy || !m || !!measurement;
4159
+ $("measure-cancel").hidden = !measurement;
4160
+ }
4161
+ function syncBookmarks() {
4162
+ const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4163
+ $("bookmark-list").replaceChildren(
4164
+ ...bookmarks.map((b) => {
4165
+ const option = window2.document.createElement("option");
4166
+ option.value = b.name;
4167
+ option.textContent = b.name;
4168
+ return option;
4169
+ })
4170
+ );
4171
+ if (bookmarks.some((b) => b.name === previous))
4172
+ $("bookmark-list").value = previous;
4173
+ for (const id of [
4174
+ "bookmark-restore",
4175
+ "bookmark-rename",
4176
+ "bookmark-delete",
4177
+ "bookmark-list"
4178
+ ])
4179
+ $(id).disabled = !bookmarks.length;
4180
+ $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4181
+ }
4182
+ function sync() {
4183
+ if (disposed) return;
4184
+ const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4185
+ if (selectedId !== selected) {
4186
+ measurement?.abort();
4187
+ selected = selectedId;
4188
+ }
4189
+ draftKind = null;
4190
+ base = m?.geometry ? v.get_model_size(selectedId) : null;
4191
+ $("size-fields").disabled = busy || !base;
4192
+ $("duplicate-model").disabled = busy || !base;
4193
+ $("display-units").value = v?.display_units || "mm";
4194
+ $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4195
+ $("file-units").value = base?.file_units || "mm";
4196
+ $("file-units").disabled = busy || !base?.editable;
4197
+ $("file-units-note").textContent = base?.editable ? "STL/OBJ have no units. Millimetres are assumed; choose inches to correct the physical size." : "Explicit file units were converted to millimetres on import.";
4198
+ axes2.forEach((a, i) => {
4199
+ $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4200
+ $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4201
+ });
4202
+ const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4203
+ $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4204
+ error("size-error");
4205
+ syncInfo();
4206
+ syncBookmarks();
4207
+ }
4208
+ const attempt = (id, action) => {
4209
+ try {
4210
+ action();
4211
+ error(id);
4212
+ } catch (e) {
4213
+ error(id, e.message);
4214
+ }
4215
+ };
4216
+ axes2.forEach(
4217
+ (a, i) => listen($("size-" + a), "input", () => {
4218
+ if (!base) return;
4219
+ draftKind = "axis";
4220
+ lastAxis = i;
4221
+ const n = $("size-" + a).valueAsNumber;
4222
+ if (!Number.isFinite(n) || n <= 0) return;
4223
+ if ($("size-lock").checked)
4224
+ axes2.forEach((axis, j) => {
4225
+ if (i !== j)
4226
+ $("size-" + axis).value = value(
4227
+ n * base.dimensions_mm[j] / base.dimensions_mm[i]
4228
+ );
4229
+ });
4230
+ $("size-percent").value = "";
4231
+ })
4232
+ );
4233
+ listen($("size-percent"), "input", () => {
4234
+ draftKind = "percent";
4235
+ const n = $("size-percent").valueAsNumber;
4236
+ if (base && Number.isFinite(n) && n > 0)
4237
+ axes2.forEach(
4238
+ (a, i) => $("size-" + a).value = value(
4239
+ base.baseline_mm[i] * n / 100 / factor()
4240
+ )
4241
+ );
4242
+ });
4243
+ listen(
4244
+ $("size-apply"),
4245
+ "click",
4246
+ () => attempt("size-error", () => {
4247
+ if (!base) return;
4248
+ if (!draftKind) return;
4249
+ const current = getViewer().get_model_size(selected).dimensions_mm;
4250
+ let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4251
+ if (draftKind === "percent") {
4252
+ const percent = $("size-percent").valueAsNumber;
4253
+ if (!Number.isFinite(percent) || percent <= 0)
4254
+ throw new Error("Enter a positive scale percentage.");
4255
+ desired = base.baseline_mm.map((v) => v * percent / 100);
4256
+ } else if ($("size-lock").checked) {
4257
+ const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4258
+ desired = base.dimensions_mm.map((v) => v * ratio);
4259
+ }
4260
+ let ratios = desired.map((n, i) => {
4261
+ if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4262
+ throw new Error("Enter positive dimensions for each non-flat axis.");
4263
+ return current[i] ? n / current[i] : 1;
4264
+ });
4265
+ if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4266
+ ratios = ratios.map(() => ratios[0]);
4267
+ changeSize(() => getViewer().resize_model(selected, ratios));
4268
+ announce("Model size updated.");
4269
+ })
4270
+ );
4271
+ listen($("size-cancel"), "click", sync);
4272
+ listen(
4273
+ $("size-reset"),
4274
+ "click",
4275
+ () => attempt("size-error", () => {
4276
+ changeSize(() => getViewer().reset_model_size(selected));
4277
+ announce("Original size restored; file-unit correction retained.");
4278
+ })
4279
+ );
4280
+ listen(
4281
+ $("file-units"),
4282
+ "change",
4283
+ () => attempt("size-error", () => {
4284
+ changeSize(
4285
+ () => getViewer().set_file_units(selected, $("file-units").value)
4286
+ );
4287
+ announce("File units corrected.");
4288
+ })
4289
+ );
4290
+ listen($("display-units"), "change", () => {
4291
+ getViewer()?.set_display_units($("display-units").value);
4292
+ refresh();
4293
+ });
4294
+ listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4295
+ listen(
4296
+ $("camera-preset"),
4297
+ "change",
4298
+ () => attempt("camera-error", () => {
4299
+ if ($("camera-preset").value)
4300
+ getViewer().set_camera_view($("camera-preset").value);
4301
+ announce("Camera view updated.");
4302
+ $("camera-preset").value = "";
4303
+ })
4304
+ );
4305
+ const editBookmarks = (action) => attempt("camera-error", () => {
4306
+ const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4307
+ action(bookmarks, v);
4308
+ v.set_camera_bookmarks(bookmarks);
4309
+ syncBookmarks();
4310
+ });
4311
+ listen(
4312
+ $("bookmark-save"),
4313
+ "click",
4314
+ () => editBookmarks((b, v) => {
4315
+ b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4316
+ })
4317
+ );
4318
+ listen(
4319
+ $("bookmark-rename"),
4320
+ "click",
4321
+ () => editBookmarks((b) => {
4322
+ b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4323
+ })
4324
+ );
4325
+ listen(
4326
+ $("bookmark-delete"),
4327
+ "click",
4328
+ () => editBookmarks((b) => {
4329
+ b.splice(
4330
+ b.findIndex((b2) => b2.name === $("bookmark-list").value),
4331
+ 1
4332
+ );
4333
+ })
4334
+ );
4335
+ listen(
4336
+ $("bookmark-restore"),
4337
+ "click",
4338
+ () => attempt("camera-error", () => {
4339
+ const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4340
+ if (b) {
4341
+ getViewer().set_camera_state(b.camera);
4342
+ announce("Saved view restored.");
4343
+ }
4344
+ })
4345
+ );
4346
+ listen(
4347
+ $("bookmark-list"),
4348
+ "change",
4349
+ () => $("bookmark-name").value = $("bookmark-list").value
4350
+ );
4351
+ listen($("measure-cancel"), "click", () => measurement?.abort());
4352
+ listen($("measure-model"), "click", async () => {
4353
+ const id = selected, v = getViewer(), controller = new window2.AbortController();
4354
+ measurement = controller;
4355
+ syncInfo();
4356
+ $("measure-status").textContent = "Calculating area and checking volume\u2026";
4357
+ try {
4358
+ await v.measure_model(id, {
4359
+ signal: controller.signal,
4360
+ on_progress: (p) => {
4361
+ if (selected === id && !disposed)
4362
+ $("measure-status").textContent = p.phase + "\u2026";
4363
+ }
4364
+ });
4365
+ if (!disposed && selected === id) announce("Measurements ready.");
4366
+ } catch (e) {
4367
+ if (!disposed && selected === id)
4368
+ $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4369
+ } finally {
4370
+ const message = $("measure-status").textContent;
4371
+ if (measurement === controller) measurement = null;
4372
+ if (!disposed) {
4373
+ syncInfo();
4374
+ if (!v._models.get(id)?._measurements && selected === id)
4375
+ $("measure-status").textContent = message;
4376
+ }
4377
+ }
4378
+ });
4379
+ return {
4380
+ sync,
4381
+ syncInfo,
4382
+ dispose() {
4383
+ disposed = true;
4384
+ measurement?.abort();
4385
+ }
4386
+ };
4387
+ }
4388
+
4389
+ // src/memory-policy.js
3375
4390
  var GiB = 1024 ** 3;
3376
4391
  var MiB = 1024 ** 2;
3377
4392
  function memoryPolicy(reportedGiB) {
@@ -3401,10 +4416,10 @@ async function estimateFile(file) {
3401
4416
  return 64 * MiB + (file.size || 0) * 3;
3402
4417
  }
3403
4418
 
3404
- // src/v2/model-transform.js
3405
- var axes = ["x", "y", "z"];
3406
- var vector2 = (p) => axes.map((a) => p[a]);
3407
- var object = (p) => Object.fromEntries(axes.map((a, i) => [a, p[i]]));
4419
+ // src/model-transform.js
4420
+ var axes3 = ["x", "y", "z"];
4421
+ var vector2 = (p) => axes3.map((a) => p[a]);
4422
+ var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
3408
4423
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
3409
4424
  var norm2 = (a) => {
3410
4425
  const n = Math.hypot(...a) || 1;
@@ -3435,8 +4450,8 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
3435
4450
  }
3436
4451
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
3437
4452
  function modelCenter(model) {
3438
- return object(
3439
- centerOf(model.bounds).map((v, i) => v + model.position[axes[i]])
4453
+ return object2(
4454
+ centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
3440
4455
  );
3441
4456
  }
3442
4457
  function modelRadius(model) {
@@ -3447,7 +4462,7 @@ function modelRadius(model) {
3447
4462
  }
3448
4463
  function enginePosition(model) {
3449
4464
  const c = centerOf(model.bounds), rotated = transform2(rotationMatrix(model.rotation), c);
3450
- return object(c.map((v, i) => model.position[axes[i]] + v - rotated[i]));
4465
+ return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
3451
4466
  }
3452
4467
  function modelBounds(model) {
3453
4468
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -3500,12 +4515,12 @@ function depthPosition(model, camera, amount) {
3500
4515
  const clearance = Math.max(model.radius || 1e-6, 1e-6) * 1.1 / Math.max(0.05, dot2(direction, forward));
3501
4516
  const desired = (distance || clearance) * Math.exp(Math.max(-6, Math.min(6, amount)));
3502
4517
  const next = amount < 0 ? Math.max(Math.min(distance, clearance), desired) : Math.max(distance, Math.min(1e12, desired));
3503
- return object(
4518
+ return object2(
3504
4519
  vector2(model.position).map((v, i) => v + direction[i] * (next - distance))
3505
4520
  );
3506
4521
  }
3507
4522
 
3508
- // src/v2/scene-layout.js
4523
+ // src/scene-layout.js
3509
4524
  function scenePlacement(models, mode) {
3510
4525
  if (mode === "custom")
3511
4526
  return models.map((m) => ({
@@ -3543,7 +4558,7 @@ function sceneFloor(models, placement) {
3543
4558
  ) : 0;
3544
4559
  }
3545
4560
 
3546
- // src/v2/model-move.js
4561
+ // src/model-move.js
3547
4562
  var vector3 = (p) => [p.x, p.y, p.z];
3548
4563
  var sub2 = (a, b) => a.map((v, i) => v - b[i]);
3549
4564
  var dot3 = (a, b) => a.reduce((n, v, i) => n + v * b[i], 0);
@@ -3709,7 +4724,7 @@ function bindModelMove(canvas, {
3709
4724
  };
3710
4725
  }
3711
4726
 
3712
- // src/v2/drop-source.js
4727
+ // src/drop-source.js
3713
4728
  function dropKind(transfer) {
3714
4729
  const types = Array.from(transfer?.types || [], (type) => type.toLowerCase());
3715
4730
  if (types.includes("files") || transfer?.files?.length) return "files";
@@ -3749,7 +4764,7 @@ function droppedURLs(transfer) {
3749
4764
  return [...urls];
3750
4765
  }
3751
4766
 
3752
- // src/v2/app-export.js
4767
+ // src/app-export.js
3753
4768
  var exportControls = [
3754
4769
  "save-button",
3755
4770
  "save-dialog",
@@ -3788,16 +4803,16 @@ function bindAppExport({
3788
4803
  $("save-error").textContent = "";
3789
4804
  };
3790
4805
  function formatChanged() {
3791
- const format = $("save-format").value;
4806
+ const format2 = $("save-format").value;
3792
4807
  if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
3793
4808
  suggestedName();
3794
4809
  }
3795
4810
  if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
3796
4811
  if ($("save-scene-note")) $("save-scene-note").hidden = !isScene();
3797
- $("save-colors-row").hidden = !["obj", "3mf"].includes(format);
3798
- $("save-cad-note").hidden = !["step", "iges"].includes(format);
4812
+ $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4813
+ $("save-cad-note").hidden = !["step", "iges"].includes(format2);
3799
4814
  $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
3800
- $("save-submit").textContent = format === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4815
+ $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
3801
4816
  sync();
3802
4817
  }
3803
4818
  function suggestedName() {
@@ -3919,7 +4934,7 @@ function bindAppExport({
3919
4934
  };
3920
4935
  }
3921
4936
 
3922
- // src/v2/app-print.js
4937
+ // src/app-print.js
3923
4938
  var printControls = [
3924
4939
  "print-button",
3925
4940
  "print-panel",
@@ -3953,6 +4968,7 @@ function bindAppPrint({
3953
4968
  }) {
3954
4969
  let job = null, report = null, timer = null, disposed = false, scopeKey = "", previousScope = "", unavailable = false;
3955
4970
  const panel = $("print-panel"), document2 = panel.ownerDocument;
4971
+ const pausedForCheck = /* @__PURE__ */ new Set();
3956
4972
  const summary = {
3957
4973
  issues_found: "Issues found",
3958
4974
  incomplete: "Check incomplete",
@@ -3974,6 +4990,15 @@ function bindAppPrint({
3974
4990
  const s = getState();
3975
4991
  $("print-button").disabled = s.busy || !s.models.length;
3976
4992
  if (panel.hidden) return;
4993
+ for (const id of pausedForCheck)
4994
+ if (!s.models.some((m) => m.id === id) || getViewer()?.get_model_animation_state(id)?.status !== "paused")
4995
+ pausedForCheck.delete(id);
4996
+ if ($("print-resume")) {
4997
+ $("print-resume").hidden = !pausedForCheck.size;
4998
+ $("print-resume").disabled = !!job || s.busy;
4999
+ }
5000
+ if ($("print-animation-note"))
5001
+ $("print-animation-note").hidden = !pausedForCheck.size;
3977
5002
  const wasUnavailable = unavailable;
3978
5003
  unavailable = s.busy || !s.models.length;
3979
5004
  $("print-run").disabled = !!job || unavailable;
@@ -4120,6 +5145,19 @@ function bindAppPrint({
4120
5145
  listen($("print-fields"), "change", changed);
4121
5146
  listen($("print-clear"), "click", clear);
4122
5147
  listen($("print-cancel"), "click", cancel);
5148
+ if ($("print-resume"))
5149
+ listen($("print-resume"), "click", () => {
5150
+ if (job || getState().busy) return;
5151
+ for (const id of pausedForCheck)
5152
+ if (getState().models.some((m) => m.id === id) && getViewer().get_model_animation_state(id)?.status === "paused")
5153
+ getViewer().resume_model_animation(id);
5154
+ pausedForCheck.clear();
5155
+ report = null;
5156
+ clear();
5157
+ $("print-results").replaceChildren();
5158
+ $("print-status").textContent = "Animation resumed. Pause and run the check again for the new pose.";
5159
+ sync();
5160
+ });
4123
5161
  listen($("print-results"), "click", (e) => {
4124
5162
  const b = e.target.closest("button[data-issue]");
4125
5163
  if (!b || !report) return;
@@ -4163,6 +5201,13 @@ function bindAppPrint({
4163
5201
  }
4164
5202
  }
4165
5203
  scopeKey = key();
5204
+ for (const id of stateIds()) {
5205
+ if (getViewer().get_model_animation_state(id)?.status === "running") {
5206
+ getViewer().pause_model_animation(id);
5207
+ pausedForCheck.add(id);
5208
+ }
5209
+ }
5210
+ sync();
4166
5211
  const result = await getViewer().check_printability({
4167
5212
  model_ids: stateIds(),
4168
5213
  level: $("print-level").value,
@@ -4200,11 +5245,216 @@ function bindAppPrint({
4200
5245
  disposed = true;
4201
5246
  close(false);
4202
5247
  report = null;
5248
+ pausedForCheck.clear();
5249
+ }
5250
+ };
5251
+ }
5252
+
5253
+ // src/app-animation.js
5254
+ var animationControls = [
5255
+ "animation-details",
5256
+ "animation-fields",
5257
+ "animation-axis",
5258
+ "animation-seconds",
5259
+ "animation-direction",
5260
+ "animation-play",
5261
+ "animation-pause",
5262
+ "animation-stop",
5263
+ "animation-status",
5264
+ "animation-error"
5265
+ ];
5266
+ var axes4 = ["x", "y", "z"];
5267
+ function syncAnimatedRecord(record, model) {
5268
+ if (!record || !model?.geometry) return;
5269
+ record.bounds = Object.fromEntries(
5270
+ ["min", "max"].map((key) => [
5271
+ key,
5272
+ model.geometry.bounds[key].map(
5273
+ (v, i) => (v - model.center[i]) * model.scaleVector[i]
5274
+ )
5275
+ ])
5276
+ );
5277
+ record.rotation = Object.fromEntries(
5278
+ axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5279
+ );
5280
+ record.position = { x: 0, y: 0, z: 0 };
5281
+ const offset = enginePosition(record);
5282
+ for (const a of axes4) record.position[a] = model[a] - offset[a];
5283
+ }
5284
+ function centerAnimationPivot(record, model) {
5285
+ syncAnimatedRecord(record, model);
5286
+ const center = model.geometry.bounds.min.map(
5287
+ (v, i) => (v + model.geometry.bounds.max[i]) / 2
5288
+ );
5289
+ const delta = center.map(
5290
+ (v, i) => (v - model.center[i]) * model.scaleVector[i]
5291
+ );
5292
+ const rotation2 = rotationMatrix(record.rotation);
5293
+ const original = record.originalPosition || { x: 0, y: 0, z: 0 };
5294
+ record.originalPosition = Object.fromEntries(
5295
+ axes4.map((a, i) => [a, original[a] + delta[i]])
5296
+ );
5297
+ for (let i = 0; i < 3; i++)
5298
+ model[axes4[i]] += delta.reduce(
5299
+ (sum, v, j) => sum + rotation2[i * 3 + j] * v,
5300
+ 0
5301
+ );
5302
+ model.center = center;
5303
+ record.animationPivot = true;
5304
+ syncAnimatedRecord(record, model);
5305
+ }
5306
+ function spinDefinition(axis, seconds, direction) {
5307
+ if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5308
+ throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
5309
+ return {
5310
+ delta: {
5311
+ ["rotation" + axis]: 2 * Math.PI * Number(direction),
5312
+ msec: seconds * 1e3,
5313
+ loop: true
5314
+ }
5315
+ };
5316
+ }
5317
+ function bindAppAnimation({
5318
+ $,
5319
+ listen,
5320
+ window: window2,
5321
+ getState,
5322
+ getViewer,
5323
+ beforePlay,
5324
+ refreshPose,
5325
+ announce
5326
+ }) {
5327
+ const settings = /* @__PURE__ */ new Map();
5328
+ let selected = null, timer = null, disposed = false;
5329
+ const state = () => {
5330
+ const s = getState();
5331
+ return {
5332
+ ...s,
5333
+ playback: s.selectedId == null ? null : getViewer()?.get_model_animation_state(s.selectedId)
5334
+ };
5335
+ };
5336
+ function sync() {
5337
+ if (disposed) return;
5338
+ const s = state(), available = s.models.some((m) => m.id === s.selectedId);
5339
+ for (const id of settings.keys())
5340
+ if (!s.models.some((m) => m.id === id)) settings.delete(id);
5341
+ $("animation-details").hidden = !available;
5342
+ $("animation-fields").disabled = s.busy || !available;
5343
+ if (selected !== s.selectedId) {
5344
+ selected = s.selectedId;
5345
+ let value = settings.get(selected);
5346
+ if (!value) {
5347
+ value = { axis: "z", seconds: 5, direction: "1" };
5348
+ const a = getViewer()?._models.get(selected)?.animation;
5349
+ const entries = Object.entries(a?.delta || {}).filter(
5350
+ ([k, v]) => k.startsWith("rotation") && v !== 0
5351
+ );
5352
+ if (a?.delta?.loop && !a.exact && entries.length === 1) {
5353
+ value = {
5354
+ axis: entries[0][0].slice(-1),
5355
+ seconds: a.delta.msec * 2 * Math.PI / Math.abs(entries[0][1]) / 1e3,
5356
+ direction: entries[0][1] < 0 ? "-1" : "1"
5357
+ };
5358
+ }
5359
+ settings.set(selected, value);
5360
+ }
5361
+ $("animation-axis").value = value.axis;
5362
+ $("animation-seconds").value = value.seconds;
5363
+ $("animation-direction").value = value.direction;
5364
+ $("animation-error").hidden = true;
5365
+ }
5366
+ const status = s.playback?.status || "idle", active = ["running", "paused"].includes(status);
5367
+ $("animation-play").textContent = active ? "Restart spin" : "Play";
5368
+ $("animation-pause").textContent = status === "paused" ? "Resume" : "Pause";
5369
+ $("animation-pause").disabled = !active;
5370
+ $("animation-stop").disabled = !active;
5371
+ const label = {
5372
+ idle: "Ready",
5373
+ running: "Playing",
5374
+ paused: "Paused",
5375
+ stopped: "Stopped",
5376
+ completed: "Finished"
5377
+ }[status];
5378
+ const text = `${label}. ${active ? "Stop keeps the current pose." : "Spin the selected model around its center."}`;
5379
+ if ($("animation-status").textContent !== text)
5380
+ $("animation-status").textContent = text;
5381
+ if (status === "running" && !timer)
5382
+ timer = window2.setInterval(() => {
5383
+ if (!disposed && !window2.document.hidden) refreshPose();
5384
+ }, 250);
5385
+ if (status !== "running" && timer) {
5386
+ window2.clearInterval(timer);
5387
+ timer = null;
5388
+ }
5389
+ }
5390
+ function play(keepPaused = false) {
5391
+ const s = state();
5392
+ if (s.busy || s.selectedId == null) return;
5393
+ try {
5394
+ const value = {
5395
+ axis: $("animation-axis").value,
5396
+ seconds: $("animation-seconds").valueAsNumber,
5397
+ direction: $("animation-direction").value
5398
+ };
5399
+ const definition = spinDefinition(
5400
+ value.axis,
5401
+ value.seconds,
5402
+ value.direction
5403
+ );
5404
+ beforePlay(s.selectedId);
5405
+ getViewer().animate_model(s.selectedId, definition);
5406
+ if (keepPaused && s.playback?.status === "paused")
5407
+ getViewer().pause_model_animation(s.selectedId);
5408
+ settings.set(s.selectedId, value);
5409
+ $("animation-error").hidden = true;
5410
+ announce(
5411
+ keepPaused && s.playback?.status === "paused" ? "Spin updated. Still paused." : "Animation playing."
5412
+ );
5413
+ sync();
5414
+ } catch (error) {
5415
+ $("animation-error").textContent = error.message;
5416
+ $("animation-error").hidden = false;
5417
+ }
5418
+ }
5419
+ listen($("animation-play"), "click", () => play());
5420
+ listen($("animation-pause"), "click", () => {
5421
+ const s = state();
5422
+ if (s.busy || s.selectedId == null) return;
5423
+ getViewer()[s.playback?.status === "paused" ? "resume_model_animation" : "pause_model_animation"](s.selectedId);
5424
+ sync();
5425
+ });
5426
+ listen($("animation-stop"), "click", () => {
5427
+ const s = state();
5428
+ if (!s.busy && s.selectedId != null)
5429
+ getViewer().animate_model(s.selectedId, null);
5430
+ sync();
5431
+ });
5432
+ for (const id of [
5433
+ "animation-axis",
5434
+ "animation-seconds",
5435
+ "animation-direction"
5436
+ ])
5437
+ listen($(id), "change", () => {
5438
+ if (["running", "paused"].includes(state().playback?.status)) play(true);
5439
+ else
5440
+ settings.set(selected, {
5441
+ axis: $("animation-axis").value,
5442
+ seconds: $("animation-seconds").valueAsNumber,
5443
+ direction: $("animation-direction").value
5444
+ });
5445
+ });
5446
+ sync();
5447
+ return {
5448
+ sync,
5449
+ dispose() {
5450
+ disposed = true;
5451
+ window2.clearInterval(timer);
5452
+ settings.clear();
4203
5453
  }
4204
5454
  };
4205
5455
  }
4206
5456
 
4207
- // src/v2/app.js
5457
+ // src/app.js
4208
5458
  var mountedRoots = /* @__PURE__ */ new WeakMap();
4209
5459
  var requiredControls = [
4210
5460
  "background-color",
@@ -4309,6 +5559,10 @@ function mountViewstlApp(root, options = {}) {
4309
5559
  missing.push(...exportControls.filter((id) => !$(id)));
4310
5560
  if (printControls.some((id) => $(id)))
4311
5561
  missing.push(...printControls.filter((id) => !$(id)));
5562
+ if (animationControls.some((id) => $(id)))
5563
+ missing.push(...animationControls.filter((id) => !$(id)));
5564
+ if (toolsControls.some((id) => $(id)))
5565
+ missing.push(...toolsControls.filter((id) => !$(id)));
4312
5566
  if (missing.length)
4313
5567
  throw new Error("Missing ViewSTL controls: " + missing.join(", "));
4314
5568
  const abort = new window2.AbortController();
@@ -4336,6 +5590,8 @@ function mountViewstlApp(root, options = {}) {
4336
5590
  let viewerPromise, viewer, generation = 0, appearanceRequest = 0, modelID = null, nextID = 0, loadingID = null;
4337
5591
  let exportUI;
4338
5592
  let printUI;
5593
+ let animationUI;
5594
+ let toolsUI;
4339
5595
  let sceneImport;
4340
5596
  let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, transformTool = "move", modelMover, dragArrangement, background = "transparent";
4341
5597
  const viewNames = {
@@ -4384,6 +5640,8 @@ function mountViewstlApp(root, options = {}) {
4384
5640
  $(name + "-panel").hidden = !open;
4385
5641
  $(name + "-button").setAttribute("aria-expanded", String(open));
4386
5642
  syncSelection();
5643
+ if (open && name === "info") populateInfo();
5644
+ if (open && (name === "scene" || name === "view")) toolsUI?.sync();
4387
5645
  }
4388
5646
  function fileSize(size) {
4389
5647
  return size == null ? "Sample model" : size < 1024 ** 2 ? `${formatNumber.format(size / 1024)} KiB` : size < 1024 ** 3 ? `${formatNumber.format(size / 1024 ** 2)} MiB` : `${formatNumber.format(size / 1024 ** 3)} GiB`;
@@ -4409,12 +5667,14 @@ function mountViewstlApp(root, options = {}) {
4409
5667
  $("background-value").textContent = background === "transparent" ? "Default" : background.toUpperCase();
4410
5668
  }
4411
5669
  function syncControls() {
4412
- const info = modelID === null ? null : viewer.get_model_info(modelID), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
5670
+ syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
5671
+ const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
4413
5672
  for (const input of modeInputs) {
4414
5673
  input.checked = input.value === mode;
4415
5674
  input.disabled = busy;
4416
5675
  }
4417
- $("view-label").textContent = viewNames[mode];
5676
+ $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5677
+ $("view-button").title = "View \xB7 " + viewNames[mode];
4418
5678
  $("smooth-shading").checked = info?.smooth_shading || false;
4419
5679
  $("smooth-shading").disabled = busy || mode === "wireframe";
4420
5680
  $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
@@ -4437,14 +5697,18 @@ function mountViewstlApp(root, options = {}) {
4437
5697
  syncPosition();
4438
5698
  exportUI?.sync();
4439
5699
  printUI?.sync();
5700
+ animationUI?.sync();
5701
+ toolsUI?.sync();
4440
5702
  }
4441
5703
  function populateInfo() {
4442
5704
  if (modelID === null) return;
4443
- const info = viewer.get_model_info(modelID), record = records.get(modelID), units = record.units === "mm" ? " mm" : "";
5705
+ const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
4444
5706
  $("info-triangles").textContent = info.triangles.toLocaleString();
4445
- $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x)).join(" \xD7 ") + units;
5707
+ $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
4446
5708
  $("info-size").textContent = fileSize(record.size);
4447
- $("unit-note").textContent = units ? "Dimensions are shown in millimetres." : "Dimensions are in model units. STL and OBJ files do not specify physical units.";
5709
+ const size = viewer.get_model_size(modelID);
5710
+ $("unit-note").textContent = size.editable ? "File coordinates interpreted as " + (size.file_units === "inch" ? "inches." : "millimetres (assumed).") + " Change file units in Models \u2192 Size & units." : "Explicit file units were converted to millimetres on import.";
5711
+ toolsUI?.syncInfo();
4448
5712
  }
4449
5713
  function renderList() {
4450
5714
  $("model-list").replaceChildren(
@@ -4516,6 +5780,10 @@ function mountViewstlApp(root, options = {}) {
4516
5780
  }
4517
5781
  function arrangeScene() {
4518
5782
  setMoveMode(false);
5783
+ for (const record of records.values())
5784
+ syncAnimatedRecord(record, viewer?._models.get(record.id));
5785
+ if ($("scene-arrangement").value === "custom") return;
5786
+ for (const record of records.values()) prepareManual(record.id);
4519
5787
  if ($("scene-arrangement").value === "original")
4520
5788
  for (const record of records.values())
4521
5789
  record.rotation = {
@@ -4532,19 +5800,20 @@ function mountViewstlApp(root, options = {}) {
4532
5800
  updateGrid();
4533
5801
  viewer?.set_auto_zoom();
4534
5802
  }
4535
- function syncPosition() {
5803
+ function syncPosition(preserveFocus = false) {
4536
5804
  const record = records.get(modelID);
4537
5805
  for (const axis of ["x", "y", "z"]) {
4538
5806
  const input = $("position-" + axis);
4539
- input.value = Number((record?.position?.[axis] || 0).toPrecision(12));
5807
+ if (!preserveFocus || document2.activeElement !== input)
5808
+ input.value = Number((record?.position?.[axis] || 0).toPrecision(12));
4540
5809
  input.disabled = !record;
4541
5810
  input.removeAttribute("aria-invalid");
4542
5811
  }
4543
5812
  $("position-error").hidden = true;
4544
- $("position-units").textContent = record?.units === "mm" ? "Offset from file coordinates, in mm." : "Offset from file coordinates, in model units.";
5813
+ $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
4545
5814
  $("move-model-button").disabled = !record || busy;
4546
5815
  $("reset-position").disabled = !record;
4547
- syncRotation();
5816
+ syncRotation(preserveFocus);
4548
5817
  for (const id of [
4549
5818
  "rotate-model-button",
4550
5819
  "depth-model-button",
@@ -4573,14 +5842,16 @@ function mountViewstlApp(root, options = {}) {
4573
5842
  viewer.set_position(record.id, p.x, p.y, p.z);
4574
5843
  }
4575
5844
  function selectedTransform() {
5845
+ prepareManual(modelID);
4576
5846
  const record = records.get(modelID);
4577
5847
  return record ? { ...record, center: modelCenter(record), radius: modelRadius(record) } : null;
4578
5848
  }
4579
- function syncRotation() {
5849
+ function syncRotation(preserveFocus = false) {
4580
5850
  const record = records.get(modelID);
4581
5851
  for (const axis of ["x", "y", "z"]) {
4582
5852
  const input = $("rotation-" + axis);
4583
- input.value = Number((record?.rotation?.[axis] || 0).toPrecision(10));
5853
+ if (!preserveFocus || document2.activeElement !== input)
5854
+ input.value = Number((record?.rotation?.[axis] || 0).toPrecision(10));
4584
5855
  input.disabled = !record;
4585
5856
  input.removeAttribute("aria-invalid");
4586
5857
  }
@@ -4622,10 +5893,11 @@ function mountViewstlApp(root, options = {}) {
4622
5893
  $("model-tool").value = transformTool;
4623
5894
  $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
4624
5895
  }
4625
- function setMoveMode(enabled, tool = transformTool) {
5896
+ function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
4626
5897
  modelMover?.setEnabled(false);
4627
5898
  transformTool = tool;
4628
5899
  moveMode = !!enabled && modelID !== null && !busy;
5900
+ if (moveMode && !retainPaused) prepareManual(modelID);
4629
5901
  modelMover?.setEnabled(moveMode);
4630
5902
  syncTool();
4631
5903
  $("move-mode").hidden = !moveMode;
@@ -4649,6 +5921,7 @@ function mountViewstlApp(root, options = {}) {
4649
5921
  const input = $("rotation-" + axis);
4650
5922
  listen(input, "input", () => {
4651
5923
  if (modelID === null || !input.value.trim()) return;
5924
+ prepareManual(modelID);
4652
5925
  if (applyRotation(
4653
5926
  modelID,
4654
5927
  { ...records.get(modelID).rotation, [axis]: input.valueAsNumber },
@@ -4660,6 +5933,7 @@ function mountViewstlApp(root, options = {}) {
4660
5933
  });
4661
5934
  listen(input, "change", () => {
4662
5935
  if (modelID === null) return;
5936
+ prepareManual(modelID);
4663
5937
  if (!input.value.trim() || !applyRotation(modelID, {
4664
5938
  ...records.get(modelID).rotation,
4665
5939
  [axis]: input.valueAsNumber
@@ -4672,6 +5946,7 @@ function mountViewstlApp(root, options = {}) {
4672
5946
  listen($("reset-rotation"), "click", () => {
4673
5947
  setMoveMode(false);
4674
5948
  if (modelID !== null) {
5949
+ prepareManual(modelID);
4675
5950
  applyRotation(modelID, {
4676
5951
  ...records.get(modelID).originalRotation || { x: 0, y: 0, z: 0 }
4677
5952
  });
@@ -4711,6 +5986,7 @@ function mountViewstlApp(root, options = {}) {
4711
5986
  const input = $("position-" + axis);
4712
5987
  listen(input, "input", () => {
4713
5988
  if (modelID === null || !input.value.trim()) return;
5989
+ prepareManual(modelID);
4714
5990
  if (applyPosition(
4715
5991
  modelID,
4716
5992
  { ...records.get(modelID).position, [axis]: input.valueAsNumber },
@@ -4722,6 +5998,7 @@ function mountViewstlApp(root, options = {}) {
4722
5998
  });
4723
5999
  listen(input, "change", () => {
4724
6000
  if (modelID === null) return;
6001
+ prepareManual(modelID);
4725
6002
  if (!input.value.trim() || !applyPosition(modelID, {
4726
6003
  ...records.get(modelID).position,
4727
6004
  [axis]: input.valueAsNumber
@@ -4738,6 +6015,7 @@ function mountViewstlApp(root, options = {}) {
4738
6015
  listen($("reset-position"), "click", () => {
4739
6016
  setMoveMode(false);
4740
6017
  if (modelID !== null) {
6018
+ prepareManual(modelID);
4741
6019
  applyPosition(modelID, {
4742
6020
  ...records.get(modelID).originalPosition || { x: 0, y: 0, z: 0 }
4743
6021
  });
@@ -4788,6 +6066,30 @@ function mountViewstlApp(root, options = {}) {
4788
6066
  renderScene();
4789
6067
  announce(name + " removed.");
4790
6068
  }
6069
+ function prepareManual(id) {
6070
+ if (!records.has(id)) return;
6071
+ syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6072
+ viewer?.animate_model(id, null);
6073
+ }
6074
+ function refreshAnimatedPose() {
6075
+ if (disposed || modelID === null) return;
6076
+ syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6077
+ if (!$("info-panel").hidden) populateInfo();
6078
+ if (!$("scene-panel").hidden && $("position-details").open)
6079
+ syncPosition(true);
6080
+ }
6081
+ function animationChanged(id, state, reason) {
6082
+ if (disposed) return;
6083
+ if (reason === "restored" && state.status === "running" && window2.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
6084
+ viewer.pause_model_animation(id);
6085
+ syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6086
+ if (["started", "restored", "resumed"].includes(reason))
6087
+ $("scene-arrangement").value = "custom";
6088
+ if (id === modelID) refreshAnimatedPose();
6089
+ animationUI?.sync();
6090
+ printUI?.sync();
6091
+ options.animation_state_callback?.(id, state, reason);
6092
+ }
4791
6093
  async function getViewer() {
4792
6094
  if (!viewerPromise)
4793
6095
  viewerPromise = Promise.resolve().then(async () => {
@@ -4802,6 +6104,10 @@ function mountViewstlApp(root, options = {}) {
4802
6104
  center_models: false,
4803
6105
  bg_color: background,
4804
6106
  allow_drag_and_drop: false,
6107
+ animation_state_callback: animationChanged,
6108
+ animation_complete_callback: (...args) => {
6109
+ if (!disposed) options.animation_complete_callback?.(...args);
6110
+ },
4805
6111
  camera_state: {
4806
6112
  position: { x: 90, y: -130, z: 90 },
4807
6113
  target: { x: 0, y: 0, z: 0 },
@@ -4893,7 +6199,7 @@ function mountViewstlApp(root, options = {}) {
4893
6199
  if (queue.length && !draining) drainPromise = drainQueue();
4894
6200
  return drainPromise || Promise.resolve();
4895
6201
  }
4896
- function openURL(value, format = "") {
6202
+ function openURL(value, format2 = "") {
4897
6203
  assertActive();
4898
6204
  if (!options.url_import_endpoint)
4899
6205
  throw new Error("URL imports are not configured.");
@@ -4902,7 +6208,7 @@ function mountViewstlApp(root, options = {}) {
4902
6208
  throw new Error(
4903
6209
  "Use a public HTTP or HTTPS file URL without login credentials."
4904
6210
  );
4905
- if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
6211
+ if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
4906
6212
  throw new Error("Choose a supported 3D format.");
4907
6213
  url.hash = "";
4908
6214
  const name = decodeURIComponent(
@@ -4911,9 +6217,9 @@ function mountViewstlApp(root, options = {}) {
4911
6217
  queue.push({
4912
6218
  name,
4913
6219
  remote: url.href,
4914
- format,
6220
+ format: format2,
4915
6221
  size: 0,
4916
- scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6222
+ scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
4917
6223
  });
4918
6224
  if (!draining) drainPromise = drainQueue();
4919
6225
  return drainPromise || Promise.resolve();
@@ -4982,7 +6288,7 @@ function mountViewstlApp(root, options = {}) {
4982
6288
  await engine.when_idle();
4983
6289
  if (token !== generation) continue;
4984
6290
  loadingID = null;
4985
- const loaded = lastLoadedID === id ? engine.get_model_info(id) : null;
6291
+ const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
4986
6292
  if (!loaded) {
4987
6293
  announce(source.name + " could not be opened.");
4988
6294
  continue;
@@ -5074,7 +6380,7 @@ function mountViewstlApp(root, options = {}) {
5074
6380
  );
5075
6381
  const record = {
5076
6382
  id,
5077
- name: String(m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6383
+ name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
5078
6384
  size: g.file_bytes ?? m.local_file?.size ?? null,
5079
6385
  units: g.units || m.units,
5080
6386
  bounds,
@@ -5383,6 +6689,64 @@ function mountViewstlApp(root, options = {}) {
5383
6689
  },
5384
6690
  announce
5385
6691
  });
6692
+ if ($("animation-details"))
6693
+ animationUI = bindAppAnimation({
6694
+ $,
6695
+ listen,
6696
+ window: window2,
6697
+ getState: () => ({
6698
+ busy,
6699
+ selectedId: modelID,
6700
+ models: [...records.values()]
6701
+ }),
6702
+ getViewer: () => viewer,
6703
+ beforePlay(id) {
6704
+ setMoveMode(false);
6705
+ prepareManual(id);
6706
+ centerAnimationPivot(records.get(id), viewer._models.get(id));
6707
+ $("scene-arrangement").value = "custom";
6708
+ },
6709
+ refreshPose: refreshAnimatedPose,
6710
+ announce
6711
+ });
6712
+ if ($("size-details"))
6713
+ toolsUI = bindAppTools({
6714
+ $,
6715
+ listen,
6716
+ window: window2,
6717
+ getViewer: () => viewer,
6718
+ getState: () => ({ selectedId: modelID, busy }),
6719
+ refresh: renderScene,
6720
+ announce,
6721
+ changeSize(operation) {
6722
+ setMoveMode(false);
6723
+ const record = records.get(modelID);
6724
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6725
+ operation();
6726
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6727
+ $("scene-arrangement").value = "custom";
6728
+ renderScene();
6729
+ },
6730
+ duplicate() {
6731
+ const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6732
+ const record = {
6733
+ ...original,
6734
+ id,
6735
+ name: m.display_name,
6736
+ position: { ...original.position },
6737
+ rotation: { ...original.rotation }
6738
+ };
6739
+ syncAnimatedRecord(record, m);
6740
+ record.originalPosition = { ...record.position };
6741
+ record.originalRotation = { ...record.rotation };
6742
+ records.set(id, record);
6743
+ nextID = Math.max(nextID, id + 1);
6744
+ $("scene-arrangement").value = "custom";
6745
+ selectModel(id);
6746
+ setMoveMode(true, "move", true);
6747
+ announce("Copy selected at the same position. Drag to move it.");
6748
+ }
6749
+ });
5386
6750
  updateGrid();
5387
6751
  setBusy(false);
5388
6752
  setMoveMode(false);
@@ -5407,6 +6771,8 @@ function mountViewstlApp(root, options = {}) {
5407
6771
  },
5408
6772
  getState() {
5409
6773
  assertActive();
6774
+ for (const record of records.values())
6775
+ syncAnimatedRecord(record, viewer?._models.get(record.id));
5410
6776
  return {
5411
6777
  selectedId: modelID,
5412
6778
  busy,
@@ -5419,7 +6785,8 @@ function mountViewstlApp(root, options = {}) {
5419
6785
  units: record.units,
5420
6786
  position: { ...record.position },
5421
6787
  rotation: { ...record.rotation },
5422
- color: record.color
6788
+ color: record.color,
6789
+ animation: viewer.get_model_animation_state(record.id)
5423
6790
  }))
5424
6791
  };
5425
6792
  },
@@ -5427,6 +6794,8 @@ function mountViewstlApp(root, options = {}) {
5427
6794
  if (disposed) return;
5428
6795
  exportUI?.dispose();
5429
6796
  printUI?.dispose();
6797
+ animationUI?.dispose();
6798
+ toolsUI?.dispose();
5430
6799
  cancelLoading();
5431
6800
  setMoveMode(false);
5432
6801
  appearanceRequest++;