@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
@@ -1,52 +1,4 @@
1
- // src/v2/appearance.js
2
- var DISPLAYS = [
3
- "flat",
4
- "smooth",
5
- "wireframe",
6
- "edges",
7
- "mesh",
8
- "xray",
9
- "studio",
10
- "orientation"
11
- ];
12
- function validateView(display, smooth) {
13
- if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
14
- if (typeof smooth !== "boolean")
15
- throw new Error("smooth_shading must be a boolean");
16
- return { display, smooth_shading: display === "smooth" || smooth };
17
- }
18
- function viewFeatures(view) {
19
- return {
20
- smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
21
- edges: view.view_edges || view.display === "edges",
22
- wire: view.display === "wireframe",
23
- edgeAngle: view.display === "edges" ? 30 : 1
24
- };
25
- }
26
- function validateSection(section) {
27
- if (section == null) return null;
28
- if (!["x", "y", "z"].includes(section.axis))
29
- throw new Error("Section axis must be x, y or z");
30
- if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
31
- throw new Error("Section position must be between 0 and 1");
32
- if (section.flip !== void 0 && typeof section.flip !== "boolean")
33
- throw new Error("Section flip must be a boolean");
34
- return {
35
- axis: section.axis,
36
- position: section.position,
37
- flip: section.flip ?? false
38
- };
39
- }
40
- function sectionPlane(bounds, section) {
41
- if (!section) return [0, 0, 0, 0];
42
- const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
43
- const plane = [0, 0, 0, 0];
44
- plane[axis] = sign;
45
- plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
46
- return plane;
47
- }
48
-
49
- // src/v2/math.js
1
+ // src/math.js
50
2
  var add = (a, b) => a.map((v, i) => v + b[i]);
51
3
  var sub = (a, b) => a.map((v, i) => v - b[i]);
52
4
  var mul = (a, s) => a.map((v) => v * s);
@@ -198,7 +150,178 @@ function rayTriangle(o, d, p, i) {
198
150
  return t >= 0 ? t : Infinity;
199
151
  }
200
152
 
201
- // src/v2/memory.js
153
+ // src/animation.js
154
+ var animationKeys = [
155
+ "x",
156
+ "y",
157
+ "z",
158
+ "rotationx",
159
+ "rotationy",
160
+ "rotationz",
161
+ "scalex",
162
+ "scaley",
163
+ "scalez"
164
+ ];
165
+ var isScale = (key) => key.startsWith("scale");
166
+ var number = (value, name, positive2 = false) => {
167
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
168
+ throw new Error(`Invalid animation ${name}`);
169
+ return value;
170
+ };
171
+ var object = (value, name) => {
172
+ if (!value || typeof value !== "object" || Array.isArray(value))
173
+ throw new Error(`Invalid animation ${name}`);
174
+ return value;
175
+ };
176
+ function animationPose(model) {
177
+ return Object.fromEntries(
178
+ animationKeys.map((key) => [
179
+ key,
180
+ number(
181
+ model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
182
+ key,
183
+ isScale(key)
184
+ )
185
+ ])
186
+ );
187
+ }
188
+ function normalizeAnimation(input) {
189
+ object(input, "definition");
190
+ const result = {}, claimed = /* @__PURE__ */ new Set();
191
+ for (const mode of ["delta", "exact"]) {
192
+ if (input[mode] == null) continue;
193
+ const source = object(input[mode], mode), values = {};
194
+ const msec = number(source.msec ?? 300, "duration", true);
195
+ if (source.loop !== void 0 && typeof source.loop !== "boolean")
196
+ throw new Error("Animation loop must be a boolean");
197
+ const loop = mode === "delta" && !!source.loop;
198
+ for (const key of animationKeys) {
199
+ const value = source[key] ?? (isScale(key) ? source.scale : void 0);
200
+ if (value === void 0) continue;
201
+ if (claimed.has(key))
202
+ throw new Error(`Animation delta/exact conflict: ${key}`);
203
+ number(value, key, mode === "exact" && isScale(key));
204
+ if (loop && isScale(key) && value < 0)
205
+ throw new Error("Looping scale deltas cannot be negative");
206
+ values[key] = value;
207
+ claimed.add(key);
208
+ }
209
+ if (!Object.keys(values).length)
210
+ throw new Error("Animation track needs a transform");
211
+ result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
212
+ }
213
+ if (!Object.keys(result).length)
214
+ throw new Error("Animation needs delta or exact");
215
+ return result;
216
+ }
217
+ function normalizePlaybackState(input) {
218
+ object(input, "playback state");
219
+ if (input.version !== 1 || typeof input.paused !== "boolean")
220
+ throw new Error("Invalid animation playback state version or paused flag");
221
+ const elapsed_ms = number(input.elapsed_ms, "elapsed time");
222
+ if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
223
+ object(input.origin, "origin");
224
+ for (const key of animationKeys) number(input.origin[key], key, isScale(key));
225
+ if (!Array.isArray(input.center) || input.center.length !== 3)
226
+ throw new Error("Invalid animation pivot");
227
+ return {
228
+ version: 1,
229
+ elapsed_ms,
230
+ paused: input.paused,
231
+ origin: animationPose(input.origin),
232
+ center: input.center.map((v) => number(v, "pivot"))
233
+ };
234
+ }
235
+ function createPlayback(definition, model, saved) {
236
+ const animation = normalizeAnimation(definition);
237
+ const state = saved == null ? null : normalizePlaybackState(saved);
238
+ const origin = state?.origin ?? animationPose(model);
239
+ const tracks = Object.entries(animation).map(([mode, a]) => {
240
+ const values = Object.entries(a).filter(
241
+ ([key]) => animationKeys.includes(key)
242
+ );
243
+ if (mode === "delta")
244
+ for (const [key, value] of values)
245
+ number(origin[key] + value, `${key} endpoint`, isScale(key));
246
+ return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
247
+ });
248
+ const playback = {
249
+ animation,
250
+ origin,
251
+ tracks,
252
+ elapsed_ms: state?.elapsed_ms ?? 0,
253
+ paused: state?.paused ?? false,
254
+ last_time: null
255
+ };
256
+ evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
257
+ return playback;
258
+ }
259
+ function evaluatePlayback(playback, model) {
260
+ const updates = [];
261
+ for (const track of playback.tracks) {
262
+ if (track.done) continue;
263
+ const fraction = playback.elapsed_ms / track.duration;
264
+ const p = track.loop ? fraction : Math.min(fraction, 1);
265
+ for (const [key, value] of track.values) {
266
+ const from = playback.origin[key];
267
+ const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
268
+ updates.push([key, number(to, `${key} result`, isScale(key))]);
269
+ }
270
+ }
271
+ let scaleChanged = false;
272
+ for (const [key, value] of updates) {
273
+ scaleChanged ||= isScale(key) && model[key] !== value;
274
+ model[key] = value;
275
+ }
276
+ for (const track of playback.tracks)
277
+ if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
278
+ if (scaleChanged) model._stats = null;
279
+ return playback.tracks.some((track) => !track.done);
280
+ }
281
+ function advancePlayback(playback, model, time) {
282
+ if (playback.paused) return true;
283
+ if (playback.last_time !== null)
284
+ playback.elapsed_ms = number(
285
+ playback.elapsed_ms + Math.max(0, time - playback.last_time),
286
+ "elapsed time"
287
+ );
288
+ playback.last_time = time;
289
+ return evaluatePlayback(playback, model);
290
+ }
291
+ function playbackState(playback, suspended = false) {
292
+ return {
293
+ status: playback.paused ? "paused" : "running",
294
+ elapsed_ms: playback.elapsed_ms,
295
+ suspended,
296
+ tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
297
+ mode: t.mode,
298
+ msec: t.duration,
299
+ loop: t.loop,
300
+ progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
301
+ }))
302
+ };
303
+ }
304
+ function savePlayback(model) {
305
+ const p = model._animation;
306
+ if (!p) return null;
307
+ const factor = model.unitScale ?? 1;
308
+ const animation = structuredClone(p.animation), origin = { ...p.origin };
309
+ for (const key of animationKeys.filter(isScale)) {
310
+ origin[key] *= factor;
311
+ for (const track of Object.values(animation))
312
+ if (track[key] !== void 0) track[key] *= factor;
313
+ }
314
+ const animation_state = normalizePlaybackState({
315
+ version: 1,
316
+ origin,
317
+ elapsed_ms: p.elapsed_ms,
318
+ paused: p.paused,
319
+ center: [...model.center]
320
+ });
321
+ return { animation: normalizeAnimation(animation), animation_state };
322
+ }
323
+
324
+ // src/memory.js
202
325
  var MEMORY_OVERHEAD = 64 * 1024 * 1024;
203
326
  var TRIANGLE_MEMORY = 224;
204
327
  function geometryMemory(g) {
@@ -215,7 +338,420 @@ function derivedMemory(triangles, features) {
215
338
  return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
216
339
  }
217
340
 
218
- // src/v2/geometry.js
341
+ // src/print-options.js
342
+ function printOptions(input = {}) {
343
+ const level = input.level ?? "basic";
344
+ if (!["basic", "detailed"].includes(level))
345
+ throw new Error("Choose basic or detailed print checks");
346
+ const number2 = (v, name, max = 1e9) => {
347
+ if (!Number.isFinite(v) || v <= 0 || v > max)
348
+ throw new Error(`Invalid ${name}`);
349
+ return v;
350
+ };
351
+ let profile = null;
352
+ if (input.profile != null) {
353
+ if (input.profile.technology !== "fdm")
354
+ throw new Error("Only FDM profiles are supported");
355
+ profile = { technology: "fdm" };
356
+ for (const key of ["min_wall_mm", "line_width_mm"])
357
+ if (input.profile[key] != null)
358
+ profile[key] = number2(input.profile[key], key);
359
+ if (input.profile.overhang_degrees != null)
360
+ profile.overhang_degrees = number2(
361
+ input.profile.overhang_degrees,
362
+ "overhang angle",
363
+ 89.9
364
+ );
365
+ if (input.profile.build_volume_mm != null) {
366
+ if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
367
+ throw new Error("Build volume requires X, Y and Z");
368
+ profile.build_volume_mm = input.profile.build_volume_mm.map(
369
+ (v) => number2(v, "build volume")
370
+ );
371
+ }
372
+ }
373
+ const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
374
+ return {
375
+ level,
376
+ profile,
377
+ apply_transforms: input.apply_transforms !== false,
378
+ units_confirmed: input.units_confirmed === true,
379
+ max_time_ms: time
380
+ };
381
+ }
382
+ var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
383
+ var printSignature = (model) => JSON.stringify([
384
+ model.x,
385
+ model.y,
386
+ model.z,
387
+ model.rotationx,
388
+ model.rotationy,
389
+ model.rotationz,
390
+ model.scaleVector,
391
+ model.center,
392
+ model.units,
393
+ model.unitScale
394
+ ]);
395
+
396
+ // src/model-tools.js
397
+ var axes = ["x", "y", "z"];
398
+ function validateUnits(units) {
399
+ if (!["mm", "inch"].includes(units)) throw new Error("Choose mm or inch");
400
+ return units;
401
+ }
402
+ function validateSizeState(value) {
403
+ 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))
404
+ throw new Error("Invalid saved model size");
405
+ return {
406
+ version: 1,
407
+ baseline_mm: [...value.baseline_mm],
408
+ file_units: validateUnits(value.file_units),
409
+ editable: value.editable,
410
+ unit_factor: value.unit_factor ?? 1
411
+ };
412
+ }
413
+ function sizeState(model) {
414
+ return model.size_state ??= {
415
+ version: 1,
416
+ baseline_mm: model.geometry.bounds.max.map(
417
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.unitScale
418
+ ),
419
+ file_units: "mm",
420
+ editable: !model.geometry.units,
421
+ unit_factor: 1
422
+ };
423
+ }
424
+ function modelSize(model) {
425
+ if (!model.geometry) throw new Error("Wait for the model to finish opening");
426
+ const state = sizeState(model);
427
+ return {
428
+ ...structuredClone(state),
429
+ dimensions_mm: model.geometry.bounds.max.map(
430
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.scaleVector[i]
431
+ )
432
+ };
433
+ }
434
+ function resizeModel(id, factors) {
435
+ const m = this._model(id);
436
+ modelSize(m);
437
+ if (!Array.isArray(factors) || factors.length !== 3 || factors.some((v) => !Number.isFinite(v) || v <= 0))
438
+ throw new Error("Scale must be a positive finite number");
439
+ const next = axes.map((a, i) => m["scale" + a] * factors[i]);
440
+ if (next.some((v) => !Number.isFinite(v) || v < 1e-12 || v > 1e12))
441
+ throw new Error("Scale is outside the supported range");
442
+ const center = m.geometry.bounds.min.map(
443
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
444
+ );
445
+ const before = transform(modelMatrix(m), center);
446
+ const cached = m._measurements;
447
+ const oldKey = m.scaleVector.join(",");
448
+ this._stopAnimation(m, "manual");
449
+ axes.forEach((a, i) => m["scale" + a] = next[i]);
450
+ const after = transform(modelMatrix(m), center);
451
+ axes.forEach((a, i) => m[a] += before[i] - after[i]);
452
+ m._stats = null;
453
+ if (cached?.key === oldKey && cached.positions === m.geometry.positions && factors.every((v) => v === factors[0])) {
454
+ m._measurements = {
455
+ ...cached,
456
+ key: m.scaleVector.join(","),
457
+ result: {
458
+ ...cached.result,
459
+ area_mm2: cached.result.area_mm2 * factors[0] ** 2,
460
+ volume_mm3: cached.result.volume_mm3 == null ? null : cached.result.volume_mm3 * factors[0] ** 3
461
+ }
462
+ };
463
+ }
464
+ this._invalidate();
465
+ return modelSize(m);
466
+ }
467
+ function setFileUnits(id, units) {
468
+ validateUnits(units);
469
+ const m = this._model(id), state = sizeState(m);
470
+ if (!state.editable)
471
+ throw new Error(
472
+ "This model has explicit file units, already converted to millimetres"
473
+ );
474
+ if (state.file_units === units) return modelSize(m);
475
+ const factor = units === "inch" ? 25.4 : 1 / 25.4;
476
+ const result = resizeModel.call(this, id, [factor, factor, factor]);
477
+ state.baseline_mm = state.baseline_mm.map((v) => v * factor);
478
+ state.file_units = units;
479
+ state.unit_factor = (state.unit_factor ?? 1) * factor;
480
+ return { ...result, ...structuredClone(state) };
481
+ }
482
+ function resetModelSize(id) {
483
+ const size = modelSize(this._model(id));
484
+ return resizeModel.call(
485
+ this,
486
+ id,
487
+ size.dimensions_mm.map((v, i) => v ? size.baseline_mm[i] / v : 1)
488
+ );
489
+ }
490
+ function duplicateModel(id) {
491
+ const source = this._model(id);
492
+ modelSize(source);
493
+ if (source._sceneImportPending)
494
+ throw new Error("Wait for the scene to finish opening");
495
+ if (this._models.size + 1 > this.limits.max_archive_entries || [...this._models.values()].reduce(
496
+ (n, m) => n + (m.geometry?.triangles || 0),
497
+ source.geometry.triangles
498
+ ) > this.limits.max_triangles)
499
+ throw new Error("Scene model or triangle limit exceeded");
500
+ assertMemory(
501
+ this.limits,
502
+ this._occupiedMemory() + geometryMemory(source.geometry),
503
+ "duplicate"
504
+ );
505
+ const copy = {};
506
+ for (const key of [
507
+ "filename",
508
+ "orig_filename",
509
+ "local_file",
510
+ "x",
511
+ "y",
512
+ "z",
513
+ "rotationx",
514
+ "rotationy",
515
+ "rotationz",
516
+ "opacity",
517
+ "units",
518
+ "display",
519
+ "view_edges",
520
+ "smooth_shading",
521
+ "color"
522
+ ])
523
+ copy[key] = source[key];
524
+ copy.id = this.max_model_id + 1;
525
+ if (!Number.isSafeInteger(copy.id))
526
+ throw new Error("Model ID limit exceeded");
527
+ copy.display_name = (source.display_name || source.orig_filename || source.filename || "Model").split(/[\\/]/).pop() + " \u2014 copy";
528
+ copy.center = [...source.center];
529
+ copy.unitScale = 1;
530
+ axes.forEach((a, i) => copy["scale" + a] = source.scaleVector[i]);
531
+ Object.defineProperty(copy, "scaleVector", {
532
+ get: () => axes.map((a) => copy["scale" + a] * copy.unitScale)
533
+ });
534
+ copy.geometry = { ...source.geometry };
535
+ copy.section = source.section ? { ...source.section } : null;
536
+ copy.size_state = structuredClone(sizeState(source));
537
+ copy.size_state.unit_factor = (copy.size_state.unit_factor ?? 1) * source.unitScale;
538
+ const saved = savePlayback(source);
539
+ this.max_model_id = copy.id;
540
+ this._models.set(copy.id, copy);
541
+ this.models_ref[copy.id] = this.models.length;
542
+ this.models.push(copy);
543
+ this.models_count = this._models.size;
544
+ if (saved)
545
+ this._startAnimation(copy, saved.animation, {
546
+ ...saved.animation_state,
547
+ paused: true
548
+ });
549
+ this._invalidate();
550
+ return copy.id;
551
+ }
552
+ var cameraDirections = {
553
+ front: [
554
+ [0, -1, 0],
555
+ [0, 0, 1]
556
+ ],
557
+ back: [
558
+ [0, 1, 0],
559
+ [0, 0, 1]
560
+ ],
561
+ left: [
562
+ [-1, 0, 0],
563
+ [0, 0, 1]
564
+ ],
565
+ right: [
566
+ [1, 0, 0],
567
+ [0, 0, 1]
568
+ ],
569
+ top: [
570
+ [0, 0, 1],
571
+ [0, 1, 0]
572
+ ],
573
+ bottom: [
574
+ [0, 0, -1],
575
+ [0, -1, 0]
576
+ ],
577
+ isometric: [
578
+ [1, -1, 1],
579
+ [0, 0, 1]
580
+ ]
581
+ };
582
+ function cameraView(name) {
583
+ const view = cameraDirections[name];
584
+ if (!view) throw new Error("Unknown camera view");
585
+ this.camera.position = this.camera.target.map((v, i) => v + view[0][i]);
586
+ this.camera.up = [...view[1]];
587
+ this.set_auto_zoom();
588
+ }
589
+ function validateBookmarks(bookmarks) {
590
+ if (!Array.isArray(bookmarks) || bookmarks.length > 32)
591
+ throw new Error("Save up to 32 camera views");
592
+ const names = /* @__PURE__ */ new Set();
593
+ return bookmarks.map((b) => {
594
+ const name = typeof b?.name === "string" ? b.name.trim() : "";
595
+ if (!name || name.length > 80 || /[\x00-\x1f]/.test(name) || names.has(name))
596
+ throw new Error("Use a unique view name of 1\u201380 characters");
597
+ names.add(name);
598
+ const state = {};
599
+ for (const key of ["position", "target", "up"]) {
600
+ if (!b.camera?.[key] || axes.some(
601
+ (a) => !Number.isFinite(b.camera[key][a]) || Math.abs(b.camera[key][a]) > 1e15
602
+ ))
603
+ throw new Error("Invalid saved camera");
604
+ state[key] = Object.fromEntries(axes.map((a) => [a, b.camera[key][a]]));
605
+ }
606
+ const d = axes.map((a) => state.position[a] - state.target[a]), u = axes.map((a) => state.up[a]);
607
+ if (Math.hypot(
608
+ d[1] * u[2] - d[2] * u[1],
609
+ d[2] * u[0] - d[0] * u[2],
610
+ d[0] * u[1] - d[1] * u[0]
611
+ ) < 1e-12)
612
+ throw new Error("Degenerate saved camera");
613
+ return { name, camera: state };
614
+ });
615
+ }
616
+ function mergeBookmarks(existing, incoming) {
617
+ const result = validateBookmarks(existing), names = new Set(result.map((b) => b.name));
618
+ for (const b of validateBookmarks(incoming)) {
619
+ let name = b.name, n = 2;
620
+ while (names.has(name)) name = b.name.slice(0, 70) + ` (${n++})`;
621
+ names.add(name);
622
+ result.push({ ...b, name });
623
+ }
624
+ return validateBookmarks(result);
625
+ }
626
+ async function measureModel(id, options = {}) {
627
+ await this.when_ready;
628
+ const m = this._model(id), g = m.geometry;
629
+ if (!g || m._sceneImportPending)
630
+ throw new Error("Wait for the model to finish opening");
631
+ const scale = m.scaleVector, key = scale.join(","), cached = m._measurements;
632
+ if (options.signal?.aborted)
633
+ throw new DOMException("Measurement cancelled", "AbortError");
634
+ if (cached?.positions === g.positions && cached.key === key)
635
+ return { ...cached.result };
636
+ const working = printMemory(g.triangles), occupied = this._occupiedMemory();
637
+ assertMemory(this.limits, occupied + working, "measurements");
638
+ const release = this._reserveMemory(
639
+ m,
640
+ working + geometryMemory(g),
641
+ "measurements"
642
+ );
643
+ const controller = new AbortController(), cancel = () => controller.abort();
644
+ this._aborters.add(controller);
645
+ (m._measureJobs ??= /* @__PURE__ */ new Set()).add(controller);
646
+ options.signal?.addEventListener("abort", cancel, { once: true });
647
+ let job, callbackError;
648
+ const check = () => {
649
+ if (controller.signal.aborted || this.killsign)
650
+ throw new DOMException("Measurement cancelled", "AbortError");
651
+ if (this._models.get(id) !== m || m.geometry.positions !== g.positions || m.scaleVector.join(",") !== key)
652
+ throw new Error("Model size changed. Calculate again.");
653
+ };
654
+ try {
655
+ const positions = new Float32Array(g.positions.length);
656
+ for (let start = 0; start < positions.length; start += 262144) {
657
+ check();
658
+ positions.set(g.positions.subarray(start, start + 262144), start);
659
+ if (start + 262144 < positions.length)
660
+ await new Promise((r) => setTimeout(r, 0));
661
+ }
662
+ job = this._pool.run(
663
+ {
664
+ kind: "measure",
665
+ positions,
666
+ scale,
667
+ area: g.area,
668
+ max_time_ms: options.max_time_ms ?? 3e4,
669
+ limits: {
670
+ ...this.limits,
671
+ max_memory_bytes: this.limits.max_memory_bytes - occupied
672
+ }
673
+ },
674
+ (progress) => {
675
+ try {
676
+ options.on_progress?.(progress);
677
+ } catch (error) {
678
+ callbackError = error;
679
+ controller.abort();
680
+ }
681
+ },
682
+ [positions.buffer]
683
+ );
684
+ controller.signal.addEventListener("abort", () => job.cancel(), {
685
+ once: true
686
+ });
687
+ check();
688
+ let result;
689
+ try {
690
+ result = await job;
691
+ } catch (error) {
692
+ throw callbackError || error;
693
+ }
694
+ check();
695
+ m._measurements = { positions: g.positions, key, result };
696
+ return { ...result };
697
+ } finally {
698
+ job?.cancel?.();
699
+ release();
700
+ this._aborters.delete(controller);
701
+ m._measureJobs.delete(controller);
702
+ options.signal?.removeEventListener("abort", cancel);
703
+ }
704
+ }
705
+
706
+ // src/appearance.js
707
+ var DISPLAYS = [
708
+ "flat",
709
+ "smooth",
710
+ "wireframe",
711
+ "edges",
712
+ "mesh",
713
+ "xray",
714
+ "studio",
715
+ "orientation"
716
+ ];
717
+ function validateView(display, smooth) {
718
+ if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
719
+ if (typeof smooth !== "boolean")
720
+ throw new Error("smooth_shading must be a boolean");
721
+ return { display, smooth_shading: display === "smooth" || smooth };
722
+ }
723
+ function viewFeatures(view) {
724
+ return {
725
+ smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
726
+ edges: view.view_edges || view.display === "edges",
727
+ wire: view.display === "wireframe",
728
+ edgeAngle: view.display === "edges" ? 30 : 1
729
+ };
730
+ }
731
+ function validateSection(section) {
732
+ if (section == null) return null;
733
+ if (!["x", "y", "z"].includes(section.axis))
734
+ throw new Error("Section axis must be x, y or z");
735
+ if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
736
+ throw new Error("Section position must be between 0 and 1");
737
+ if (section.flip !== void 0 && typeof section.flip !== "boolean")
738
+ throw new Error("Section flip must be a boolean");
739
+ return {
740
+ axis: section.axis,
741
+ position: section.position,
742
+ flip: section.flip ?? false
743
+ };
744
+ }
745
+ function sectionPlane(bounds, section) {
746
+ if (!section) return [0, 0, 0, 0];
747
+ const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
748
+ const plane = [0, 0, 0, 0];
749
+ plane[axis] = sign;
750
+ plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
751
+ return plane;
752
+ }
753
+
754
+ // src/geometry.js
219
755
  var DEFAULT_LIMITS = Object.freeze({
220
756
  max_file_bytes: 256 * 1024 * 1024,
221
757
  max_triangles: 5e6,
@@ -363,7 +899,7 @@ function exportSTL(g, scale = [1, 1, 1], center = [0, 0, 0]) {
363
899
  return buffer;
364
900
  }
365
901
 
366
- // src/v2/infinite-grid.js
902
+ // src/infinite-grid.js
367
903
  var vertex = `#version 300 es
368
904
  precision highp float;
369
905
  out vec2 screen;
@@ -455,7 +991,7 @@ var InfiniteGrid = class {
455
991
  }
456
992
  };
457
993
 
458
- // src/v2/selection-frame.js
994
+ // src/selection-frame.js
459
995
  function selectionFrame(bounds) {
460
996
  const padding = Math.max(
461
997
  Math.max(...bounds.max.map((v, i) => v - bounds.min[i])) * 0.015,
@@ -473,7 +1009,7 @@ function selectionFrame(bounds) {
473
1009
  return new Float32Array(lines);
474
1010
  }
475
1011
 
476
- // src/v2/renderer.js
1012
+ // src/renderer.js
477
1013
  var vertex2 = `#version 300 es
478
1014
  precision highp float;
479
1015
  layout(location=0) in vec3 position;
@@ -509,6 +1045,7 @@ var Renderer = class {
509
1045
  this.canvas = canvas;
510
1046
  this.domElement = canvas;
511
1047
  this.resources = /* @__PURE__ */ new Map();
1048
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
512
1049
  this.frames = 0;
513
1050
  this.lost = false;
514
1051
  this.gl = canvas.getContext("webgl2", {
@@ -525,6 +1062,7 @@ var Renderer = class {
525
1062
  e.preventDefault();
526
1063
  this.lost = true;
527
1064
  this.resources.clear();
1065
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
528
1066
  this.gridResource = null;
529
1067
  this.infiniteGrid = null;
530
1068
  this.selectionResource = null;
@@ -632,7 +1170,12 @@ var Renderer = class {
632
1170
  if (this.diagnostic?.model === model) this.setDiagnostic(null);
633
1171
  const resources = this.resources.get(model);
634
1172
  if (resources)
635
- for (const resource of Object.values(resources)) this.free(resource);
1173
+ for (const resource of Object.values(resources)) {
1174
+ if (--resource.users === 0) {
1175
+ this.sharedResources.get(resource.source)?.delete(resource);
1176
+ this.free(resource);
1177
+ }
1178
+ }
636
1179
  this.resources.delete(model);
637
1180
  }
638
1181
  clear() {
@@ -646,8 +1189,10 @@ var Renderer = class {
646
1189
  }
647
1190
  get liveBuffers() {
648
1191
  let n = (this.gridResource?.buffers.length || 0) + (this.selectionResource?.buffers.length || 0) + (this.diagnosticResource?.buffers.length || 0);
1192
+ const unique = /* @__PURE__ */ new Set();
649
1193
  for (const r of this.resources.values())
650
- for (const b of Object.values(r)) n += b.buffers.length;
1194
+ for (const b of Object.values(r)) unique.add(b);
1195
+ for (const b of unique) n += b.buffers.length;
651
1196
  return n;
652
1197
  }
653
1198
  setDiagnostic(value) {
@@ -713,12 +1258,23 @@ var Renderer = class {
713
1258
  this.resources.set(model, r);
714
1259
  }
715
1260
  if (!r[mode]) {
716
- const g = model.geometry;
717
- r[mode] = mode === "edges" || mode === "wire" ? this.upload(g[mode]) : this.upload(
718
- g.positions,
719
- mode === "smooth" ? g.smoothNormals : g.normals,
720
- g.colors
1261
+ 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;
1262
+ let entries = this.sharedResources.get(source);
1263
+ if (!entries) this.sharedResources.set(source, entries = /* @__PURE__ */ new Set());
1264
+ let resource = [...entries].find(
1265
+ (r2) => r2.normals === normals && r2.colors === colors
721
1266
  );
1267
+ if (!resource) {
1268
+ resource = Object.assign(this.upload(source, normals, colors), {
1269
+ source,
1270
+ normals,
1271
+ colors,
1272
+ users: 0
1273
+ });
1274
+ entries.add(resource);
1275
+ }
1276
+ resource.users++;
1277
+ r[mode] = resource;
722
1278
  }
723
1279
  return r[mode];
724
1280
  }
@@ -894,7 +1450,7 @@ var Renderer = class {
894
1450
  }
895
1451
  };
896
1452
 
897
- // src/v2/controls.js
1453
+ // src/controls.js
898
1454
  var Controls = class {
899
1455
  constructor(canvas, camera, invalidate, pick, type = 0) {
900
1456
  this.enabled = true;
@@ -1005,7 +1561,7 @@ var Controls = class {
1005
1561
  }
1006
1562
  };
1007
1563
 
1008
- // src/v2/worker-pool.js
1564
+ // src/worker-pool.js
1009
1565
  var WorkerPool = class {
1010
1566
  constructor(url, { count = 2, timeout = 12e4, factory } = {}) {
1011
1567
  this.url = url;
@@ -1090,7 +1646,7 @@ var WorkerPool = class {
1090
1646
  }
1091
1647
  };
1092
1648
 
1093
- // src/v2/scene-import.js
1649
+ // src/scene-import.js
1094
1650
  var sceneFormat = (name) => String(name || "").match(/\.(vsb|vsj)(?:[?#]|$)/i)?.[1].toLowerCase() || "";
1095
1651
  function localSceneFile(reference, files) {
1096
1652
  let path;
@@ -1124,7 +1680,7 @@ function sceneURL(reference, base) {
1124
1680
  return url.href;
1125
1681
  }
1126
1682
 
1127
- // src/v2/scene-import-controller.js
1683
+ // src/scene-import-controller.js
1128
1684
  function importScene(source, options = {}) {
1129
1685
  const controller = new AbortController(), signal = controller.signal;
1130
1686
  const generation = this._generation, ids = [], added = /* @__PURE__ */ new Map(), usedFiles = /* @__PURE__ */ new Set();
@@ -1149,8 +1705,8 @@ function importScene(source, options = {}) {
1149
1705
  await this.when_ready;
1150
1706
  check();
1151
1707
  oldCamera = this.get_camera_state();
1152
- const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1153
- if (!["vsb", "vsj"].includes(format))
1708
+ const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1709
+ if (!["vsb", "vsj"].includes(format2))
1154
1710
  throw new Error("Choose VSB or VSJ for a scene");
1155
1711
  let remote, relay;
1156
1712
  if (typeof source === "string") {
@@ -1183,8 +1739,8 @@ function importScene(source, options = {}) {
1183
1739
  source,
1184
1740
  remote,
1185
1741
  relay,
1186
- format,
1187
- name: `scene.${format}`,
1742
+ format: format2,
1743
+ name: `scene.${format2}`,
1188
1744
  limits
1189
1745
  },
1190
1746
  (p) => {
@@ -1205,6 +1761,10 @@ function importScene(source, options = {}) {
1205
1761
  job = null;
1206
1762
  check();
1207
1763
  const { scene, files, base } = documentData;
1764
+ const bookmarks = mergeBookmarks(
1765
+ this.get_camera_bookmarks(),
1766
+ scene.camera_bookmarks || []
1767
+ );
1208
1768
  this._sceneBytes -= held;
1209
1769
  held = Object.values(files || {}).reduce((n, b) => n + b.byteLength, 0) + JSON.stringify(scene).length * 2;
1210
1770
  this._sceneBytes += held;
@@ -1239,7 +1799,7 @@ function importScene(source, options = {}) {
1239
1799
  progress({ phase: "opening-model", loaded: index, total: models.length });
1240
1800
  check();
1241
1801
  const input = models[index], id = this.max_model_id + 1;
1242
- this.add_model({ ...input, id });
1802
+ this.add_model({ ...input, id, animation: null, animation_state: null });
1243
1803
  const model = this._models.get(id);
1244
1804
  if (!model) throw new Error(this.error || "Invalid scene model");
1245
1805
  ids.push(id);
@@ -1264,7 +1824,9 @@ function importScene(source, options = {}) {
1264
1824
  }),
1265
1825
  delta
1266
1826
  );
1267
- ["x", "y", "z"].forEach((axis, i) => model[axis] += shift[i]);
1827
+ if (input.animation)
1828
+ model.center = [...input.animation_state?.center ?? desired];
1829
+ else ["x", "y", "z"].forEach((axis, i) => model[axis] += shift[i]);
1268
1830
  if ([...this._models.values()].reduce(
1269
1831
  (n, m) => n + (m.geometry?.triangles || 0),
1270
1832
  0
@@ -1277,7 +1839,12 @@ function importScene(source, options = {}) {
1277
1839
  total: models.length
1278
1840
  });
1279
1841
  check();
1280
- for (const model of added.values()) model._sceneImportPending = false;
1842
+ for (let index = 0; index < ids.length; index++) {
1843
+ const model = added.get(ids[index]), input = models[index];
1844
+ if (input.animation)
1845
+ this._startAnimation(model, input.animation, input.animation_state);
1846
+ model._sceneImportPending = false;
1847
+ }
1281
1848
  if (scene.bg_color !== void 0) this.set_bg_color(scene.bg_color);
1282
1849
  if (scene.infinite_grid)
1283
1850
  this.set_infinite_grid(
@@ -1289,6 +1856,8 @@ function importScene(source, options = {}) {
1289
1856
  if (scene.auto_rotate !== void 0)
1290
1857
  this.set_auto_rotate(scene.auto_rotate);
1291
1858
  this._restoreCamera = scene.camera_state || (hadModels ? oldCamera : null);
1859
+ this.camera_bookmarks = bookmarks;
1860
+ if (scene.display_units) this.display_units = scene.display_units;
1292
1861
  committed = true;
1293
1862
  return { model_ids: ids, scene, used_files: [...usedFiles] };
1294
1863
  })().catch((error) => {
@@ -1309,62 +1878,7 @@ function importScene(source, options = {}) {
1309
1878
  return promise;
1310
1879
  }
1311
1880
 
1312
- // src/v2/print-options.js
1313
- function printOptions(input = {}) {
1314
- const level = input.level ?? "basic";
1315
- if (!["basic", "detailed"].includes(level))
1316
- throw new Error("Choose basic or detailed print checks");
1317
- const number = (v, name, max = 1e9) => {
1318
- if (!Number.isFinite(v) || v <= 0 || v > max)
1319
- throw new Error(`Invalid ${name}`);
1320
- return v;
1321
- };
1322
- let profile = null;
1323
- if (input.profile != null) {
1324
- if (input.profile.technology !== "fdm")
1325
- throw new Error("Only FDM profiles are supported");
1326
- profile = { technology: "fdm" };
1327
- for (const key of ["min_wall_mm", "line_width_mm"])
1328
- if (input.profile[key] != null)
1329
- profile[key] = number(input.profile[key], key);
1330
- if (input.profile.overhang_degrees != null)
1331
- profile.overhang_degrees = number(
1332
- input.profile.overhang_degrees,
1333
- "overhang angle",
1334
- 89.9
1335
- );
1336
- if (input.profile.build_volume_mm != null) {
1337
- if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
1338
- throw new Error("Build volume requires X, Y and Z");
1339
- profile.build_volume_mm = input.profile.build_volume_mm.map(
1340
- (v) => number(v, "build volume")
1341
- );
1342
- }
1343
- }
1344
- const time = number(input.max_time_ms ?? 3e4, "check time limit", 12e4);
1345
- return {
1346
- level,
1347
- profile,
1348
- apply_transforms: input.apply_transforms !== false,
1349
- units_confirmed: input.units_confirmed === true,
1350
- max_time_ms: time
1351
- };
1352
- }
1353
- var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
1354
- var printSignature = (model) => JSON.stringify([
1355
- model.x,
1356
- model.y,
1357
- model.z,
1358
- model.rotationx,
1359
- model.rotationy,
1360
- model.rotationz,
1361
- model.scaleVector,
1362
- model.center,
1363
- model.units,
1364
- model.unitScale
1365
- ]);
1366
-
1367
- // src/v2/print-controller.js
1881
+ // src/print-controller.js
1368
1882
  var reports = /* @__PURE__ */ new WeakMap();
1369
1883
  function reportCurrent(viewer, report) {
1370
1884
  const saved = reports.get(report);
@@ -1461,7 +1975,9 @@ async function checkPrintability(options = {}) {
1461
1975
  rotationy: 0,
1462
1976
  rotationz: 0,
1463
1977
  center: [0, 0, 0],
1464
- scaleVector: [model.unitScale, model.unitScale, model.unitScale]
1978
+ scaleVector: [1, 1, 1].map(
1979
+ () => model.unitScale * (model.size_state?.unit_factor ?? 1)
1980
+ )
1465
1981
  };
1466
1982
  progress({
1467
1983
  phase: "Starting",
@@ -1577,7 +2093,7 @@ function showPrintability(report, { issue, model_id = report.model_ids[0] } = {}
1577
2093
  return lines.length / 6;
1578
2094
  }
1579
2095
 
1580
- // src/v2/export-options.js
2096
+ // src/export-options.js
1581
2097
  function exportFilename(filename, extension) {
1582
2098
  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";
1583
2099
  return `${name}.${extension}`;
@@ -1585,8 +2101,8 @@ function exportFilename(filename, extension) {
1585
2101
  function exportOptions(options = {}) {
1586
2102
  const aliases = { stp: "step", igs: "iges" };
1587
2103
  const requested = String(options.format || "").toLowerCase();
1588
- const format = aliases[requested] || requested;
1589
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2104
+ const format2 = aliases[requested] || requested;
2105
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
1590
2106
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
1591
2107
  const units = options.units ?? "mm";
1592
2108
  if (!["mm", "inch"].includes(units))
@@ -1596,10 +2112,10 @@ function exportOptions(options = {}) {
1596
2112
  throw new Error(`${key} must be a boolean`);
1597
2113
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
1598
2114
  throw new Error("on_progress must be a function");
1599
- const colors = options.colors !== false && ["obj", "3mf"].includes(format);
1600
- const extension = format === "obj" && colors ? "zip" : format;
2115
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2116
+ const extension = format2 === "obj" && colors ? "zip" : format2;
1601
2117
  return {
1602
- format,
2118
+ format: format2,
1603
2119
  units,
1604
2120
  colors,
1605
2121
  apply_transforms: options.apply_transforms !== false,
@@ -1618,7 +2134,7 @@ function exportMemory(models, options) {
1618
2134
  return MEMORY_OVERHEAD + triangles * perTriangle[options.format] + models.length * 4096;
1619
2135
  }
1620
2136
 
1621
- // src/v2/io.js
2137
+ // src/io.js
1622
2138
  async function readSource(source, limit, progress = () => {
1623
2139
  }, signal) {
1624
2140
  let stream, total = 0;
@@ -1680,7 +2196,7 @@ async function readSource(source, limit, progress = () => {
1680
2196
  return data.buffer;
1681
2197
  }
1682
2198
 
1683
- // src/v2/three-bridge.js
2199
+ // src/three-bridge.js
1684
2200
  function fromThreeMesh(mesh, limits) {
1685
2201
  if (!mesh?.isMesh && !mesh?.geometry)
1686
2202
  throw new Error("Expected a Three.js mesh");
@@ -1753,7 +2269,7 @@ function toThreeMesh(model, THREE) {
1753
2269
  return mesh;
1754
2270
  }
1755
2271
 
1756
- // src/v2/viewer.js
2272
+ // src/viewer.js
1757
2273
  var transformKeys = [
1758
2274
  "x",
1759
2275
  "y",
@@ -1791,7 +2307,10 @@ var modelKeys = [
1791
2307
  "smooth_shading",
1792
2308
  "section",
1793
2309
  "units",
1794
- "animation"
2310
+ "animation",
2311
+ "animation_state",
2312
+ "size_state",
2313
+ "display_name"
1795
2314
  ];
1796
2315
  var finite = (v, name) => {
1797
2316
  if (typeof v !== "number" || !Number.isFinite(v))
@@ -1823,7 +2342,7 @@ function modelOptions(input) {
1823
2342
  return out;
1824
2343
  }
1825
2344
  var StlViewer = class _StlViewer {
1826
- static version = "2.0.0-rc.4";
2345
+ static version = "2.0.0-rc.6";
1827
2346
  static workerURL = null;
1828
2347
  constructor(parent, options = {}) {
1829
2348
  if (!parent?.appendChild)
@@ -1852,6 +2371,8 @@ var StlViewer = class _StlViewer {
1852
2371
  this._sessionActive = false;
1853
2372
  this._frame = 0;
1854
2373
  this._lastTime = 0;
2374
+ this.camera_bookmarks = [];
2375
+ this.display_units = "mm";
1855
2376
  this.grid = null;
1856
2377
  this._gridAuto = false;
1857
2378
  this.camera = {
@@ -1873,7 +2394,9 @@ var StlViewer = class _StlViewer {
1873
2394
  "model_loaded_callback",
1874
2395
  "load_error_callback",
1875
2396
  "loading_progress_callback",
1876
- "on_model_drop"
2397
+ "on_model_drop",
2398
+ "animation_state_callback",
2399
+ "animation_complete_callback"
1877
2400
  ])
1878
2401
  this[name] = options[name] ?? null;
1879
2402
  this.onmousedown_callback = options.on_model_mousedown ?? options.on_model_mouseclick ?? null;
@@ -1912,11 +2435,15 @@ var StlViewer = class _StlViewer {
1912
2435
  this.parent_element.appendChild(this.canvas);
1913
2436
  this.renderer = new Renderer(
1914
2437
  this.canvas,
1915
- () => this._message(
1916
- "Graphics interrupted. Waiting for the browser to restore rendering\u2026"
1917
- ),
2438
+ () => {
2439
+ this._suspendAnimations(true);
2440
+ this._message(
2441
+ "Graphics interrupted. Waiting for the browser to restore rendering\u2026"
2442
+ );
2443
+ },
1918
2444
  () => {
1919
2445
  this._removeMessage();
2446
+ this._suspendAnimations(this.canvas.ownerDocument.hidden);
1920
2447
  this._invalidate();
1921
2448
  },
1922
2449
  (e) => this._error(e)
@@ -1981,6 +2508,14 @@ var StlViewer = class _StlViewer {
1981
2508
  }
1982
2509
  };
1983
2510
  this.canvas.addEventListener("keydown", this._key);
2511
+ this._visibility = () => this._suspendAnimations(
2512
+ this.canvas.ownerDocument.hidden || this.renderer.lost
2513
+ );
2514
+ this.canvas.ownerDocument.addEventListener(
2515
+ "visibilitychange",
2516
+ this._visibility
2517
+ );
2518
+ this._animationSuspended = this.canvas.ownerDocument.hidden;
1984
2519
  this.ready = true;
1985
2520
  this._readyResolve(this);
1986
2521
  this._callback("ready_callback");
@@ -1995,6 +2530,11 @@ var StlViewer = class _StlViewer {
1995
2530
  this.controls?.dispose();
1996
2531
  this.renderer?.dispose();
1997
2532
  this._resizeObserver?.disconnect();
2533
+ if (this._visibility)
2534
+ this.canvas.ownerDocument.removeEventListener(
2535
+ "visibilitychange",
2536
+ this._visibility
2537
+ );
1998
2538
  }
1999
2539
  }
2000
2540
  _callback(name, ...args) {
@@ -2043,7 +2583,7 @@ var StlViewer = class _StlViewer {
2043
2583
  const diagnostic = this.renderer?.diagnostic;
2044
2584
  if (diagnostic && printSignature(diagnostic.model) !== diagnostic.signature)
2045
2585
  this.renderer.setDiagnostic(null);
2046
- if (this.killsign || !this.renderer || this.renderer.lost || this._frame)
2586
+ if (this.killsign || !this.renderer || this.renderer.lost || this._frame || this._animationSuspended)
2047
2587
  return;
2048
2588
  this._frame = requestAnimationFrame((t) => {
2049
2589
  this._frame = 0;
@@ -2055,12 +2595,15 @@ var StlViewer = class _StlViewer {
2055
2595
  this._lastTime = time;
2056
2596
  let animated = false;
2057
2597
  for (const m of this._models.values())
2058
- if (m.geometry && m._animation) {
2598
+ if (m.geometry && !m._sceneImportPending && m._animation && !m._animation.paused) {
2059
2599
  this._animate(m, time);
2060
- animated ||= !!m._animation;
2600
+ animated ||= !!m._animation && !m._animation.paused;
2061
2601
  }
2062
2602
  if (this.auto_rotate && this.controls.enabled)
2063
2603
  this.controls.orbit(elapsed * 0.3, 0);
2604
+ const diagnostic = this.renderer.diagnostic;
2605
+ if (diagnostic && printSignature(diagnostic.model) !== diagnostic.signature)
2606
+ this.renderer.setDiagnostic(null);
2064
2607
  this._clip();
2065
2608
  try {
2066
2609
  this.renderer.draw(
@@ -2166,6 +2709,13 @@ var StlViewer = class _StlViewer {
2166
2709
  finite(m[key], key);
2167
2710
  if (key.startsWith("scale")) positive(m[key], key);
2168
2711
  }
2712
+ if (supplied.size_state)
2713
+ m.size_state = validateSizeState(supplied.size_state);
2714
+ if (supplied.display_name != null) {
2715
+ if (typeof supplied.display_name !== "string" || supplied.display_name.length > 1024)
2716
+ throw new Error("Invalid model name");
2717
+ m.display_name = supplied.display_name;
2718
+ }
2169
2719
  m.opacity = supplied.opacity ?? supplied.mesh?.material?.opacity ?? 1;
2170
2720
  finite(m.opacity, "opacity");
2171
2721
  if (m.opacity < 0 || m.opacity > 1)
@@ -2184,6 +2734,11 @@ var StlViewer = class _StlViewer {
2184
2734
  m.color = supplied.color ?? (supplied.mesh?.material?.color?.getHexString ? "#" + supplied.mesh.material.color.getHexString() : null);
2185
2735
  if (m.color && m.color !== "transparent") colorRGB(m.color);
2186
2736
  m.view_edges = !!supplied.view_edges;
2737
+ m.animation = supplied.animation == null ? null : normalizeAnimation(supplied.animation);
2738
+ m.animation_state = supplied.animation_state == null ? null : normalizePlaybackState(supplied.animation_state);
2739
+ if (m.animation_state && !m.animation)
2740
+ throw new Error("Playback state needs an animation");
2741
+ if (m.animation) createPlayback(m.animation, m, m.animation_state);
2187
2742
  Object.defineProperty(m, "scaleVector", {
2188
2743
  get: () => [
2189
2744
  m.scalex * m.unitScale,
@@ -2312,7 +2867,7 @@ var StlViewer = class _StlViewer {
2312
2867
  format: supplied.remote_format,
2313
2868
  relay,
2314
2869
  source: typeof source === "string" ? new URL(source, document.baseURI).href : source,
2315
- name: m.filename,
2870
+ name: m.display_name || m.filename,
2316
2871
  limits,
2317
2872
  features: features.smooth || features.edges || features.wire ? features : null
2318
2873
  },
@@ -2336,7 +2891,10 @@ var StlViewer = class _StlViewer {
2336
2891
  );
2337
2892
  if ((supplied.mesh || supplied.data) && (features.smooth || features.edges || features.wire))
2338
2893
  await this._derive(m, features);
2339
- if (supplied.animation) this.animate_model(m.id, supplied.animation);
2894
+ if (m.animation_state) m.center = [...m.animation_state.center];
2895
+ if (m.animation && !m._animation)
2896
+ this._startAnimation(m, m.animation, m.animation_state);
2897
+ delete m.animation_state;
2340
2898
  delete m.mesh;
2341
2899
  delete m.data;
2342
2900
  this._invalidate();
@@ -2358,6 +2916,8 @@ var StlViewer = class _StlViewer {
2358
2916
  this._invalidate();
2359
2917
  }
2360
2918
  _deleteModel(m) {
2919
+ this._stopAnimation(m, "removed");
2920
+ for (const controller of m._measureJobs || []) controller.abort();
2361
2921
  for (const [controller, models] of this._printJobs || [])
2362
2922
  if (models.has(m)) controller.abort();
2363
2923
  this._printCache = null;
@@ -2385,9 +2945,12 @@ var StlViewer = class _StlViewer {
2385
2945
  if (this.killsign) return;
2386
2946
  this._generation++;
2387
2947
  this._printCache = null;
2948
+ this.camera_bookmarks = [];
2949
+ this.display_units = "mm";
2388
2950
  this._pool?.clear();
2389
2951
  for (const c of this._aborters) c.abort();
2390
2952
  this._aborters.clear();
2953
+ for (const m of this._models.values()) this._stopAnimation(m, "clean");
2391
2954
  this._models.clear();
2392
2955
  this._pending.clear();
2393
2956
  this._jobs.clear();
@@ -2419,6 +2982,11 @@ var StlViewer = class _StlViewer {
2419
2982
  window.removeEventListener("resize", this._windowResize);
2420
2983
  this.renderer?.dispose();
2421
2984
  if (this.canvas) {
2985
+ if (this._visibility)
2986
+ this.canvas.ownerDocument.removeEventListener(
2987
+ "visibilitychange",
2988
+ this._visibility
2989
+ );
2422
2990
  this.canvas.removeEventListener("drop", this._drop);
2423
2991
  this.canvas.removeEventListener("dragover", this._drag);
2424
2992
  this.canvas.removeEventListener("keydown", this._key);
@@ -2557,6 +3125,10 @@ var StlViewer = class _StlViewer {
2557
3125
  set_position(id, x, y, z) {
2558
3126
  return this._guard(() => {
2559
3127
  const m = this._model(id);
3128
+ [x, y, z].forEach((v) => {
3129
+ if (v !== void 0) finite(v, "position");
3130
+ });
3131
+ this._stopAnimation(m, "manual");
2560
3132
  [x, y, z].forEach((v, i) => {
2561
3133
  if (v !== void 0) m[["x", "y", "z"][i]] = finite(v, "position");
2562
3134
  });
@@ -2570,8 +3142,12 @@ var StlViewer = class _StlViewer {
2570
3142
  if (keepPrevious) {
2571
3143
  if (x / m.scalex !== y / m.scaley || x / m.scalex !== z / m.scalez)
2572
3144
  throw new Error("keep_prev_scale requires a uniform geometry factor");
3145
+ this._stopAnimation(m, "manual");
2573
3146
  m.unitScale *= x / m.scalex;
2574
- } else [m.scalex, m.scaley, m.scalez] = [x, y, z];
3147
+ } else {
3148
+ this._stopAnimation(m, "manual");
3149
+ [m.scalex, m.scaley, m.scalez] = [x, y, z];
3150
+ }
2575
3151
  m._stats = null;
2576
3152
  this._invalidate();
2577
3153
  });
@@ -2585,6 +3161,10 @@ var StlViewer = class _StlViewer {
2585
3161
  set_rotation(id, x, y, z, increment = false) {
2586
3162
  return this._guard(() => {
2587
3163
  const m = this._model(id);
3164
+ [x, y, z].forEach((v) => {
3165
+ if (v !== void 0) finite(v, "rotation");
3166
+ });
3167
+ this._stopAnimation(m, "manual");
2588
3168
  [x, y, z].forEach((v, i) => {
2589
3169
  const k = ["rotationx", "rotationy", "rotationz"][i];
2590
3170
  if (v !== void 0)
@@ -2638,6 +3218,7 @@ var StlViewer = class _StlViewer {
2638
3218
  if (!["mm", "inch"].includes(units))
2639
3219
  throw new Error("Units must be mm or inch");
2640
3220
  const m = this._model(id);
3221
+ this._stopAnimation(m, "manual");
2641
3222
  if (scale && m.units !== units)
2642
3223
  m.unitScale *= units === "inch" ? 25.4 : 1 / 25.4;
2643
3224
  m.units = units;
@@ -2805,12 +3386,42 @@ var StlViewer = class _StlViewer {
2805
3386
  type
2806
3387
  );
2807
3388
  }
2808
- get_model_info(id) {
3389
+ get_model_size(id) {
3390
+ return modelSize(this._model(id));
3391
+ }
3392
+ resize_model(id, factors) {
3393
+ return resizeModel.call(this, id, factors);
3394
+ }
3395
+ set_file_units(id, units) {
3396
+ return setFileUnits.call(this, id, units);
3397
+ }
3398
+ reset_model_size(id) {
3399
+ return resetModelSize.call(this, id);
3400
+ }
3401
+ duplicate_model(id) {
3402
+ return duplicateModel.call(this, id);
3403
+ }
3404
+ set_camera_view(name) {
3405
+ return cameraView.call(this, name);
3406
+ }
3407
+ set_display_units(units) {
3408
+ this.display_units = validateUnits(units);
3409
+ }
3410
+ get_camera_bookmarks() {
3411
+ return structuredClone(this.camera_bookmarks || []);
3412
+ }
3413
+ set_camera_bookmarks(bookmarks) {
3414
+ this.camera_bookmarks = validateBookmarks(bookmarks);
3415
+ }
3416
+ measure_model(id, options) {
3417
+ return measureModel.call(this, id, options);
3418
+ }
3419
+ get_model_info(id, options = {}) {
2809
3420
  return this._guard(() => {
2810
3421
  const m = this._model(id), g = m.geometry;
2811
3422
  if (!g) return null;
2812
3423
  const s = m.scaleVector, key = s.join(",");
2813
- if (!m._stats || m._stats.key !== key)
3424
+ if (options.measurements !== false && (!m._stats || m._stats.key !== key))
2814
3425
  m._stats = { key, ...scaledStats(g, s) };
2815
3426
  const factor = m.units === "inch" ? 1 / 25.4 : 1;
2816
3427
  return {
@@ -2826,8 +3437,8 @@ var StlViewer = class _StlViewer {
2826
3437
  section: m.section ? { ...m.section } : null,
2827
3438
  color: m.color ?? (g.colors ? "#ffffff" : "#909090"),
2828
3439
  scale: { x: m.scalex, y: m.scaley, z: m.scalez },
2829
- volume: m._stats.volume * factor ** 3,
2830
- area: m._stats.area * factor ** 2,
3440
+ volume: options.measurements === false ? null : m._stats.volume * factor ** 3,
3441
+ area: options.measurements === false ? null : m._stats.area * factor ** 2,
2831
3442
  triangles: g.triangles,
2832
3443
  bounds: { min: [...g.bounds.min], max: [...g.bounds.max] },
2833
3444
  has_colors: !!g.colors,
@@ -2915,7 +3526,9 @@ var StlViewer = class _StlViewer {
2915
3526
  rotationx: 0,
2916
3527
  rotationy: 0,
2917
3528
  rotationz: 0,
2918
- scaleVector: [m.unitScale, m.unitScale, m.unitScale]
3529
+ scaleVector: [1, 1, 1].map(
3530
+ () => m.unitScale * (m.size_state?.unit_factor ?? 1)
3531
+ )
2919
3532
  };
2920
3533
  const matrix = modelMatrix(source), factor = settings.units === "inch" ? 1 / 25.4 : 1;
2921
3534
  for (let c = 0; c < 4; c++)
@@ -2983,8 +3596,8 @@ var StlViewer = class _StlViewer {
2983
3596
  );
2984
3597
  }
2985
3598
  async export_scene(options = {}) {
2986
- const format = String(options.format || "").toLowerCase();
2987
- if (!["vsb", "vsj"].includes(format))
3599
+ const format2 = String(options.format || "").toLowerCase();
3600
+ if (!["vsb", "vsj"].includes(format2))
2988
3601
  throw new Error("Scene format must be vsb or vsj");
2989
3602
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
2990
3603
  throw new Error("force_basename must be a boolean");
@@ -3007,7 +3620,7 @@ var StlViewer = class _StlViewer {
3007
3620
  if (!models.length) throw new Error("No models to export");
3008
3621
  if (models.some((m) => !m.geometry))
3009
3622
  throw new Error("A model has not finished loading");
3010
- if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3623
+ if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3011
3624
  throw new Error("Scene model count limit exceeded");
3012
3625
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3013
3626
  if (triangles > this.limits.max_triangles)
@@ -3015,18 +3628,26 @@ var StlViewer = class _StlViewer {
3015
3628
  const scene = this.get_vsj(
3016
3629
  true,
3017
3630
  !!options.force_basename,
3018
- format === "vsb"
3631
+ format2 === "vsb"
3019
3632
  );
3020
3633
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3021
3634
  if (manifestBytes > this.limits.max_file_bytes)
3022
3635
  throw new Error("Scene manifest size limit exceeded");
3023
- if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3636
+ if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3024
3637
  throw new Error("Scene archive limit exceeded");
3025
- const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3638
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3026
3639
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3027
3640
  this._exportBytes += working;
3028
3641
  let job, cancel, callbackError;
3029
3642
  try {
3643
+ const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3644
+ id: m.id,
3645
+ triangles: m.geometry.triangles,
3646
+ positions: m.geometry.positions.slice(),
3647
+ colors: m.geometry.colors?.slice() || null,
3648
+ scale: scene.models[index].animation_state ? [1, 1, 1] : m.scaleVector,
3649
+ center: scene.models[index].animation_state ? [0, 0, 0] : [...m.center]
3650
+ })) : null;
3030
3651
  options.on_progress?.({
3031
3652
  loaded: 0,
3032
3653
  total: models.length,
@@ -3034,7 +3655,7 @@ var StlViewer = class _StlViewer {
3034
3655
  });
3035
3656
  checkAbort();
3036
3657
  let blob;
3037
- if (format === "vsj") {
3658
+ if (format2 === "vsj") {
3038
3659
  blob = new Blob([json], { type: "application/json" });
3039
3660
  options.on_progress?.({
3040
3661
  loaded: models.length,
@@ -3042,14 +3663,6 @@ var StlViewer = class _StlViewer {
3042
3663
  phase: "complete"
3043
3664
  });
3044
3665
  } else {
3045
- const snapshot = models.map((m) => ({
3046
- id: m.id,
3047
- triangles: m.geometry.triangles,
3048
- positions: m.geometry.positions.slice(),
3049
- colors: m.geometry.colors?.slice() || null,
3050
- scale: m.scaleVector,
3051
- center: [...m.center]
3052
- }));
3053
3666
  job = this._pool.run(
3054
3667
  {
3055
3668
  kind: "export-scene",
@@ -3082,8 +3695,8 @@ var StlViewer = class _StlViewer {
3082
3695
  checkAbort();
3083
3696
  return {
3084
3697
  blob,
3085
- format,
3086
- filename: exportFilename(options.filename, format),
3698
+ format: format2,
3699
+ filename: exportFilename(options.filename, format2),
3087
3700
  model_ids: models.map((m) => m.id),
3088
3701
  triangles,
3089
3702
  representation: "scene"
@@ -3105,6 +3718,8 @@ var StlViewer = class _StlViewer {
3105
3718
  canvas_height: this.canvas_height,
3106
3719
  bg_color: this.bg_color,
3107
3720
  camera_state: this.get_camera_state(),
3721
+ camera_bookmarks: this.get_camera_bookmarks(),
3722
+ display_units: this.display_units || "mm",
3108
3723
  auto_rotate: this.auto_rotate,
3109
3724
  mouse_zoom: this.mouse_zoom,
3110
3725
  auto_resize: this.auto_resize,
@@ -3125,6 +3740,11 @@ var StlViewer = class _StlViewer {
3125
3740
  id: for_vsb ? -1 : m.id,
3126
3741
  filename: for_vsb ? `${m.id}.stl` : force_basename ? basename(m.filename) : m.filename,
3127
3742
  units: m.units,
3743
+ size_state: {
3744
+ ...structuredClone(sizeState(m)),
3745
+ unit_factor: for_vsb && !m._animation ? 1 : m.unitScale * (sizeState(m).unit_factor ?? 1)
3746
+ },
3747
+ ...m.display_name ? { display_name: m.display_name } : {},
3128
3748
  display: m.display,
3129
3749
  opacity: m.opacity,
3130
3750
  view_edges: m.view_edges,
@@ -3135,13 +3755,21 @@ var StlViewer = class _StlViewer {
3135
3755
  model.orig_filename = basename(m.orig_filename || m.filename);
3136
3756
  for (const key of transformKeys)
3137
3757
  if (!key.startsWith("scale")) model[key] = m[key];
3138
- if (!for_vsb) {
3758
+ if (!for_vsb || m._animation) {
3139
3759
  model.scalex = m.scalex * m.unitScale;
3140
3760
  model.scaley = m.scaley * m.unitScale;
3141
3761
  model.scalez = m.scalez * m.unitScale;
3142
3762
  }
3143
3763
  if (m.color) model.color = m.color;
3144
- if (m.animation) model.animation = structuredClone(m.animation);
3764
+ const playback = savePlayback(m);
3765
+ if (playback) Object.assign(model, playback);
3766
+ else if (!for_vsb) {
3767
+ const center = this.center_models ? m.geometry.bounds.min.map(
3768
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
3769
+ ) : [0, 0, 0];
3770
+ const position = transform(modelMatrix(m), center);
3771
+ ["x", "y", "z"].forEach((axis, i) => model[axis] = position[i]);
3772
+ }
3145
3773
  data.models.push(model);
3146
3774
  }
3147
3775
  return as_js_obj ? data : JSON.stringify(data);
@@ -3155,7 +3783,10 @@ var StlViewer = class _StlViewer {
3155
3783
  get_vsb() {
3156
3784
  return {
3157
3785
  vsj: this.get_vsj(true, true, true),
3158
- files: [...this._models.values()].filter((m) => m.geometry).map((m) => ({ id: m.id, bin: this.get_stl_bin(m.id) }))
3786
+ files: [...this._models.values()].filter((m) => m.geometry).map((m) => ({
3787
+ id: m.id,
3788
+ bin: m._animation ? exportSTL(m.geometry, [1, 1, 1], [0, 0, 0]) : this.get_stl_bin(m.id)
3789
+ }))
3159
3790
  };
3160
3791
  }
3161
3792
  async get_vsb_blob() {
@@ -3236,20 +3867,66 @@ var StlViewer = class _StlViewer {
3236
3867
  );
3237
3868
  else if (scene.grid) this.set_grid(true);
3238
3869
  if (scene.camera_state) this._restoreCamera = scene.camera_state;
3239
- for (const input of scene.models) {
3240
- if (!input || typeof input !== "object")
3241
- throw new Error("Invalid scene model");
3242
- const model = modelOptions(input);
3243
- delete model.mesh;
3244
- delete model.data;
3245
- delete model.local_file;
3246
- if (files) {
3247
- if (typeof model.filename !== "string" || !Object.hasOwn(files, model.filename))
3248
- throw new Error("VSB model file is missing");
3249
- model.local_file = new File([files[model.filename]], model.filename);
3250
- } else if (model.filename)
3251
- model.filename = new URL(model.filename, base).href;
3252
- this.add_model(model);
3870
+ const bookmarks = mergeBookmarks(
3871
+ this.get_camera_bookmarks(),
3872
+ scene.camera_bookmarks || []
3873
+ );
3874
+ const displayUnits = validateUnits(
3875
+ scene.display_units || this.display_units || "mm"
3876
+ );
3877
+ const incoming = [];
3878
+ try {
3879
+ for (const input of scene.models) {
3880
+ if (!input || typeof input !== "object")
3881
+ throw new Error("Invalid scene model");
3882
+ const model = modelOptions(input);
3883
+ delete model.mesh;
3884
+ delete model.data;
3885
+ delete model.local_file;
3886
+ if (files) {
3887
+ if (typeof model.filename !== "string" || !Object.hasOwn(files, model.filename))
3888
+ throw new Error("VSB model file is missing");
3889
+ model.local_file = new File(
3890
+ [files[model.filename]],
3891
+ model.filename
3892
+ );
3893
+ } else if (model.filename)
3894
+ model.filename = new URL(model.filename, base).href;
3895
+ const id = model.id === void 0 || model.id === -1 ? this.max_model_id + 1 : model.id;
3896
+ if (this._models.has(id))
3897
+ throw new Error(`Invalid or duplicate model id: ${id}`);
3898
+ this.add_model({
3899
+ ...model,
3900
+ id,
3901
+ animation: null,
3902
+ animation_state: null
3903
+ });
3904
+ const m = this._models.get(id);
3905
+ if (!m) throw new Error(this.error || "Invalid scene model");
3906
+ m._sceneImportPending = true;
3907
+ incoming.push({ m, model, pending: this._pending.get(id) });
3908
+ }
3909
+ await Promise.all(incoming.map((item) => item.pending));
3910
+ if (generation !== this._generation || this.killsign) return;
3911
+ for (const { m, model } of incoming) {
3912
+ if (this._models.get(m.id) !== m || !m.geometry)
3913
+ throw new Error("A scene model could not be opened");
3914
+ if (model.animation)
3915
+ createPlayback(model.animation, m, model.animation_state);
3916
+ else if (model.animation_state)
3917
+ throw new Error("Playback state needs an animation");
3918
+ }
3919
+ for (const { m, model } of incoming) {
3920
+ if (model.animation)
3921
+ this._startAnimation(m, model.animation, model.animation_state);
3922
+ m._sceneImportPending = false;
3923
+ }
3924
+ this.camera_bookmarks = bookmarks;
3925
+ this.display_units = displayUnits;
3926
+ } catch (error) {
3927
+ for (const { m } of incoming)
3928
+ if (this._models.get(m.id) === m) this.remove_model(m.id);
3929
+ throw error;
3253
3930
  }
3254
3931
  })().catch((e) => {
3255
3932
  if (generation === this._generation && e.name !== "AbortError")
@@ -3271,56 +3948,101 @@ var StlViewer = class _StlViewer {
3271
3948
  return this._guard(() => {
3272
3949
  const m = this._model(id);
3273
3950
  if (!animation) {
3274
- m.animation = null;
3275
- m._animation = null;
3951
+ this._stopAnimation(m, "stopped");
3952
+ this._invalidate();
3276
3953
  return;
3277
3954
  }
3278
- const copy = structuredClone(animation), tracks = [];
3279
- for (const mode of ["delta", "exact"])
3280
- if (copy[mode]) {
3281
- const a = copy[mode];
3282
- positive(a.msec ?? 300, "animation duration");
3283
- const values = {};
3284
- for (const key of transformKeys) {
3285
- const value = a[key] ?? (key.startsWith("scale") ? a.scale : void 0);
3286
- if (value !== void 0) {
3287
- finite(value, key);
3288
- if (mode === "exact" && key.startsWith("scale"))
3289
- positive(value, key);
3290
- values[key] = value;
3291
- }
3292
- }
3293
- tracks.push({
3294
- mode,
3295
- values,
3296
- duration: a.msec ?? 300,
3297
- loop: mode === "delta" && !!a.loop,
3298
- from: Object.fromEntries(transformKeys.map((k) => [k, m[k]]))
3299
- });
3300
- }
3301
- if (!tracks.length) throw new Error("Animation needs delta or exact");
3302
- m.animation = copy;
3303
- m._animation = { start: null, tracks };
3955
+ this._startAnimation(m, animation);
3304
3956
  this._invalidate();
3305
3957
  });
3306
3958
  }
3959
+ _startAnimation(m, definition, saved) {
3960
+ const playback = createPlayback(definition, m, saved);
3961
+ if (m._animation) this._stopAnimation(m, "replaced");
3962
+ if (saved) m.center = [...saved.center];
3963
+ m.animation = playback.animation;
3964
+ m._animation = playback;
3965
+ delete m.animation_state;
3966
+ m._animationResult = null;
3967
+ const running = evaluatePlayback(playback, m);
3968
+ if (running) this._animationEvent(m, saved ? "restored" : "started");
3969
+ else this._completeAnimation(m);
3970
+ }
3971
+ _animationEvent(m, reason) {
3972
+ const state = this._animationState(m);
3973
+ queueMicrotask(() => {
3974
+ this._callback("animation_state_callback", m.id, state, reason);
3975
+ if (reason === "completed")
3976
+ this._callback("animation_complete_callback", m.id, state);
3977
+ });
3978
+ }
3979
+ _animationState(m) {
3980
+ return m._animation ? playbackState(m._animation, !!this._animationSuspended) : {
3981
+ ...m._animationResult ?? { status: "idle", elapsed_ms: 0 },
3982
+ suspended: false,
3983
+ tracks: []
3984
+ };
3985
+ }
3986
+ _stopAnimation(m, reason) {
3987
+ if (!m._animation && !m.animation) return;
3988
+ m._animationResult = {
3989
+ status: "stopped",
3990
+ elapsed_ms: m._animation?.elapsed_ms ?? 0
3991
+ };
3992
+ m._animation = null;
3993
+ m.animation = null;
3994
+ delete m.animation_state;
3995
+ this._animationEvent(m, reason);
3996
+ }
3997
+ _completeAnimation(m) {
3998
+ m._animationResult = {
3999
+ status: "completed",
4000
+ elapsed_ms: m._animation.elapsed_ms
4001
+ };
4002
+ m._animation = null;
4003
+ m.animation = null;
4004
+ this._animationEvent(m, "completed");
4005
+ }
4006
+ pause_model_animation(id) {
4007
+ return this._guard(() => {
4008
+ const m = this._model(id);
4009
+ if (!m._animation || m._animation.paused) return;
4010
+ m._animation.paused = true;
4011
+ m._animation.last_time = null;
4012
+ this._animationEvent(m, "paused");
4013
+ this._invalidate();
4014
+ });
4015
+ }
4016
+ resume_model_animation(id) {
4017
+ return this._guard(() => {
4018
+ const m = this._model(id);
4019
+ if (!m._animation || !m._animation.paused) return;
4020
+ m._animation.paused = false;
4021
+ m._animation.last_time = null;
4022
+ this._animationEvent(m, "resumed");
4023
+ this._invalidate();
4024
+ });
4025
+ }
4026
+ get_model_animation_state(id) {
4027
+ return this._guard(() => this._animationState(this._model(id)));
4028
+ }
4029
+ _suspendAnimations(suspended) {
4030
+ this._animationSuspended = !!suspended;
4031
+ this._lastTime = 0;
4032
+ for (const m of this._models.values())
4033
+ if (m._animation) m._animation.last_time = null;
4034
+ if (suspended) {
4035
+ cancelAnimationFrame(this._frame);
4036
+ this._frame = 0;
4037
+ } else this._invalidate();
4038
+ }
3307
4039
  _animate(m, time) {
3308
- const animation = m._animation;
3309
- animation.start ??= time;
3310
- let running = false;
3311
- for (const t of animation.tracks) {
3312
- const fraction = (time - animation.start) / t.duration, p = t.loop ? fraction : Math.min(fraction, 1);
3313
- running ||= t.loop || fraction < 1;
3314
- for (const [k, v] of Object.entries(t.values))
3315
- m[k] = k.startsWith("scale") ? Math.max(
3316
- 1e-6,
3317
- t.mode === "delta" ? t.from[k] + v * p : t.from[k] + (v - t.from[k]) * p
3318
- ) : t.mode === "delta" ? t.from[k] + v * p : t.from[k] + (v - t.from[k]) * p;
3319
- }
3320
- m._stats = null;
3321
- if (!running) {
3322
- m._animation = null;
3323
- m.animation = null;
4040
+ if (this._animationSuspended || !m._animation) return;
4041
+ try {
4042
+ if (!advancePlayback(m._animation, m, time)) this._completeAnimation(m);
4043
+ } catch (error) {
4044
+ this._stopAnimation(m, "error");
4045
+ this._error(error);
3324
4046
  }
3325
4047
  }
3326
4048
  async get_image_blob(type = "image/png") {
@@ -3343,7 +4065,300 @@ var StlViewer = class _StlViewer {
3343
4065
  }
3344
4066
  };
3345
4067
 
3346
- // src/v2/memory-policy.js
4068
+ // src/app-tools.js
4069
+ var toolsControls = [
4070
+ "size-details",
4071
+ "size-fields",
4072
+ "size-x",
4073
+ "size-y",
4074
+ "size-z",
4075
+ "size-lock",
4076
+ "size-percent",
4077
+ "size-apply",
4078
+ "size-cancel",
4079
+ "size-reset",
4080
+ "size-error",
4081
+ "file-units",
4082
+ "file-units-note",
4083
+ "display-units",
4084
+ "size-unit-label",
4085
+ "duplicate-model",
4086
+ "camera-preset",
4087
+ "bookmark-name",
4088
+ "bookmark-list",
4089
+ "bookmark-save",
4090
+ "bookmark-restore",
4091
+ "bookmark-rename",
4092
+ "bookmark-delete",
4093
+ "camera-error",
4094
+ "info-area",
4095
+ "info-volume",
4096
+ "measure-model",
4097
+ "measure-cancel",
4098
+ "measure-status"
4099
+ ];
4100
+ var axes2 = ["x", "y", "z"];
4101
+ var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4102
+ function bindAppTools({
4103
+ $,
4104
+ listen,
4105
+ window: window2,
4106
+ getViewer,
4107
+ getState,
4108
+ changeSize,
4109
+ duplicate,
4110
+ refresh,
4111
+ announce
4112
+ }) {
4113
+ let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4114
+ const error = (id, message = "") => {
4115
+ $(id).textContent = message;
4116
+ $(id).hidden = !message;
4117
+ };
4118
+ const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4119
+ const value = (v) => Number(v.toPrecision(10));
4120
+ function syncInfo() {
4121
+ const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4122
+ const s = m?.scaleVector, cached = m?._measurements;
4123
+ const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4124
+ const unit = factor() === 1 ? "mm" : "in";
4125
+ const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4126
+ $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4127
+ $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4128
+ if (!measurement)
4129
+ $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4130
+ $("measure-model").disabled = busy || !m || !!measurement;
4131
+ $("measure-cancel").hidden = !measurement;
4132
+ }
4133
+ function syncBookmarks() {
4134
+ const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4135
+ $("bookmark-list").replaceChildren(
4136
+ ...bookmarks.map((b) => {
4137
+ const option = window2.document.createElement("option");
4138
+ option.value = b.name;
4139
+ option.textContent = b.name;
4140
+ return option;
4141
+ })
4142
+ );
4143
+ if (bookmarks.some((b) => b.name === previous))
4144
+ $("bookmark-list").value = previous;
4145
+ for (const id of [
4146
+ "bookmark-restore",
4147
+ "bookmark-rename",
4148
+ "bookmark-delete",
4149
+ "bookmark-list"
4150
+ ])
4151
+ $(id).disabled = !bookmarks.length;
4152
+ $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4153
+ }
4154
+ function sync() {
4155
+ if (disposed) return;
4156
+ const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4157
+ if (selectedId !== selected) {
4158
+ measurement?.abort();
4159
+ selected = selectedId;
4160
+ }
4161
+ draftKind = null;
4162
+ base = m?.geometry ? v.get_model_size(selectedId) : null;
4163
+ $("size-fields").disabled = busy || !base;
4164
+ $("duplicate-model").disabled = busy || !base;
4165
+ $("display-units").value = v?.display_units || "mm";
4166
+ $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4167
+ $("file-units").value = base?.file_units || "mm";
4168
+ $("file-units").disabled = busy || !base?.editable;
4169
+ $("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.";
4170
+ axes2.forEach((a, i) => {
4171
+ $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4172
+ $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4173
+ });
4174
+ const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4175
+ $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4176
+ error("size-error");
4177
+ syncInfo();
4178
+ syncBookmarks();
4179
+ }
4180
+ const attempt = (id, action) => {
4181
+ try {
4182
+ action();
4183
+ error(id);
4184
+ } catch (e) {
4185
+ error(id, e.message);
4186
+ }
4187
+ };
4188
+ axes2.forEach(
4189
+ (a, i) => listen($("size-" + a), "input", () => {
4190
+ if (!base) return;
4191
+ draftKind = "axis";
4192
+ lastAxis = i;
4193
+ const n = $("size-" + a).valueAsNumber;
4194
+ if (!Number.isFinite(n) || n <= 0) return;
4195
+ if ($("size-lock").checked)
4196
+ axes2.forEach((axis, j) => {
4197
+ if (i !== j)
4198
+ $("size-" + axis).value = value(
4199
+ n * base.dimensions_mm[j] / base.dimensions_mm[i]
4200
+ );
4201
+ });
4202
+ $("size-percent").value = "";
4203
+ })
4204
+ );
4205
+ listen($("size-percent"), "input", () => {
4206
+ draftKind = "percent";
4207
+ const n = $("size-percent").valueAsNumber;
4208
+ if (base && Number.isFinite(n) && n > 0)
4209
+ axes2.forEach(
4210
+ (a, i) => $("size-" + a).value = value(
4211
+ base.baseline_mm[i] * n / 100 / factor()
4212
+ )
4213
+ );
4214
+ });
4215
+ listen(
4216
+ $("size-apply"),
4217
+ "click",
4218
+ () => attempt("size-error", () => {
4219
+ if (!base) return;
4220
+ if (!draftKind) return;
4221
+ const current = getViewer().get_model_size(selected).dimensions_mm;
4222
+ let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4223
+ if (draftKind === "percent") {
4224
+ const percent = $("size-percent").valueAsNumber;
4225
+ if (!Number.isFinite(percent) || percent <= 0)
4226
+ throw new Error("Enter a positive scale percentage.");
4227
+ desired = base.baseline_mm.map((v) => v * percent / 100);
4228
+ } else if ($("size-lock").checked) {
4229
+ const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4230
+ desired = base.dimensions_mm.map((v) => v * ratio);
4231
+ }
4232
+ let ratios = desired.map((n, i) => {
4233
+ if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4234
+ throw new Error("Enter positive dimensions for each non-flat axis.");
4235
+ return current[i] ? n / current[i] : 1;
4236
+ });
4237
+ if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4238
+ ratios = ratios.map(() => ratios[0]);
4239
+ changeSize(() => getViewer().resize_model(selected, ratios));
4240
+ announce("Model size updated.");
4241
+ })
4242
+ );
4243
+ listen($("size-cancel"), "click", sync);
4244
+ listen(
4245
+ $("size-reset"),
4246
+ "click",
4247
+ () => attempt("size-error", () => {
4248
+ changeSize(() => getViewer().reset_model_size(selected));
4249
+ announce("Original size restored; file-unit correction retained.");
4250
+ })
4251
+ );
4252
+ listen(
4253
+ $("file-units"),
4254
+ "change",
4255
+ () => attempt("size-error", () => {
4256
+ changeSize(
4257
+ () => getViewer().set_file_units(selected, $("file-units").value)
4258
+ );
4259
+ announce("File units corrected.");
4260
+ })
4261
+ );
4262
+ listen($("display-units"), "change", () => {
4263
+ getViewer()?.set_display_units($("display-units").value);
4264
+ refresh();
4265
+ });
4266
+ listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4267
+ listen(
4268
+ $("camera-preset"),
4269
+ "change",
4270
+ () => attempt("camera-error", () => {
4271
+ if ($("camera-preset").value)
4272
+ getViewer().set_camera_view($("camera-preset").value);
4273
+ announce("Camera view updated.");
4274
+ $("camera-preset").value = "";
4275
+ })
4276
+ );
4277
+ const editBookmarks = (action) => attempt("camera-error", () => {
4278
+ const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4279
+ action(bookmarks, v);
4280
+ v.set_camera_bookmarks(bookmarks);
4281
+ syncBookmarks();
4282
+ });
4283
+ listen(
4284
+ $("bookmark-save"),
4285
+ "click",
4286
+ () => editBookmarks((b, v) => {
4287
+ b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4288
+ })
4289
+ );
4290
+ listen(
4291
+ $("bookmark-rename"),
4292
+ "click",
4293
+ () => editBookmarks((b) => {
4294
+ b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4295
+ })
4296
+ );
4297
+ listen(
4298
+ $("bookmark-delete"),
4299
+ "click",
4300
+ () => editBookmarks((b) => {
4301
+ b.splice(
4302
+ b.findIndex((b2) => b2.name === $("bookmark-list").value),
4303
+ 1
4304
+ );
4305
+ })
4306
+ );
4307
+ listen(
4308
+ $("bookmark-restore"),
4309
+ "click",
4310
+ () => attempt("camera-error", () => {
4311
+ const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4312
+ if (b) {
4313
+ getViewer().set_camera_state(b.camera);
4314
+ announce("Saved view restored.");
4315
+ }
4316
+ })
4317
+ );
4318
+ listen(
4319
+ $("bookmark-list"),
4320
+ "change",
4321
+ () => $("bookmark-name").value = $("bookmark-list").value
4322
+ );
4323
+ listen($("measure-cancel"), "click", () => measurement?.abort());
4324
+ listen($("measure-model"), "click", async () => {
4325
+ const id = selected, v = getViewer(), controller = new window2.AbortController();
4326
+ measurement = controller;
4327
+ syncInfo();
4328
+ $("measure-status").textContent = "Calculating area and checking volume\u2026";
4329
+ try {
4330
+ await v.measure_model(id, {
4331
+ signal: controller.signal,
4332
+ on_progress: (p) => {
4333
+ if (selected === id && !disposed)
4334
+ $("measure-status").textContent = p.phase + "\u2026";
4335
+ }
4336
+ });
4337
+ if (!disposed && selected === id) announce("Measurements ready.");
4338
+ } catch (e) {
4339
+ if (!disposed && selected === id)
4340
+ $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4341
+ } finally {
4342
+ const message = $("measure-status").textContent;
4343
+ if (measurement === controller) measurement = null;
4344
+ if (!disposed) {
4345
+ syncInfo();
4346
+ if (!v._models.get(id)?._measurements && selected === id)
4347
+ $("measure-status").textContent = message;
4348
+ }
4349
+ }
4350
+ });
4351
+ return {
4352
+ sync,
4353
+ syncInfo,
4354
+ dispose() {
4355
+ disposed = true;
4356
+ measurement?.abort();
4357
+ }
4358
+ };
4359
+ }
4360
+
4361
+ // src/memory-policy.js
3347
4362
  var GiB = 1024 ** 3;
3348
4363
  var MiB = 1024 ** 2;
3349
4364
  function memoryPolicy(reportedGiB) {
@@ -3373,10 +4388,10 @@ async function estimateFile(file) {
3373
4388
  return 64 * MiB + (file.size || 0) * 3;
3374
4389
  }
3375
4390
 
3376
- // src/v2/model-transform.js
3377
- var axes = ["x", "y", "z"];
3378
- var vector2 = (p) => axes.map((a) => p[a]);
3379
- var object = (p) => Object.fromEntries(axes.map((a, i) => [a, p[i]]));
4391
+ // src/model-transform.js
4392
+ var axes3 = ["x", "y", "z"];
4393
+ var vector2 = (p) => axes3.map((a) => p[a]);
4394
+ var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
3380
4395
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
3381
4396
  var norm2 = (a) => {
3382
4397
  const n = Math.hypot(...a) || 1;
@@ -3407,8 +4422,8 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
3407
4422
  }
3408
4423
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
3409
4424
  function modelCenter(model) {
3410
- return object(
3411
- centerOf(model.bounds).map((v, i) => v + model.position[axes[i]])
4425
+ return object2(
4426
+ centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
3412
4427
  );
3413
4428
  }
3414
4429
  function modelRadius(model) {
@@ -3419,7 +4434,7 @@ function modelRadius(model) {
3419
4434
  }
3420
4435
  function enginePosition(model) {
3421
4436
  const c = centerOf(model.bounds), rotated = transform2(rotationMatrix(model.rotation), c);
3422
- return object(c.map((v, i) => model.position[axes[i]] + v - rotated[i]));
4437
+ return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
3423
4438
  }
3424
4439
  function modelBounds(model) {
3425
4440
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -3472,12 +4487,12 @@ function depthPosition(model, camera, amount) {
3472
4487
  const clearance = Math.max(model.radius || 1e-6, 1e-6) * 1.1 / Math.max(0.05, dot2(direction, forward));
3473
4488
  const desired = (distance || clearance) * Math.exp(Math.max(-6, Math.min(6, amount)));
3474
4489
  const next = amount < 0 ? Math.max(Math.min(distance, clearance), desired) : Math.max(distance, Math.min(1e12, desired));
3475
- return object(
4490
+ return object2(
3476
4491
  vector2(model.position).map((v, i) => v + direction[i] * (next - distance))
3477
4492
  );
3478
4493
  }
3479
4494
 
3480
- // src/v2/scene-layout.js
4495
+ // src/scene-layout.js
3481
4496
  function scenePlacement(models, mode) {
3482
4497
  if (mode === "custom")
3483
4498
  return models.map((m) => ({
@@ -3515,7 +4530,7 @@ function sceneFloor(models, placement) {
3515
4530
  ) : 0;
3516
4531
  }
3517
4532
 
3518
- // src/v2/model-move.js
4533
+ // src/model-move.js
3519
4534
  var vector3 = (p) => [p.x, p.y, p.z];
3520
4535
  var sub2 = (a, b) => a.map((v, i) => v - b[i]);
3521
4536
  var dot3 = (a, b) => a.reduce((n, v, i) => n + v * b[i], 0);
@@ -3681,7 +4696,7 @@ function bindModelMove(canvas, {
3681
4696
  };
3682
4697
  }
3683
4698
 
3684
- // src/v2/drop-source.js
4699
+ // src/drop-source.js
3685
4700
  function dropKind(transfer) {
3686
4701
  const types = Array.from(transfer?.types || [], (type) => type.toLowerCase());
3687
4702
  if (types.includes("files") || transfer?.files?.length) return "files";
@@ -3721,7 +4736,7 @@ function droppedURLs(transfer) {
3721
4736
  return [...urls];
3722
4737
  }
3723
4738
 
3724
- // src/v2/app-export.js
4739
+ // src/app-export.js
3725
4740
  var exportControls = [
3726
4741
  "save-button",
3727
4742
  "save-dialog",
@@ -3760,16 +4775,16 @@ function bindAppExport({
3760
4775
  $("save-error").textContent = "";
3761
4776
  };
3762
4777
  function formatChanged() {
3763
- const format = $("save-format").value;
4778
+ const format2 = $("save-format").value;
3764
4779
  if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
3765
4780
  suggestedName();
3766
4781
  }
3767
4782
  if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
3768
4783
  if ($("save-scene-note")) $("save-scene-note").hidden = !isScene();
3769
- $("save-colors-row").hidden = !["obj", "3mf"].includes(format);
3770
- $("save-cad-note").hidden = !["step", "iges"].includes(format);
4784
+ $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4785
+ $("save-cad-note").hidden = !["step", "iges"].includes(format2);
3771
4786
  $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
3772
- $("save-submit").textContent = format === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4787
+ $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
3773
4788
  sync();
3774
4789
  }
3775
4790
  function suggestedName() {
@@ -3891,7 +4906,7 @@ function bindAppExport({
3891
4906
  };
3892
4907
  }
3893
4908
 
3894
- // src/v2/app-print.js
4909
+ // src/app-print.js
3895
4910
  var printControls = [
3896
4911
  "print-button",
3897
4912
  "print-panel",
@@ -3925,6 +4940,7 @@ function bindAppPrint({
3925
4940
  }) {
3926
4941
  let job = null, report = null, timer = null, disposed = false, scopeKey = "", previousScope = "", unavailable = false;
3927
4942
  const panel = $("print-panel"), document2 = panel.ownerDocument;
4943
+ const pausedForCheck = /* @__PURE__ */ new Set();
3928
4944
  const summary = {
3929
4945
  issues_found: "Issues found",
3930
4946
  incomplete: "Check incomplete",
@@ -3946,6 +4962,15 @@ function bindAppPrint({
3946
4962
  const s = getState();
3947
4963
  $("print-button").disabled = s.busy || !s.models.length;
3948
4964
  if (panel.hidden) return;
4965
+ for (const id of pausedForCheck)
4966
+ if (!s.models.some((m) => m.id === id) || getViewer()?.get_model_animation_state(id)?.status !== "paused")
4967
+ pausedForCheck.delete(id);
4968
+ if ($("print-resume")) {
4969
+ $("print-resume").hidden = !pausedForCheck.size;
4970
+ $("print-resume").disabled = !!job || s.busy;
4971
+ }
4972
+ if ($("print-animation-note"))
4973
+ $("print-animation-note").hidden = !pausedForCheck.size;
3949
4974
  const wasUnavailable = unavailable;
3950
4975
  unavailable = s.busy || !s.models.length;
3951
4976
  $("print-run").disabled = !!job || unavailable;
@@ -4092,6 +5117,19 @@ function bindAppPrint({
4092
5117
  listen($("print-fields"), "change", changed);
4093
5118
  listen($("print-clear"), "click", clear);
4094
5119
  listen($("print-cancel"), "click", cancel);
5120
+ if ($("print-resume"))
5121
+ listen($("print-resume"), "click", () => {
5122
+ if (job || getState().busy) return;
5123
+ for (const id of pausedForCheck)
5124
+ if (getState().models.some((m) => m.id === id) && getViewer().get_model_animation_state(id)?.status === "paused")
5125
+ getViewer().resume_model_animation(id);
5126
+ pausedForCheck.clear();
5127
+ report = null;
5128
+ clear();
5129
+ $("print-results").replaceChildren();
5130
+ $("print-status").textContent = "Animation resumed. Pause and run the check again for the new pose.";
5131
+ sync();
5132
+ });
4095
5133
  listen($("print-results"), "click", (e) => {
4096
5134
  const b = e.target.closest("button[data-issue]");
4097
5135
  if (!b || !report) return;
@@ -4135,6 +5173,13 @@ function bindAppPrint({
4135
5173
  }
4136
5174
  }
4137
5175
  scopeKey = key();
5176
+ for (const id of stateIds()) {
5177
+ if (getViewer().get_model_animation_state(id)?.status === "running") {
5178
+ getViewer().pause_model_animation(id);
5179
+ pausedForCheck.add(id);
5180
+ }
5181
+ }
5182
+ sync();
4138
5183
  const result = await getViewer().check_printability({
4139
5184
  model_ids: stateIds(),
4140
5185
  level: $("print-level").value,
@@ -4172,11 +5217,216 @@ function bindAppPrint({
4172
5217
  disposed = true;
4173
5218
  close(false);
4174
5219
  report = null;
5220
+ pausedForCheck.clear();
5221
+ }
5222
+ };
5223
+ }
5224
+
5225
+ // src/app-animation.js
5226
+ var animationControls = [
5227
+ "animation-details",
5228
+ "animation-fields",
5229
+ "animation-axis",
5230
+ "animation-seconds",
5231
+ "animation-direction",
5232
+ "animation-play",
5233
+ "animation-pause",
5234
+ "animation-stop",
5235
+ "animation-status",
5236
+ "animation-error"
5237
+ ];
5238
+ var axes4 = ["x", "y", "z"];
5239
+ function syncAnimatedRecord(record, model) {
5240
+ if (!record || !model?.geometry) return;
5241
+ record.bounds = Object.fromEntries(
5242
+ ["min", "max"].map((key) => [
5243
+ key,
5244
+ model.geometry.bounds[key].map(
5245
+ (v, i) => (v - model.center[i]) * model.scaleVector[i]
5246
+ )
5247
+ ])
5248
+ );
5249
+ record.rotation = Object.fromEntries(
5250
+ axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5251
+ );
5252
+ record.position = { x: 0, y: 0, z: 0 };
5253
+ const offset = enginePosition(record);
5254
+ for (const a of axes4) record.position[a] = model[a] - offset[a];
5255
+ }
5256
+ function centerAnimationPivot(record, model) {
5257
+ syncAnimatedRecord(record, model);
5258
+ const center = model.geometry.bounds.min.map(
5259
+ (v, i) => (v + model.geometry.bounds.max[i]) / 2
5260
+ );
5261
+ const delta = center.map(
5262
+ (v, i) => (v - model.center[i]) * model.scaleVector[i]
5263
+ );
5264
+ const rotation2 = rotationMatrix(record.rotation);
5265
+ const original = record.originalPosition || { x: 0, y: 0, z: 0 };
5266
+ record.originalPosition = Object.fromEntries(
5267
+ axes4.map((a, i) => [a, original[a] + delta[i]])
5268
+ );
5269
+ for (let i = 0; i < 3; i++)
5270
+ model[axes4[i]] += delta.reduce(
5271
+ (sum, v, j) => sum + rotation2[i * 3 + j] * v,
5272
+ 0
5273
+ );
5274
+ model.center = center;
5275
+ record.animationPivot = true;
5276
+ syncAnimatedRecord(record, model);
5277
+ }
5278
+ function spinDefinition(axis, seconds, direction) {
5279
+ if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5280
+ throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
5281
+ return {
5282
+ delta: {
5283
+ ["rotation" + axis]: 2 * Math.PI * Number(direction),
5284
+ msec: seconds * 1e3,
5285
+ loop: true
5286
+ }
5287
+ };
5288
+ }
5289
+ function bindAppAnimation({
5290
+ $,
5291
+ listen,
5292
+ window: window2,
5293
+ getState,
5294
+ getViewer,
5295
+ beforePlay,
5296
+ refreshPose,
5297
+ announce
5298
+ }) {
5299
+ const settings = /* @__PURE__ */ new Map();
5300
+ let selected = null, timer = null, disposed = false;
5301
+ const state = () => {
5302
+ const s = getState();
5303
+ return {
5304
+ ...s,
5305
+ playback: s.selectedId == null ? null : getViewer()?.get_model_animation_state(s.selectedId)
5306
+ };
5307
+ };
5308
+ function sync() {
5309
+ if (disposed) return;
5310
+ const s = state(), available = s.models.some((m) => m.id === s.selectedId);
5311
+ for (const id of settings.keys())
5312
+ if (!s.models.some((m) => m.id === id)) settings.delete(id);
5313
+ $("animation-details").hidden = !available;
5314
+ $("animation-fields").disabled = s.busy || !available;
5315
+ if (selected !== s.selectedId) {
5316
+ selected = s.selectedId;
5317
+ let value = settings.get(selected);
5318
+ if (!value) {
5319
+ value = { axis: "z", seconds: 5, direction: "1" };
5320
+ const a = getViewer()?._models.get(selected)?.animation;
5321
+ const entries = Object.entries(a?.delta || {}).filter(
5322
+ ([k, v]) => k.startsWith("rotation") && v !== 0
5323
+ );
5324
+ if (a?.delta?.loop && !a.exact && entries.length === 1) {
5325
+ value = {
5326
+ axis: entries[0][0].slice(-1),
5327
+ seconds: a.delta.msec * 2 * Math.PI / Math.abs(entries[0][1]) / 1e3,
5328
+ direction: entries[0][1] < 0 ? "-1" : "1"
5329
+ };
5330
+ }
5331
+ settings.set(selected, value);
5332
+ }
5333
+ $("animation-axis").value = value.axis;
5334
+ $("animation-seconds").value = value.seconds;
5335
+ $("animation-direction").value = value.direction;
5336
+ $("animation-error").hidden = true;
5337
+ }
5338
+ const status = s.playback?.status || "idle", active = ["running", "paused"].includes(status);
5339
+ $("animation-play").textContent = active ? "Restart spin" : "Play";
5340
+ $("animation-pause").textContent = status === "paused" ? "Resume" : "Pause";
5341
+ $("animation-pause").disabled = !active;
5342
+ $("animation-stop").disabled = !active;
5343
+ const label = {
5344
+ idle: "Ready",
5345
+ running: "Playing",
5346
+ paused: "Paused",
5347
+ stopped: "Stopped",
5348
+ completed: "Finished"
5349
+ }[status];
5350
+ const text = `${label}. ${active ? "Stop keeps the current pose." : "Spin the selected model around its center."}`;
5351
+ if ($("animation-status").textContent !== text)
5352
+ $("animation-status").textContent = text;
5353
+ if (status === "running" && !timer)
5354
+ timer = window2.setInterval(() => {
5355
+ if (!disposed && !window2.document.hidden) refreshPose();
5356
+ }, 250);
5357
+ if (status !== "running" && timer) {
5358
+ window2.clearInterval(timer);
5359
+ timer = null;
5360
+ }
5361
+ }
5362
+ function play(keepPaused = false) {
5363
+ const s = state();
5364
+ if (s.busy || s.selectedId == null) return;
5365
+ try {
5366
+ const value = {
5367
+ axis: $("animation-axis").value,
5368
+ seconds: $("animation-seconds").valueAsNumber,
5369
+ direction: $("animation-direction").value
5370
+ };
5371
+ const definition = spinDefinition(
5372
+ value.axis,
5373
+ value.seconds,
5374
+ value.direction
5375
+ );
5376
+ beforePlay(s.selectedId);
5377
+ getViewer().animate_model(s.selectedId, definition);
5378
+ if (keepPaused && s.playback?.status === "paused")
5379
+ getViewer().pause_model_animation(s.selectedId);
5380
+ settings.set(s.selectedId, value);
5381
+ $("animation-error").hidden = true;
5382
+ announce(
5383
+ keepPaused && s.playback?.status === "paused" ? "Spin updated. Still paused." : "Animation playing."
5384
+ );
5385
+ sync();
5386
+ } catch (error) {
5387
+ $("animation-error").textContent = error.message;
5388
+ $("animation-error").hidden = false;
5389
+ }
5390
+ }
5391
+ listen($("animation-play"), "click", () => play());
5392
+ listen($("animation-pause"), "click", () => {
5393
+ const s = state();
5394
+ if (s.busy || s.selectedId == null) return;
5395
+ getViewer()[s.playback?.status === "paused" ? "resume_model_animation" : "pause_model_animation"](s.selectedId);
5396
+ sync();
5397
+ });
5398
+ listen($("animation-stop"), "click", () => {
5399
+ const s = state();
5400
+ if (!s.busy && s.selectedId != null)
5401
+ getViewer().animate_model(s.selectedId, null);
5402
+ sync();
5403
+ });
5404
+ for (const id of [
5405
+ "animation-axis",
5406
+ "animation-seconds",
5407
+ "animation-direction"
5408
+ ])
5409
+ listen($(id), "change", () => {
5410
+ if (["running", "paused"].includes(state().playback?.status)) play(true);
5411
+ else
5412
+ settings.set(selected, {
5413
+ axis: $("animation-axis").value,
5414
+ seconds: $("animation-seconds").valueAsNumber,
5415
+ direction: $("animation-direction").value
5416
+ });
5417
+ });
5418
+ sync();
5419
+ return {
5420
+ sync,
5421
+ dispose() {
5422
+ disposed = true;
5423
+ window2.clearInterval(timer);
5424
+ settings.clear();
4175
5425
  }
4176
5426
  };
4177
5427
  }
4178
5428
 
4179
- // src/v2/app.js
5429
+ // src/app.js
4180
5430
  var mountedRoots = /* @__PURE__ */ new WeakMap();
4181
5431
  var requiredControls = [
4182
5432
  "background-color",
@@ -4281,6 +5531,10 @@ function mountViewstlApp(root, options = {}) {
4281
5531
  missing.push(...exportControls.filter((id) => !$(id)));
4282
5532
  if (printControls.some((id) => $(id)))
4283
5533
  missing.push(...printControls.filter((id) => !$(id)));
5534
+ if (animationControls.some((id) => $(id)))
5535
+ missing.push(...animationControls.filter((id) => !$(id)));
5536
+ if (toolsControls.some((id) => $(id)))
5537
+ missing.push(...toolsControls.filter((id) => !$(id)));
4284
5538
  if (missing.length)
4285
5539
  throw new Error("Missing ViewSTL controls: " + missing.join(", "));
4286
5540
  const abort = new window2.AbortController();
@@ -4308,6 +5562,8 @@ function mountViewstlApp(root, options = {}) {
4308
5562
  let viewerPromise, viewer, generation = 0, appearanceRequest = 0, modelID = null, nextID = 0, loadingID = null;
4309
5563
  let exportUI;
4310
5564
  let printUI;
5565
+ let animationUI;
5566
+ let toolsUI;
4311
5567
  let sceneImport;
4312
5568
  let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, transformTool = "move", modelMover, dragArrangement, background = "transparent";
4313
5569
  const viewNames = {
@@ -4356,6 +5612,8 @@ function mountViewstlApp(root, options = {}) {
4356
5612
  $(name + "-panel").hidden = !open;
4357
5613
  $(name + "-button").setAttribute("aria-expanded", String(open));
4358
5614
  syncSelection();
5615
+ if (open && name === "info") populateInfo();
5616
+ if (open && (name === "scene" || name === "view")) toolsUI?.sync();
4359
5617
  }
4360
5618
  function fileSize(size) {
4361
5619
  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`;
@@ -4381,12 +5639,14 @@ function mountViewstlApp(root, options = {}) {
4381
5639
  $("background-value").textContent = background === "transparent" ? "Default" : background.toUpperCase();
4382
5640
  }
4383
5641
  function syncControls() {
4384
- const info = modelID === null ? null : viewer.get_model_info(modelID), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
5642
+ syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
5643
+ const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
4385
5644
  for (const input of modeInputs) {
4386
5645
  input.checked = input.value === mode;
4387
5646
  input.disabled = busy;
4388
5647
  }
4389
- $("view-label").textContent = viewNames[mode];
5648
+ $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5649
+ $("view-button").title = "View \xB7 " + viewNames[mode];
4390
5650
  $("smooth-shading").checked = info?.smooth_shading || false;
4391
5651
  $("smooth-shading").disabled = busy || mode === "wireframe";
4392
5652
  $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
@@ -4409,14 +5669,18 @@ function mountViewstlApp(root, options = {}) {
4409
5669
  syncPosition();
4410
5670
  exportUI?.sync();
4411
5671
  printUI?.sync();
5672
+ animationUI?.sync();
5673
+ toolsUI?.sync();
4412
5674
  }
4413
5675
  function populateInfo() {
4414
5676
  if (modelID === null) return;
4415
- const info = viewer.get_model_info(modelID), record = records.get(modelID), units = record.units === "mm" ? " mm" : "";
5677
+ const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
4416
5678
  $("info-triangles").textContent = info.triangles.toLocaleString();
4417
- $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x)).join(" \xD7 ") + units;
5679
+ $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
4418
5680
  $("info-size").textContent = fileSize(record.size);
4419
- $("unit-note").textContent = units ? "Dimensions are shown in millimetres." : "Dimensions are in model units. STL and OBJ files do not specify physical units.";
5681
+ const size = viewer.get_model_size(modelID);
5682
+ $("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.";
5683
+ toolsUI?.syncInfo();
4420
5684
  }
4421
5685
  function renderList() {
4422
5686
  $("model-list").replaceChildren(
@@ -4488,6 +5752,10 @@ function mountViewstlApp(root, options = {}) {
4488
5752
  }
4489
5753
  function arrangeScene() {
4490
5754
  setMoveMode(false);
5755
+ for (const record of records.values())
5756
+ syncAnimatedRecord(record, viewer?._models.get(record.id));
5757
+ if ($("scene-arrangement").value === "custom") return;
5758
+ for (const record of records.values()) prepareManual(record.id);
4491
5759
  if ($("scene-arrangement").value === "original")
4492
5760
  for (const record of records.values())
4493
5761
  record.rotation = {
@@ -4504,19 +5772,20 @@ function mountViewstlApp(root, options = {}) {
4504
5772
  updateGrid();
4505
5773
  viewer?.set_auto_zoom();
4506
5774
  }
4507
- function syncPosition() {
5775
+ function syncPosition(preserveFocus = false) {
4508
5776
  const record = records.get(modelID);
4509
5777
  for (const axis of ["x", "y", "z"]) {
4510
5778
  const input = $("position-" + axis);
4511
- input.value = Number((record?.position?.[axis] || 0).toPrecision(12));
5779
+ if (!preserveFocus || document2.activeElement !== input)
5780
+ input.value = Number((record?.position?.[axis] || 0).toPrecision(12));
4512
5781
  input.disabled = !record;
4513
5782
  input.removeAttribute("aria-invalid");
4514
5783
  }
4515
5784
  $("position-error").hidden = true;
4516
- $("position-units").textContent = record?.units === "mm" ? "Offset from file coordinates, in mm." : "Offset from file coordinates, in model units.";
5785
+ $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
4517
5786
  $("move-model-button").disabled = !record || busy;
4518
5787
  $("reset-position").disabled = !record;
4519
- syncRotation();
5788
+ syncRotation(preserveFocus);
4520
5789
  for (const id of [
4521
5790
  "rotate-model-button",
4522
5791
  "depth-model-button",
@@ -4545,14 +5814,16 @@ function mountViewstlApp(root, options = {}) {
4545
5814
  viewer.set_position(record.id, p.x, p.y, p.z);
4546
5815
  }
4547
5816
  function selectedTransform() {
5817
+ prepareManual(modelID);
4548
5818
  const record = records.get(modelID);
4549
5819
  return record ? { ...record, center: modelCenter(record), radius: modelRadius(record) } : null;
4550
5820
  }
4551
- function syncRotation() {
5821
+ function syncRotation(preserveFocus = false) {
4552
5822
  const record = records.get(modelID);
4553
5823
  for (const axis of ["x", "y", "z"]) {
4554
5824
  const input = $("rotation-" + axis);
4555
- input.value = Number((record?.rotation?.[axis] || 0).toPrecision(10));
5825
+ if (!preserveFocus || document2.activeElement !== input)
5826
+ input.value = Number((record?.rotation?.[axis] || 0).toPrecision(10));
4556
5827
  input.disabled = !record;
4557
5828
  input.removeAttribute("aria-invalid");
4558
5829
  }
@@ -4594,10 +5865,11 @@ function mountViewstlApp(root, options = {}) {
4594
5865
  $("model-tool").value = transformTool;
4595
5866
  $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
4596
5867
  }
4597
- function setMoveMode(enabled, tool = transformTool) {
5868
+ function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
4598
5869
  modelMover?.setEnabled(false);
4599
5870
  transformTool = tool;
4600
5871
  moveMode = !!enabled && modelID !== null && !busy;
5872
+ if (moveMode && !retainPaused) prepareManual(modelID);
4601
5873
  modelMover?.setEnabled(moveMode);
4602
5874
  syncTool();
4603
5875
  $("move-mode").hidden = !moveMode;
@@ -4621,6 +5893,7 @@ function mountViewstlApp(root, options = {}) {
4621
5893
  const input = $("rotation-" + axis);
4622
5894
  listen(input, "input", () => {
4623
5895
  if (modelID === null || !input.value.trim()) return;
5896
+ prepareManual(modelID);
4624
5897
  if (applyRotation(
4625
5898
  modelID,
4626
5899
  { ...records.get(modelID).rotation, [axis]: input.valueAsNumber },
@@ -4632,6 +5905,7 @@ function mountViewstlApp(root, options = {}) {
4632
5905
  });
4633
5906
  listen(input, "change", () => {
4634
5907
  if (modelID === null) return;
5908
+ prepareManual(modelID);
4635
5909
  if (!input.value.trim() || !applyRotation(modelID, {
4636
5910
  ...records.get(modelID).rotation,
4637
5911
  [axis]: input.valueAsNumber
@@ -4644,6 +5918,7 @@ function mountViewstlApp(root, options = {}) {
4644
5918
  listen($("reset-rotation"), "click", () => {
4645
5919
  setMoveMode(false);
4646
5920
  if (modelID !== null) {
5921
+ prepareManual(modelID);
4647
5922
  applyRotation(modelID, {
4648
5923
  ...records.get(modelID).originalRotation || { x: 0, y: 0, z: 0 }
4649
5924
  });
@@ -4683,6 +5958,7 @@ function mountViewstlApp(root, options = {}) {
4683
5958
  const input = $("position-" + axis);
4684
5959
  listen(input, "input", () => {
4685
5960
  if (modelID === null || !input.value.trim()) return;
5961
+ prepareManual(modelID);
4686
5962
  if (applyPosition(
4687
5963
  modelID,
4688
5964
  { ...records.get(modelID).position, [axis]: input.valueAsNumber },
@@ -4694,6 +5970,7 @@ function mountViewstlApp(root, options = {}) {
4694
5970
  });
4695
5971
  listen(input, "change", () => {
4696
5972
  if (modelID === null) return;
5973
+ prepareManual(modelID);
4697
5974
  if (!input.value.trim() || !applyPosition(modelID, {
4698
5975
  ...records.get(modelID).position,
4699
5976
  [axis]: input.valueAsNumber
@@ -4710,6 +5987,7 @@ function mountViewstlApp(root, options = {}) {
4710
5987
  listen($("reset-position"), "click", () => {
4711
5988
  setMoveMode(false);
4712
5989
  if (modelID !== null) {
5990
+ prepareManual(modelID);
4713
5991
  applyPosition(modelID, {
4714
5992
  ...records.get(modelID).originalPosition || { x: 0, y: 0, z: 0 }
4715
5993
  });
@@ -4760,6 +6038,30 @@ function mountViewstlApp(root, options = {}) {
4760
6038
  renderScene();
4761
6039
  announce(name + " removed.");
4762
6040
  }
6041
+ function prepareManual(id) {
6042
+ if (!records.has(id)) return;
6043
+ syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6044
+ viewer?.animate_model(id, null);
6045
+ }
6046
+ function refreshAnimatedPose() {
6047
+ if (disposed || modelID === null) return;
6048
+ syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6049
+ if (!$("info-panel").hidden) populateInfo();
6050
+ if (!$("scene-panel").hidden && $("position-details").open)
6051
+ syncPosition(true);
6052
+ }
6053
+ function animationChanged(id, state, reason) {
6054
+ if (disposed) return;
6055
+ if (reason === "restored" && state.status === "running" && window2.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
6056
+ viewer.pause_model_animation(id);
6057
+ syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6058
+ if (["started", "restored", "resumed"].includes(reason))
6059
+ $("scene-arrangement").value = "custom";
6060
+ if (id === modelID) refreshAnimatedPose();
6061
+ animationUI?.sync();
6062
+ printUI?.sync();
6063
+ options.animation_state_callback?.(id, state, reason);
6064
+ }
4763
6065
  async function getViewer() {
4764
6066
  if (!viewerPromise)
4765
6067
  viewerPromise = Promise.resolve().then(async () => {
@@ -4774,6 +6076,10 @@ function mountViewstlApp(root, options = {}) {
4774
6076
  center_models: false,
4775
6077
  bg_color: background,
4776
6078
  allow_drag_and_drop: false,
6079
+ animation_state_callback: animationChanged,
6080
+ animation_complete_callback: (...args) => {
6081
+ if (!disposed) options.animation_complete_callback?.(...args);
6082
+ },
4777
6083
  camera_state: {
4778
6084
  position: { x: 90, y: -130, z: 90 },
4779
6085
  target: { x: 0, y: 0, z: 0 },
@@ -4865,7 +6171,7 @@ function mountViewstlApp(root, options = {}) {
4865
6171
  if (queue.length && !draining) drainPromise = drainQueue();
4866
6172
  return drainPromise || Promise.resolve();
4867
6173
  }
4868
- function openURL(value, format = "") {
6174
+ function openURL(value, format2 = "") {
4869
6175
  assertActive();
4870
6176
  if (!options.url_import_endpoint)
4871
6177
  throw new Error("URL imports are not configured.");
@@ -4874,7 +6180,7 @@ function mountViewstlApp(root, options = {}) {
4874
6180
  throw new Error(
4875
6181
  "Use a public HTTP or HTTPS file URL without login credentials."
4876
6182
  );
4877
- if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
6183
+ if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
4878
6184
  throw new Error("Choose a supported 3D format.");
4879
6185
  url.hash = "";
4880
6186
  const name = decodeURIComponent(
@@ -4883,9 +6189,9 @@ function mountViewstlApp(root, options = {}) {
4883
6189
  queue.push({
4884
6190
  name,
4885
6191
  remote: url.href,
4886
- format,
6192
+ format: format2,
4887
6193
  size: 0,
4888
- scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6194
+ scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
4889
6195
  });
4890
6196
  if (!draining) drainPromise = drainQueue();
4891
6197
  return drainPromise || Promise.resolve();
@@ -4954,7 +6260,7 @@ function mountViewstlApp(root, options = {}) {
4954
6260
  await engine.when_idle();
4955
6261
  if (token !== generation) continue;
4956
6262
  loadingID = null;
4957
- const loaded = lastLoadedID === id ? engine.get_model_info(id) : null;
6263
+ const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
4958
6264
  if (!loaded) {
4959
6265
  announce(source.name + " could not be opened.");
4960
6266
  continue;
@@ -5046,7 +6352,7 @@ function mountViewstlApp(root, options = {}) {
5046
6352
  );
5047
6353
  const record = {
5048
6354
  id,
5049
- name: String(m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6355
+ name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
5050
6356
  size: g.file_bytes ?? m.local_file?.size ?? null,
5051
6357
  units: g.units || m.units,
5052
6358
  bounds,
@@ -5355,6 +6661,64 @@ function mountViewstlApp(root, options = {}) {
5355
6661
  },
5356
6662
  announce
5357
6663
  });
6664
+ if ($("animation-details"))
6665
+ animationUI = bindAppAnimation({
6666
+ $,
6667
+ listen,
6668
+ window: window2,
6669
+ getState: () => ({
6670
+ busy,
6671
+ selectedId: modelID,
6672
+ models: [...records.values()]
6673
+ }),
6674
+ getViewer: () => viewer,
6675
+ beforePlay(id) {
6676
+ setMoveMode(false);
6677
+ prepareManual(id);
6678
+ centerAnimationPivot(records.get(id), viewer._models.get(id));
6679
+ $("scene-arrangement").value = "custom";
6680
+ },
6681
+ refreshPose: refreshAnimatedPose,
6682
+ announce
6683
+ });
6684
+ if ($("size-details"))
6685
+ toolsUI = bindAppTools({
6686
+ $,
6687
+ listen,
6688
+ window: window2,
6689
+ getViewer: () => viewer,
6690
+ getState: () => ({ selectedId: modelID, busy }),
6691
+ refresh: renderScene,
6692
+ announce,
6693
+ changeSize(operation) {
6694
+ setMoveMode(false);
6695
+ const record = records.get(modelID);
6696
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6697
+ operation();
6698
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6699
+ $("scene-arrangement").value = "custom";
6700
+ renderScene();
6701
+ },
6702
+ duplicate() {
6703
+ const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6704
+ const record = {
6705
+ ...original,
6706
+ id,
6707
+ name: m.display_name,
6708
+ position: { ...original.position },
6709
+ rotation: { ...original.rotation }
6710
+ };
6711
+ syncAnimatedRecord(record, m);
6712
+ record.originalPosition = { ...record.position };
6713
+ record.originalRotation = { ...record.rotation };
6714
+ records.set(id, record);
6715
+ nextID = Math.max(nextID, id + 1);
6716
+ $("scene-arrangement").value = "custom";
6717
+ selectModel(id);
6718
+ setMoveMode(true, "move", true);
6719
+ announce("Copy selected at the same position. Drag to move it.");
6720
+ }
6721
+ });
5358
6722
  updateGrid();
5359
6723
  setBusy(false);
5360
6724
  setMoveMode(false);
@@ -5379,6 +6743,8 @@ function mountViewstlApp(root, options = {}) {
5379
6743
  },
5380
6744
  getState() {
5381
6745
  assertActive();
6746
+ for (const record of records.values())
6747
+ syncAnimatedRecord(record, viewer?._models.get(record.id));
5382
6748
  return {
5383
6749
  selectedId: modelID,
5384
6750
  busy,
@@ -5391,7 +6757,8 @@ function mountViewstlApp(root, options = {}) {
5391
6757
  units: record.units,
5392
6758
  position: { ...record.position },
5393
6759
  rotation: { ...record.rotation },
5394
- color: record.color
6760
+ color: record.color,
6761
+ animation: viewer.get_model_animation_state(record.id)
5395
6762
  }))
5396
6763
  };
5397
6764
  },
@@ -5399,6 +6766,8 @@ function mountViewstlApp(root, options = {}) {
5399
6766
  if (disposed) return;
5400
6767
  exportUI?.dispose();
5401
6768
  printUI?.dispose();
6769
+ animationUI?.dispose();
6770
+ toolsUI?.dispose();
5402
6771
  cancelLoading();
5403
6772
  setMoveMode(false);
5404
6773
  appearanceRequest++;
@@ -5416,7 +6785,7 @@ function mountViewstlApp(root, options = {}) {
5416
6785
  return api;
5417
6786
  }
5418
6787
 
5419
- // src/v2/index.js
6788
+ // src/index.js
5420
6789
  StlViewer.workerURL = new URL("./viewstl.worker.js", import.meta.url).href;
5421
6790
  var index_default = StlViewer;
5422
6791
  export {