@omrips/viewstl 2.0.0-rc.5 → 2.0.0-rc.7

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 (37) hide show
  1. package/README.md +3 -3
  2. package/build/THIRD-PARTY-NOTICES.txt +1006 -1006
  3. package/build/cad/occt-import-js.source.txt +19 -19
  4. package/build/chunks/archive-QK7XZSVT.js +1 -0
  5. package/build/chunks/{cad-4JYHORYZ.js → cad-QGVO6UGR.js} +1 -1
  6. package/build/chunks/chunk-6AW5U52H.js +1 -0
  7. package/build/chunks/{chunk-Y6VY64LY.js → chunk-CVGQTUJ5.js} +2 -2
  8. package/build/chunks/chunk-FUXP2W57.js +1 -0
  9. package/build/chunks/chunk-LFTKNL2V.js +1 -0
  10. package/build/chunks/{chunk-PQYEV7WH.js → chunk-T6PPRDOE.js} +1 -1
  11. package/build/chunks/chunk-V6KYGP62.js +1 -0
  12. package/build/chunks/{export-VM6OQCQJ.js → export-2CFMTPJ3.js} +3 -3
  13. package/build/chunks/{export-cad-QXCB6UDU.js → export-cad-XOXIE726.js} +1 -1
  14. package/build/chunks/{export-zip-PVXETVLK.js → export-zip-I2ADLFJI.js} +1 -1
  15. package/build/chunks/measure-3LDALPCN.js +1 -0
  16. package/build/chunks/print-check-WWHTT5PW.js +1 -0
  17. package/build/chunks/scene-export-7P6ZJOG3.js +1 -0
  18. package/build/chunks/scene-reader-WXTRELY3.js +1 -0
  19. package/build/node/analysis-worker.js +2 -1
  20. package/build/stl_viewer.min.js +6 -5
  21. package/build/viewstl.cjs +1246 -403
  22. package/build/viewstl.d.cts +48 -0
  23. package/build/viewstl.d.ts +48 -0
  24. package/build/viewstl.js +1246 -402
  25. package/build/viewstl.min.js +6 -5
  26. package/build/viewstl.worker.js +1 -1
  27. package/docs/releases/2.0.0-rc.7.md +16 -0
  28. package/docs/v2-usage.md +39 -0
  29. package/docs/v2-verification.md +71 -0
  30. package/package.json +2 -2
  31. package/build/chunks/archive-JU75RPTU.js +0 -1
  32. package/build/chunks/chunk-IK3WL53C.js +0 -1
  33. package/build/chunks/chunk-OP2L7L4N.js +0 -1
  34. package/build/chunks/print-check-NJ4IONC5.js +0 -1
  35. package/build/chunks/scene-export-ZIXMUNLI.js +0 -1
  36. package/build/chunks/scene-reader-JGHERXR2.js +0 -1
  37. package/docs/releases/2.0.0-rc.5.md +0 -46
package/build/viewstl.cjs CHANGED
@@ -1,3 +1,4 @@
1
+ //=========== Stl Viewer v2.0.0-rc.7, by Omri Rips, Viewstl.com, November 2026 ; admin@viewstl.com ===========
1
2
  var __defProp = Object.defineProperty;
2
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -25,54 +26,6 @@ __export(commonjs_exports, {
25
26
  });
26
27
  module.exports = __toCommonJS(commonjs_exports);
27
28
 
28
- // src/appearance.js
29
- var DISPLAYS = [
30
- "flat",
31
- "smooth",
32
- "wireframe",
33
- "edges",
34
- "mesh",
35
- "xray",
36
- "studio",
37
- "orientation"
38
- ];
39
- function validateView(display, smooth) {
40
- if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
41
- if (typeof smooth !== "boolean")
42
- throw new Error("smooth_shading must be a boolean");
43
- return { display, smooth_shading: display === "smooth" || smooth };
44
- }
45
- function viewFeatures(view) {
46
- return {
47
- smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
48
- edges: view.view_edges || view.display === "edges",
49
- wire: view.display === "wireframe",
50
- edgeAngle: view.display === "edges" ? 30 : 1
51
- };
52
- }
53
- function validateSection(section) {
54
- if (section == null) return null;
55
- if (!["x", "y", "z"].includes(section.axis))
56
- throw new Error("Section axis must be x, y or z");
57
- if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
58
- throw new Error("Section position must be between 0 and 1");
59
- if (section.flip !== void 0 && typeof section.flip !== "boolean")
60
- throw new Error("Section flip must be a boolean");
61
- return {
62
- axis: section.axis,
63
- position: section.position,
64
- flip: section.flip ?? false
65
- };
66
- }
67
- function sectionPlane(bounds, section) {
68
- if (!section) return [0, 0, 0, 0];
69
- const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
70
- const plane = [0, 0, 0, 0];
71
- plane[axis] = sign;
72
- plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
73
- return plane;
74
- }
75
-
76
29
  // src/math.js
77
30
  var add = (a, b) => a.map((v, i) => v + b[i]);
78
31
  var sub = (a, b) => a.map((v, i) => v - b[i]);
@@ -224,22 +177,606 @@ function rayTriangle(o, d, p, i) {
224
177
  const t = dot(c, q) / det;
225
178
  return t >= 0 ? t : Infinity;
226
179
  }
227
-
228
- // src/memory.js
229
- var MEMORY_OVERHEAD = 64 * 1024 * 1024;
230
- var TRIANGLE_MEMORY = 224;
231
- function geometryMemory(g) {
232
- if (!g) return 0;
233
- return g.triangles * TRIANGLE_MEMORY + (g.smoothNormals?.byteLength || 0) * 4 + ((g.edges?.byteLength || 0) + (g.wire?.byteLength || 0)) * 2;
180
+
181
+ // src/animation.js
182
+ var animationKeys = [
183
+ "x",
184
+ "y",
185
+ "z",
186
+ "rotationx",
187
+ "rotationy",
188
+ "rotationz",
189
+ "scalex",
190
+ "scaley",
191
+ "scalez"
192
+ ];
193
+ var isScale = (key) => key.startsWith("scale");
194
+ var number = (value, name, positive2 = false) => {
195
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
196
+ throw new Error(`Invalid animation ${name}`);
197
+ return value;
198
+ };
199
+ var object = (value, name) => {
200
+ if (!value || typeof value !== "object" || Array.isArray(value))
201
+ throw new Error(`Invalid animation ${name}`);
202
+ return value;
203
+ };
204
+ function animationPose(model) {
205
+ return Object.fromEntries(
206
+ animationKeys.map((key) => [
207
+ key,
208
+ number(
209
+ model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
210
+ key,
211
+ isScale(key)
212
+ )
213
+ ])
214
+ );
215
+ }
216
+ function normalizeAnimation(input) {
217
+ object(input, "definition");
218
+ const result = {}, claimed = /* @__PURE__ */ new Set();
219
+ for (const mode of ["delta", "exact"]) {
220
+ if (input[mode] == null) continue;
221
+ const source = object(input[mode], mode), values = {};
222
+ const msec = number(source.msec ?? 300, "duration", true);
223
+ if (source.loop !== void 0 && typeof source.loop !== "boolean")
224
+ throw new Error("Animation loop must be a boolean");
225
+ const loop = mode === "delta" && !!source.loop;
226
+ for (const key of animationKeys) {
227
+ const value = source[key] ?? (isScale(key) ? source.scale : void 0);
228
+ if (value === void 0) continue;
229
+ if (claimed.has(key))
230
+ throw new Error(`Animation delta/exact conflict: ${key}`);
231
+ number(value, key, mode === "exact" && isScale(key));
232
+ if (loop && isScale(key) && value < 0)
233
+ throw new Error("Looping scale deltas cannot be negative");
234
+ values[key] = value;
235
+ claimed.add(key);
236
+ }
237
+ if (!Object.keys(values).length)
238
+ throw new Error("Animation track needs a transform");
239
+ result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
240
+ }
241
+ if (!Object.keys(result).length)
242
+ throw new Error("Animation needs delta or exact");
243
+ return result;
244
+ }
245
+ function normalizePlaybackState(input) {
246
+ object(input, "playback state");
247
+ if (input.version !== 1 || typeof input.paused !== "boolean")
248
+ throw new Error("Invalid animation playback state version or paused flag");
249
+ const elapsed_ms = number(input.elapsed_ms, "elapsed time");
250
+ if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
251
+ object(input.origin, "origin");
252
+ for (const key of animationKeys) number(input.origin[key], key, isScale(key));
253
+ if (!Array.isArray(input.center) || input.center.length !== 3)
254
+ throw new Error("Invalid animation pivot");
255
+ return {
256
+ version: 1,
257
+ elapsed_ms,
258
+ paused: input.paused,
259
+ origin: animationPose(input.origin),
260
+ center: input.center.map((v) => number(v, "pivot"))
261
+ };
262
+ }
263
+ function createPlayback(definition, model, saved) {
264
+ const animation = normalizeAnimation(definition);
265
+ const state = saved == null ? null : normalizePlaybackState(saved);
266
+ const origin = state?.origin ?? animationPose(model);
267
+ const tracks = Object.entries(animation).map(([mode, a]) => {
268
+ const values = Object.entries(a).filter(
269
+ ([key]) => animationKeys.includes(key)
270
+ );
271
+ if (mode === "delta")
272
+ for (const [key, value] of values)
273
+ number(origin[key] + value, `${key} endpoint`, isScale(key));
274
+ return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
275
+ });
276
+ const playback = {
277
+ animation,
278
+ origin,
279
+ tracks,
280
+ elapsed_ms: state?.elapsed_ms ?? 0,
281
+ paused: state?.paused ?? false,
282
+ last_time: null
283
+ };
284
+ evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
285
+ return playback;
286
+ }
287
+ function evaluatePlayback(playback, model) {
288
+ const updates = [];
289
+ for (const track of playback.tracks) {
290
+ if (track.done) continue;
291
+ const fraction = playback.elapsed_ms / track.duration;
292
+ const p = track.loop ? fraction : Math.min(fraction, 1);
293
+ for (const [key, value] of track.values) {
294
+ const from = playback.origin[key];
295
+ const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
296
+ updates.push([key, number(to, `${key} result`, isScale(key))]);
297
+ }
298
+ }
299
+ let scaleChanged = false;
300
+ for (const [key, value] of updates) {
301
+ scaleChanged ||= isScale(key) && model[key] !== value;
302
+ model[key] = value;
303
+ }
304
+ for (const track of playback.tracks)
305
+ if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
306
+ if (scaleChanged) model._stats = null;
307
+ return playback.tracks.some((track) => !track.done);
308
+ }
309
+ function advancePlayback(playback, model, time) {
310
+ if (playback.paused) return true;
311
+ if (playback.last_time !== null)
312
+ playback.elapsed_ms = number(
313
+ playback.elapsed_ms + Math.max(0, time - playback.last_time),
314
+ "elapsed time"
315
+ );
316
+ playback.last_time = time;
317
+ return evaluatePlayback(playback, model);
318
+ }
319
+ function playbackState(playback, suspended = false) {
320
+ return {
321
+ status: playback.paused ? "paused" : "running",
322
+ elapsed_ms: playback.elapsed_ms,
323
+ suspended,
324
+ tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
325
+ mode: t.mode,
326
+ msec: t.duration,
327
+ loop: t.loop,
328
+ progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
329
+ }))
330
+ };
331
+ }
332
+ function savePlayback(model) {
333
+ const p = model._animation;
334
+ if (!p) return null;
335
+ const factor = model.unitScale ?? 1;
336
+ const animation = structuredClone(p.animation), origin = { ...p.origin };
337
+ for (const key of animationKeys.filter(isScale)) {
338
+ origin[key] *= factor;
339
+ for (const track of Object.values(animation))
340
+ if (track[key] !== void 0) track[key] *= factor;
341
+ }
342
+ const animation_state = normalizePlaybackState({
343
+ version: 1,
344
+ origin,
345
+ elapsed_ms: p.elapsed_ms,
346
+ paused: p.paused,
347
+ center: [...model.center]
348
+ });
349
+ return { animation: normalizeAnimation(animation), animation_state };
350
+ }
351
+
352
+ // src/memory.js
353
+ var MEMORY_OVERHEAD = 64 * 1024 * 1024;
354
+ var TRIANGLE_MEMORY = 224;
355
+ function geometryMemory(g) {
356
+ if (!g) return 0;
357
+ return g.triangles * TRIANGLE_MEMORY + (g.smoothNormals?.byteLength || 0) * 4 + ((g.edges?.byteLength || 0) + (g.wire?.byteLength || 0)) * 2;
358
+ }
359
+ function assertMemory(limits, bytes, operation = "model") {
360
+ if (bytes > (limits.max_memory_bytes ?? Number.MAX_SAFE_INTEGER))
361
+ throw new Error(
362
+ `Estimated memory for this ${operation} exceeds the device safety budget. Use a smaller model or a device with more memory.`
363
+ );
364
+ }
365
+ function derivedMemory(triangles, features) {
366
+ return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
367
+ }
368
+
369
+ // src/print-options.js
370
+ function printOptions(input = {}) {
371
+ const level = input.level ?? "basic";
372
+ if (!["basic", "detailed"].includes(level))
373
+ throw new Error("Choose basic or detailed print checks");
374
+ const number2 = (v, name, max = 1e9) => {
375
+ if (!Number.isFinite(v) || v <= 0 || v > max)
376
+ throw new Error(`Invalid ${name}`);
377
+ return v;
378
+ };
379
+ let profile = null;
380
+ if (input.profile != null) {
381
+ if (input.profile.technology !== "fdm")
382
+ throw new Error("Only FDM profiles are supported");
383
+ profile = { technology: "fdm" };
384
+ for (const key of ["min_wall_mm", "line_width_mm"])
385
+ if (input.profile[key] != null)
386
+ profile[key] = number2(input.profile[key], key);
387
+ if (input.profile.overhang_degrees != null)
388
+ profile.overhang_degrees = number2(
389
+ input.profile.overhang_degrees,
390
+ "overhang angle",
391
+ 89.9
392
+ );
393
+ if (input.profile.build_volume_mm != null) {
394
+ if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
395
+ throw new Error("Build volume requires X, Y and Z");
396
+ profile.build_volume_mm = input.profile.build_volume_mm.map(
397
+ (v) => number2(v, "build volume")
398
+ );
399
+ }
400
+ }
401
+ const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
402
+ return {
403
+ level,
404
+ profile,
405
+ apply_transforms: input.apply_transforms !== false,
406
+ units_confirmed: input.units_confirmed === true,
407
+ max_time_ms: time
408
+ };
409
+ }
410
+ var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
411
+ var printSignature = (model) => JSON.stringify([
412
+ model.x,
413
+ model.y,
414
+ model.z,
415
+ model.rotationx,
416
+ model.rotationy,
417
+ model.rotationz,
418
+ model.scaleVector,
419
+ model.center,
420
+ model.units,
421
+ model.unitScale
422
+ ]);
423
+
424
+ // src/model-tools.js
425
+ var axes = ["x", "y", "z"];
426
+ function validateUnits(units) {
427
+ if (!["mm", "inch"].includes(units)) throw new Error("Choose mm or inch");
428
+ return units;
429
+ }
430
+ function validateSizeState(value) {
431
+ 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))
432
+ throw new Error("Invalid saved model size");
433
+ return {
434
+ version: 1,
435
+ baseline_mm: [...value.baseline_mm],
436
+ file_units: validateUnits(value.file_units),
437
+ editable: value.editable,
438
+ unit_factor: value.unit_factor ?? 1
439
+ };
440
+ }
441
+ function sizeState(model) {
442
+ return model.size_state ??= {
443
+ version: 1,
444
+ baseline_mm: model.geometry.bounds.max.map(
445
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.unitScale
446
+ ),
447
+ file_units: "mm",
448
+ editable: !model.geometry.units,
449
+ unit_factor: 1
450
+ };
451
+ }
452
+ function modelSize(model) {
453
+ if (!model.geometry) throw new Error("Wait for the model to finish opening");
454
+ const state = sizeState(model);
455
+ return {
456
+ ...structuredClone(state),
457
+ dimensions_mm: model.geometry.bounds.max.map(
458
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.scaleVector[i]
459
+ )
460
+ };
461
+ }
462
+ function resizeModel(id, factors) {
463
+ const m = this._model(id);
464
+ modelSize(m);
465
+ if (!Array.isArray(factors) || factors.length !== 3 || factors.some((v) => !Number.isFinite(v) || v <= 0))
466
+ throw new Error("Scale must be a positive finite number");
467
+ const next = axes.map((a, i) => m["scale" + a] * factors[i]);
468
+ if (next.some((v) => !Number.isFinite(v) || v < 1e-12 || v > 1e12))
469
+ throw new Error("Scale is outside the supported range");
470
+ const center = m.geometry.bounds.min.map(
471
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
472
+ );
473
+ const before = transform(modelMatrix(m), center);
474
+ const cached = m._measurements;
475
+ const oldKey = m.scaleVector.join(",");
476
+ this._stopAnimation(m, "manual");
477
+ axes.forEach((a, i) => m["scale" + a] = next[i]);
478
+ const after = transform(modelMatrix(m), center);
479
+ axes.forEach((a, i) => m[a] += before[i] - after[i]);
480
+ m._stats = null;
481
+ if (cached?.key === oldKey && cached.positions === m.geometry.positions && factors.every((v) => v === factors[0])) {
482
+ m._measurements = {
483
+ ...cached,
484
+ key: m.scaleVector.join(","),
485
+ result: {
486
+ ...cached.result,
487
+ area_mm2: cached.result.area_mm2 * factors[0] ** 2,
488
+ volume_mm3: cached.result.volume_mm3 == null ? null : cached.result.volume_mm3 * factors[0] ** 3
489
+ }
490
+ };
491
+ }
492
+ this._invalidate();
493
+ return modelSize(m);
494
+ }
495
+ function setFileUnits(id, units) {
496
+ validateUnits(units);
497
+ const m = this._model(id), state = sizeState(m);
498
+ if (!state.editable)
499
+ throw new Error(
500
+ "This model has explicit file units, already converted to millimetres"
501
+ );
502
+ if (state.file_units === units) return modelSize(m);
503
+ const factor = units === "inch" ? 25.4 : 1 / 25.4;
504
+ const result = resizeModel.call(this, id, [factor, factor, factor]);
505
+ state.baseline_mm = state.baseline_mm.map((v) => v * factor);
506
+ state.file_units = units;
507
+ state.unit_factor = (state.unit_factor ?? 1) * factor;
508
+ return { ...result, ...structuredClone(state) };
509
+ }
510
+ function resetModelSize(id) {
511
+ const size = modelSize(this._model(id));
512
+ return resizeModel.call(
513
+ this,
514
+ id,
515
+ size.dimensions_mm.map((v, i) => v ? size.baseline_mm[i] / v : 1)
516
+ );
517
+ }
518
+ function duplicateModel(id) {
519
+ const source = this._model(id);
520
+ modelSize(source);
521
+ if (source._sceneImportPending)
522
+ throw new Error("Wait for the scene to finish opening");
523
+ if (this._models.size + 1 > this.limits.max_archive_entries || [...this._models.values()].reduce(
524
+ (n, m) => n + (m.geometry?.triangles || 0),
525
+ source.geometry.triangles
526
+ ) > this.limits.max_triangles)
527
+ throw new Error("Scene model or triangle limit exceeded");
528
+ assertMemory(
529
+ this.limits,
530
+ this._occupiedMemory() + geometryMemory(source.geometry),
531
+ "duplicate"
532
+ );
533
+ const copy = {};
534
+ for (const key of [
535
+ "filename",
536
+ "orig_filename",
537
+ "local_file",
538
+ "x",
539
+ "y",
540
+ "z",
541
+ "rotationx",
542
+ "rotationy",
543
+ "rotationz",
544
+ "opacity",
545
+ "units",
546
+ "display",
547
+ "view_edges",
548
+ "smooth_shading",
549
+ "color"
550
+ ])
551
+ copy[key] = source[key];
552
+ copy.id = this.max_model_id + 1;
553
+ if (!Number.isSafeInteger(copy.id))
554
+ throw new Error("Model ID limit exceeded");
555
+ copy.display_name = (source.display_name || source.orig_filename || source.filename || "Model").split(/[\\/]/).pop() + " \u2014 copy";
556
+ copy.center = [...source.center];
557
+ copy.unitScale = 1;
558
+ axes.forEach((a, i) => copy["scale" + a] = source.scaleVector[i]);
559
+ Object.defineProperty(copy, "scaleVector", {
560
+ get: () => axes.map((a) => copy["scale" + a] * copy.unitScale)
561
+ });
562
+ copy.geometry = { ...source.geometry };
563
+ copy.section = source.section ? { ...source.section } : null;
564
+ copy.size_state = structuredClone(sizeState(source));
565
+ copy.size_state.unit_factor = (copy.size_state.unit_factor ?? 1) * source.unitScale;
566
+ const saved = savePlayback(source);
567
+ this.max_model_id = copy.id;
568
+ this._models.set(copy.id, copy);
569
+ this.models_ref[copy.id] = this.models.length;
570
+ this.models.push(copy);
571
+ this.models_count = this._models.size;
572
+ if (saved)
573
+ this._startAnimation(copy, saved.animation, {
574
+ ...saved.animation_state,
575
+ paused: true
576
+ });
577
+ this._invalidate();
578
+ return copy.id;
579
+ }
580
+ var cameraDirections = {
581
+ front: [
582
+ [0, -1, 0],
583
+ [0, 0, 1]
584
+ ],
585
+ back: [
586
+ [0, 1, 0],
587
+ [0, 0, 1]
588
+ ],
589
+ left: [
590
+ [-1, 0, 0],
591
+ [0, 0, 1]
592
+ ],
593
+ right: [
594
+ [1, 0, 0],
595
+ [0, 0, 1]
596
+ ],
597
+ top: [
598
+ [0, 0, 1],
599
+ [0, 1, 0]
600
+ ],
601
+ bottom: [
602
+ [0, 0, -1],
603
+ [0, -1, 0]
604
+ ],
605
+ isometric: [
606
+ [1, -1, 1],
607
+ [0, 0, 1]
608
+ ]
609
+ };
610
+ function cameraView(name) {
611
+ const view = cameraDirections[name];
612
+ if (!view) throw new Error("Unknown camera view");
613
+ this.camera.position = this.camera.target.map((v, i) => v + view[0][i]);
614
+ this.camera.up = [...view[1]];
615
+ this.set_auto_zoom();
616
+ }
617
+ function validateBookmarks(bookmarks) {
618
+ if (!Array.isArray(bookmarks) || bookmarks.length > 32)
619
+ throw new Error("Save up to 32 camera views");
620
+ const names = /* @__PURE__ */ new Set();
621
+ return bookmarks.map((b) => {
622
+ const name = typeof b?.name === "string" ? b.name.trim() : "";
623
+ if (!name || name.length > 80 || /[\x00-\x1f]/.test(name) || names.has(name))
624
+ throw new Error("Use a unique view name of 1\u201380 characters");
625
+ names.add(name);
626
+ const state = {};
627
+ for (const key of ["position", "target", "up"]) {
628
+ if (!b.camera?.[key] || axes.some(
629
+ (a) => !Number.isFinite(b.camera[key][a]) || Math.abs(b.camera[key][a]) > 1e15
630
+ ))
631
+ throw new Error("Invalid saved camera");
632
+ state[key] = Object.fromEntries(axes.map((a) => [a, b.camera[key][a]]));
633
+ }
634
+ const d = axes.map((a) => state.position[a] - state.target[a]), u = axes.map((a) => state.up[a]);
635
+ if (Math.hypot(
636
+ d[1] * u[2] - d[2] * u[1],
637
+ d[2] * u[0] - d[0] * u[2],
638
+ d[0] * u[1] - d[1] * u[0]
639
+ ) < 1e-12)
640
+ throw new Error("Degenerate saved camera");
641
+ return { name, camera: state };
642
+ });
643
+ }
644
+ function mergeBookmarks(existing, incoming) {
645
+ const result = validateBookmarks(existing), names = new Set(result.map((b) => b.name));
646
+ for (const b of validateBookmarks(incoming)) {
647
+ let name = b.name, n = 2;
648
+ while (names.has(name)) name = b.name.slice(0, 70) + ` (${n++})`;
649
+ names.add(name);
650
+ result.push({ ...b, name });
651
+ }
652
+ return validateBookmarks(result);
653
+ }
654
+ async function measureModel(id, options = {}) {
655
+ await this.when_ready;
656
+ const m = this._model(id), g = m.geometry;
657
+ if (!g || m._sceneImportPending)
658
+ throw new Error("Wait for the model to finish opening");
659
+ const scale = m.scaleVector, key = scale.join(","), cached = m._measurements;
660
+ if (options.signal?.aborted)
661
+ throw new DOMException("Measurement cancelled", "AbortError");
662
+ if (cached?.positions === g.positions && cached.key === key)
663
+ return { ...cached.result };
664
+ const working = printMemory(g.triangles), occupied = this._occupiedMemory();
665
+ assertMemory(this.limits, occupied + working, "measurements");
666
+ const release = this._reserveMemory(
667
+ m,
668
+ working + geometryMemory(g),
669
+ "measurements"
670
+ );
671
+ const controller = new AbortController(), cancel = () => controller.abort();
672
+ this._aborters.add(controller);
673
+ (m._measureJobs ??= /* @__PURE__ */ new Set()).add(controller);
674
+ options.signal?.addEventListener("abort", cancel, { once: true });
675
+ let job, callbackError;
676
+ const check = () => {
677
+ if (controller.signal.aborted || this.killsign)
678
+ throw new DOMException("Measurement cancelled", "AbortError");
679
+ if (this._models.get(id) !== m || m.geometry.positions !== g.positions || m.scaleVector.join(",") !== key)
680
+ throw new Error("Model size changed. Calculate again.");
681
+ };
682
+ try {
683
+ const positions = new Float32Array(g.positions.length);
684
+ for (let start = 0; start < positions.length; start += 262144) {
685
+ check();
686
+ positions.set(g.positions.subarray(start, start + 262144), start);
687
+ if (start + 262144 < positions.length)
688
+ await new Promise((r) => setTimeout(r, 0));
689
+ }
690
+ job = this._pool.run(
691
+ {
692
+ kind: "measure",
693
+ positions,
694
+ scale,
695
+ area: g.area,
696
+ max_time_ms: options.max_time_ms ?? 3e4,
697
+ limits: {
698
+ ...this.limits,
699
+ max_memory_bytes: this.limits.max_memory_bytes - occupied
700
+ }
701
+ },
702
+ (progress) => {
703
+ try {
704
+ options.on_progress?.(progress);
705
+ } catch (error) {
706
+ callbackError = error;
707
+ controller.abort();
708
+ }
709
+ },
710
+ [positions.buffer]
711
+ );
712
+ controller.signal.addEventListener("abort", () => job.cancel(), {
713
+ once: true
714
+ });
715
+ check();
716
+ let result;
717
+ try {
718
+ result = await job;
719
+ } catch (error) {
720
+ throw callbackError || error;
721
+ }
722
+ check();
723
+ m._measurements = { positions: g.positions, key, result };
724
+ return { ...result };
725
+ } finally {
726
+ job?.cancel?.();
727
+ release();
728
+ this._aborters.delete(controller);
729
+ m._measureJobs.delete(controller);
730
+ options.signal?.removeEventListener("abort", cancel);
731
+ }
732
+ }
733
+
734
+ // src/appearance.js
735
+ var DISPLAYS = [
736
+ "flat",
737
+ "smooth",
738
+ "wireframe",
739
+ "edges",
740
+ "mesh",
741
+ "xray",
742
+ "studio",
743
+ "orientation"
744
+ ];
745
+ function validateView(display, smooth) {
746
+ if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
747
+ if (typeof smooth !== "boolean")
748
+ throw new Error("smooth_shading must be a boolean");
749
+ return { display, smooth_shading: display === "smooth" || smooth };
750
+ }
751
+ function viewFeatures(view) {
752
+ return {
753
+ smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
754
+ edges: view.view_edges || view.display === "edges",
755
+ wire: view.display === "wireframe",
756
+ edgeAngle: view.display === "edges" ? 30 : 1
757
+ };
234
758
  }
235
- function assertMemory(limits, bytes, operation = "model") {
236
- if (bytes > (limits.max_memory_bytes ?? Number.MAX_SAFE_INTEGER))
237
- throw new Error(
238
- `Estimated memory for this ${operation} exceeds the device safety budget. Use a smaller model or a device with more memory.`
239
- );
759
+ function validateSection(section) {
760
+ if (section == null) return null;
761
+ if (!["x", "y", "z"].includes(section.axis))
762
+ throw new Error("Section axis must be x, y or z");
763
+ if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
764
+ throw new Error("Section position must be between 0 and 1");
765
+ if (section.flip !== void 0 && typeof section.flip !== "boolean")
766
+ throw new Error("Section flip must be a boolean");
767
+ return {
768
+ axis: section.axis,
769
+ position: section.position,
770
+ flip: section.flip ?? false
771
+ };
240
772
  }
241
- function derivedMemory(triangles, features) {
242
- return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
773
+ function sectionPlane(bounds, section) {
774
+ if (!section) return [0, 0, 0, 0];
775
+ const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
776
+ const plane = [0, 0, 0, 0];
777
+ plane[axis] = sign;
778
+ plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
779
+ return plane;
243
780
  }
244
781
 
245
782
  // src/geometry.js
@@ -536,6 +1073,7 @@ var Renderer = class {
536
1073
  this.canvas = canvas;
537
1074
  this.domElement = canvas;
538
1075
  this.resources = /* @__PURE__ */ new Map();
1076
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
539
1077
  this.frames = 0;
540
1078
  this.lost = false;
541
1079
  this.gl = canvas.getContext("webgl2", {
@@ -552,6 +1090,7 @@ var Renderer = class {
552
1090
  e.preventDefault();
553
1091
  this.lost = true;
554
1092
  this.resources.clear();
1093
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
555
1094
  this.gridResource = null;
556
1095
  this.infiniteGrid = null;
557
1096
  this.selectionResource = null;
@@ -659,7 +1198,12 @@ var Renderer = class {
659
1198
  if (this.diagnostic?.model === model) this.setDiagnostic(null);
660
1199
  const resources = this.resources.get(model);
661
1200
  if (resources)
662
- for (const resource of Object.values(resources)) this.free(resource);
1201
+ for (const resource of Object.values(resources)) {
1202
+ if (--resource.users === 0) {
1203
+ this.sharedResources.get(resource.source)?.delete(resource);
1204
+ this.free(resource);
1205
+ }
1206
+ }
663
1207
  this.resources.delete(model);
664
1208
  }
665
1209
  clear() {
@@ -673,8 +1217,10 @@ var Renderer = class {
673
1217
  }
674
1218
  get liveBuffers() {
675
1219
  let n = (this.gridResource?.buffers.length || 0) + (this.selectionResource?.buffers.length || 0) + (this.diagnosticResource?.buffers.length || 0);
1220
+ const unique = /* @__PURE__ */ new Set();
676
1221
  for (const r of this.resources.values())
677
- for (const b of Object.values(r)) n += b.buffers.length;
1222
+ for (const b of Object.values(r)) unique.add(b);
1223
+ for (const b of unique) n += b.buffers.length;
678
1224
  return n;
679
1225
  }
680
1226
  setDiagnostic(value) {
@@ -740,12 +1286,23 @@ var Renderer = class {
740
1286
  this.resources.set(model, r);
741
1287
  }
742
1288
  if (!r[mode]) {
743
- const g = model.geometry;
744
- r[mode] = mode === "edges" || mode === "wire" ? this.upload(g[mode]) : this.upload(
745
- g.positions,
746
- mode === "smooth" ? g.smoothNormals : g.normals,
747
- g.colors
1289
+ 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;
1290
+ let entries = this.sharedResources.get(source);
1291
+ if (!entries) this.sharedResources.set(source, entries = /* @__PURE__ */ new Set());
1292
+ let resource = [...entries].find(
1293
+ (r2) => r2.normals === normals && r2.colors === colors
748
1294
  );
1295
+ if (!resource) {
1296
+ resource = Object.assign(this.upload(source, normals, colors), {
1297
+ source,
1298
+ normals,
1299
+ colors,
1300
+ users: 0
1301
+ });
1302
+ entries.add(resource);
1303
+ }
1304
+ resource.users++;
1305
+ r[mode] = resource;
749
1306
  }
750
1307
  return r[mode];
751
1308
  }
@@ -1080,213 +1637,42 @@ var WorkerPool = class {
1080
1637
  this.pump();
1081
1638
  };
1082
1639
  try {
1083
- job.worker = this.factory ? this.factory() : new Worker(this.url, { type: "module", name: "ViewSTL parser" });
1084
- job.worker.onmessage = ({ data }) => {
1085
- if (data.id !== job.id) return;
1086
- if (data.progress) {
1087
- job.progress?.(data.progress);
1088
- return;
1089
- }
1090
- end(data.error ? new Error(data.error) : null, data.result);
1091
- };
1092
- job.worker.onerror = (e) => {
1093
- e.preventDefault();
1094
- end(
1095
- new Error(
1096
- e.message || "Worker failed; check worker URL and Content Security Policy"
1097
- )
1098
- );
1099
- };
1100
- job.worker.onmessageerror = () => end(new Error("Invalid worker response"));
1101
- job.timer = setTimeout(
1102
- () => end(new Error("File processing timed out")),
1103
- this.timeout
1104
- );
1105
- job.worker.postMessage({ id: job.id, task: job.task }, job.transfer);
1106
- } catch (error) {
1107
- end(error);
1108
- }
1109
- }
1110
- }
1111
- clear() {
1112
- for (const job of [...this.queue, ...this.active]) this.cancel(job);
1113
- }
1114
- dispose() {
1115
- this.disposed = true;
1116
- this.clear();
1117
- }
1118
- };
1119
-
1120
- // src/animation.js
1121
- var animationKeys = [
1122
- "x",
1123
- "y",
1124
- "z",
1125
- "rotationx",
1126
- "rotationy",
1127
- "rotationz",
1128
- "scalex",
1129
- "scaley",
1130
- "scalez"
1131
- ];
1132
- var isScale = (key) => key.startsWith("scale");
1133
- var number = (value, name, positive2 = false) => {
1134
- if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
1135
- throw new Error(`Invalid animation ${name}`);
1136
- return value;
1137
- };
1138
- var object = (value, name) => {
1139
- if (!value || typeof value !== "object" || Array.isArray(value))
1140
- throw new Error(`Invalid animation ${name}`);
1141
- return value;
1142
- };
1143
- function animationPose(model) {
1144
- return Object.fromEntries(
1145
- animationKeys.map((key) => [
1146
- key,
1147
- number(
1148
- model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
1149
- key,
1150
- isScale(key)
1151
- )
1152
- ])
1153
- );
1154
- }
1155
- function normalizeAnimation(input) {
1156
- object(input, "definition");
1157
- const result = {}, claimed = /* @__PURE__ */ new Set();
1158
- for (const mode of ["delta", "exact"]) {
1159
- if (input[mode] == null) continue;
1160
- const source = object(input[mode], mode), values = {};
1161
- const msec = number(source.msec ?? 300, "duration", true);
1162
- if (source.loop !== void 0 && typeof source.loop !== "boolean")
1163
- throw new Error("Animation loop must be a boolean");
1164
- const loop = mode === "delta" && !!source.loop;
1165
- for (const key of animationKeys) {
1166
- const value = source[key] ?? (isScale(key) ? source.scale : void 0);
1167
- if (value === void 0) continue;
1168
- if (claimed.has(key))
1169
- throw new Error(`Animation delta/exact conflict: ${key}`);
1170
- number(value, key, mode === "exact" && isScale(key));
1171
- if (loop && isScale(key) && value < 0)
1172
- throw new Error("Looping scale deltas cannot be negative");
1173
- values[key] = value;
1174
- claimed.add(key);
1175
- }
1176
- if (!Object.keys(values).length)
1177
- throw new Error("Animation track needs a transform");
1178
- result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
1179
- }
1180
- if (!Object.keys(result).length)
1181
- throw new Error("Animation needs delta or exact");
1182
- return result;
1183
- }
1184
- function normalizePlaybackState(input) {
1185
- object(input, "playback state");
1186
- if (input.version !== 1 || typeof input.paused !== "boolean")
1187
- throw new Error("Invalid animation playback state version or paused flag");
1188
- const elapsed_ms = number(input.elapsed_ms, "elapsed time");
1189
- if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
1190
- object(input.origin, "origin");
1191
- for (const key of animationKeys) number(input.origin[key], key, isScale(key));
1192
- if (!Array.isArray(input.center) || input.center.length !== 3)
1193
- throw new Error("Invalid animation pivot");
1194
- return {
1195
- version: 1,
1196
- elapsed_ms,
1197
- paused: input.paused,
1198
- origin: animationPose(input.origin),
1199
- center: input.center.map((v) => number(v, "pivot"))
1200
- };
1201
- }
1202
- function createPlayback(definition, model, saved) {
1203
- const animation = normalizeAnimation(definition);
1204
- const state = saved == null ? null : normalizePlaybackState(saved);
1205
- const origin = state?.origin ?? animationPose(model);
1206
- const tracks = Object.entries(animation).map(([mode, a]) => {
1207
- const values = Object.entries(a).filter(
1208
- ([key]) => animationKeys.includes(key)
1209
- );
1210
- if (mode === "delta")
1211
- for (const [key, value] of values)
1212
- number(origin[key] + value, `${key} endpoint`, isScale(key));
1213
- return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
1214
- });
1215
- const playback = {
1216
- animation,
1217
- origin,
1218
- tracks,
1219
- elapsed_ms: state?.elapsed_ms ?? 0,
1220
- paused: state?.paused ?? false,
1221
- last_time: null
1222
- };
1223
- evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
1224
- return playback;
1225
- }
1226
- function evaluatePlayback(playback, model) {
1227
- const updates = [];
1228
- for (const track of playback.tracks) {
1229
- if (track.done) continue;
1230
- const fraction = playback.elapsed_ms / track.duration;
1231
- const p = track.loop ? fraction : Math.min(fraction, 1);
1232
- for (const [key, value] of track.values) {
1233
- const from = playback.origin[key];
1234
- const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
1235
- updates.push([key, number(to, `${key} result`, isScale(key))]);
1236
- }
1237
- }
1238
- let scaleChanged = false;
1239
- for (const [key, value] of updates) {
1240
- scaleChanged ||= isScale(key) && model[key] !== value;
1241
- model[key] = value;
1242
- }
1243
- for (const track of playback.tracks)
1244
- if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
1245
- if (scaleChanged) model._stats = null;
1246
- return playback.tracks.some((track) => !track.done);
1247
- }
1248
- function advancePlayback(playback, model, time) {
1249
- if (playback.paused) return true;
1250
- if (playback.last_time !== null)
1251
- playback.elapsed_ms = number(
1252
- playback.elapsed_ms + Math.max(0, time - playback.last_time),
1253
- "elapsed time"
1254
- );
1255
- playback.last_time = time;
1256
- return evaluatePlayback(playback, model);
1257
- }
1258
- function playbackState(playback, suspended = false) {
1259
- return {
1260
- status: playback.paused ? "paused" : "running",
1261
- elapsed_ms: playback.elapsed_ms,
1262
- suspended,
1263
- tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
1264
- mode: t.mode,
1265
- msec: t.duration,
1266
- loop: t.loop,
1267
- progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
1268
- }))
1269
- };
1270
- }
1271
- function savePlayback(model) {
1272
- const p = model._animation;
1273
- if (!p) return null;
1274
- const factor = model.unitScale ?? 1;
1275
- const animation = structuredClone(p.animation), origin = { ...p.origin };
1276
- for (const key of animationKeys.filter(isScale)) {
1277
- origin[key] *= factor;
1278
- for (const track of Object.values(animation))
1279
- if (track[key] !== void 0) track[key] *= factor;
1640
+ job.worker = this.factory ? this.factory() : new Worker(this.url, { type: "module", name: "ViewSTL parser" });
1641
+ job.worker.onmessage = ({ data }) => {
1642
+ if (data.id !== job.id) return;
1643
+ if (data.progress) {
1644
+ job.progress?.(data.progress);
1645
+ return;
1646
+ }
1647
+ end(data.error ? new Error(data.error) : null, data.result);
1648
+ };
1649
+ job.worker.onerror = (e) => {
1650
+ e.preventDefault();
1651
+ end(
1652
+ new Error(
1653
+ e.message || "Worker failed; check worker URL and Content Security Policy"
1654
+ )
1655
+ );
1656
+ };
1657
+ job.worker.onmessageerror = () => end(new Error("Invalid worker response"));
1658
+ job.timer = setTimeout(
1659
+ () => end(new Error("File processing timed out")),
1660
+ this.timeout
1661
+ );
1662
+ job.worker.postMessage({ id: job.id, task: job.task }, job.transfer);
1663
+ } catch (error) {
1664
+ end(error);
1665
+ }
1666
+ }
1280
1667
  }
1281
- const animation_state = normalizePlaybackState({
1282
- version: 1,
1283
- origin,
1284
- elapsed_ms: p.elapsed_ms,
1285
- paused: p.paused,
1286
- center: [...model.center]
1287
- });
1288
- return { animation: normalizeAnimation(animation), animation_state };
1289
- }
1668
+ clear() {
1669
+ for (const job of [...this.queue, ...this.active]) this.cancel(job);
1670
+ }
1671
+ dispose() {
1672
+ this.disposed = true;
1673
+ this.clear();
1674
+ }
1675
+ };
1290
1676
 
1291
1677
  // src/scene-import.js
1292
1678
  var sceneFormat = (name) => String(name || "").match(/\.(vsb|vsj)(?:[?#]|$)/i)?.[1].toLowerCase() || "";
@@ -1347,8 +1733,8 @@ function importScene(source, options = {}) {
1347
1733
  await this.when_ready;
1348
1734
  check();
1349
1735
  oldCamera = this.get_camera_state();
1350
- const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1351
- if (!["vsb", "vsj"].includes(format))
1736
+ const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1737
+ if (!["vsb", "vsj"].includes(format2))
1352
1738
  throw new Error("Choose VSB or VSJ for a scene");
1353
1739
  let remote, relay;
1354
1740
  if (typeof source === "string") {
@@ -1381,8 +1767,8 @@ function importScene(source, options = {}) {
1381
1767
  source,
1382
1768
  remote,
1383
1769
  relay,
1384
- format,
1385
- name: `scene.${format}`,
1770
+ format: format2,
1771
+ name: `scene.${format2}`,
1386
1772
  limits
1387
1773
  },
1388
1774
  (p) => {
@@ -1403,6 +1789,10 @@ function importScene(source, options = {}) {
1403
1789
  job = null;
1404
1790
  check();
1405
1791
  const { scene, files, base } = documentData;
1792
+ const bookmarks = mergeBookmarks(
1793
+ this.get_camera_bookmarks(),
1794
+ scene.camera_bookmarks || []
1795
+ );
1406
1796
  this._sceneBytes -= held;
1407
1797
  held = Object.values(files || {}).reduce((n, b) => n + b.byteLength, 0) + JSON.stringify(scene).length * 2;
1408
1798
  this._sceneBytes += held;
@@ -1494,6 +1884,8 @@ function importScene(source, options = {}) {
1494
1884
  if (scene.auto_rotate !== void 0)
1495
1885
  this.set_auto_rotate(scene.auto_rotate);
1496
1886
  this._restoreCamera = scene.camera_state || (hadModels ? oldCamera : null);
1887
+ this.camera_bookmarks = bookmarks;
1888
+ if (scene.display_units) this.display_units = scene.display_units;
1497
1889
  committed = true;
1498
1890
  return { model_ids: ids, scene, used_files: [...usedFiles] };
1499
1891
  })().catch((error) => {
@@ -1514,61 +1906,6 @@ function importScene(source, options = {}) {
1514
1906
  return promise;
1515
1907
  }
1516
1908
 
1517
- // src/print-options.js
1518
- function printOptions(input = {}) {
1519
- const level = input.level ?? "basic";
1520
- if (!["basic", "detailed"].includes(level))
1521
- throw new Error("Choose basic or detailed print checks");
1522
- const number2 = (v, name, max = 1e9) => {
1523
- if (!Number.isFinite(v) || v <= 0 || v > max)
1524
- throw new Error(`Invalid ${name}`);
1525
- return v;
1526
- };
1527
- let profile = null;
1528
- if (input.profile != null) {
1529
- if (input.profile.technology !== "fdm")
1530
- throw new Error("Only FDM profiles are supported");
1531
- profile = { technology: "fdm" };
1532
- for (const key of ["min_wall_mm", "line_width_mm"])
1533
- if (input.profile[key] != null)
1534
- profile[key] = number2(input.profile[key], key);
1535
- if (input.profile.overhang_degrees != null)
1536
- profile.overhang_degrees = number2(
1537
- input.profile.overhang_degrees,
1538
- "overhang angle",
1539
- 89.9
1540
- );
1541
- if (input.profile.build_volume_mm != null) {
1542
- if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
1543
- throw new Error("Build volume requires X, Y and Z");
1544
- profile.build_volume_mm = input.profile.build_volume_mm.map(
1545
- (v) => number2(v, "build volume")
1546
- );
1547
- }
1548
- }
1549
- const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
1550
- return {
1551
- level,
1552
- profile,
1553
- apply_transforms: input.apply_transforms !== false,
1554
- units_confirmed: input.units_confirmed === true,
1555
- max_time_ms: time
1556
- };
1557
- }
1558
- var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
1559
- var printSignature = (model) => JSON.stringify([
1560
- model.x,
1561
- model.y,
1562
- model.z,
1563
- model.rotationx,
1564
- model.rotationy,
1565
- model.rotationz,
1566
- model.scaleVector,
1567
- model.center,
1568
- model.units,
1569
- model.unitScale
1570
- ]);
1571
-
1572
1909
  // src/print-controller.js
1573
1910
  var reports = /* @__PURE__ */ new WeakMap();
1574
1911
  function reportCurrent(viewer, report) {
@@ -1666,7 +2003,9 @@ async function checkPrintability(options = {}) {
1666
2003
  rotationy: 0,
1667
2004
  rotationz: 0,
1668
2005
  center: [0, 0, 0],
1669
- scaleVector: [model.unitScale, model.unitScale, model.unitScale]
2006
+ scaleVector: [1, 1, 1].map(
2007
+ () => model.unitScale * (model.size_state?.unit_factor ?? 1)
2008
+ )
1670
2009
  };
1671
2010
  progress({
1672
2011
  phase: "Starting",
@@ -1790,8 +2129,8 @@ function exportFilename(filename, extension) {
1790
2129
  function exportOptions(options = {}) {
1791
2130
  const aliases = { stp: "step", igs: "iges" };
1792
2131
  const requested = String(options.format || "").toLowerCase();
1793
- const format = aliases[requested] || requested;
1794
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2132
+ const format2 = aliases[requested] || requested;
2133
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
1795
2134
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
1796
2135
  const units = options.units ?? "mm";
1797
2136
  if (!["mm", "inch"].includes(units))
@@ -1801,10 +2140,10 @@ function exportOptions(options = {}) {
1801
2140
  throw new Error(`${key} must be a boolean`);
1802
2141
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
1803
2142
  throw new Error("on_progress must be a function");
1804
- const colors = options.colors !== false && ["obj", "3mf"].includes(format);
1805
- const extension = format === "obj" && colors ? "zip" : format;
2143
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2144
+ const extension = format2 === "obj" && colors ? "zip" : format2;
1806
2145
  return {
1807
- format,
2146
+ format: format2,
1808
2147
  units,
1809
2148
  colors,
1810
2149
  apply_transforms: options.apply_transforms !== false,
@@ -1997,7 +2336,9 @@ var modelKeys = [
1997
2336
  "section",
1998
2337
  "units",
1999
2338
  "animation",
2000
- "animation_state"
2339
+ "animation_state",
2340
+ "size_state",
2341
+ "display_name"
2001
2342
  ];
2002
2343
  var finite = (v, name) => {
2003
2344
  if (typeof v !== "number" || !Number.isFinite(v))
@@ -2029,7 +2370,7 @@ function modelOptions(input) {
2029
2370
  return out;
2030
2371
  }
2031
2372
  var StlViewer = class _StlViewer {
2032
- static version = "2.0.0-rc.5";
2373
+ static version = "2.0.0-rc.7";
2033
2374
  static workerURL = null;
2034
2375
  constructor(parent, options = {}) {
2035
2376
  if (!parent?.appendChild)
@@ -2058,6 +2399,8 @@ var StlViewer = class _StlViewer {
2058
2399
  this._sessionActive = false;
2059
2400
  this._frame = 0;
2060
2401
  this._lastTime = 0;
2402
+ this.camera_bookmarks = [];
2403
+ this.display_units = "mm";
2061
2404
  this.grid = null;
2062
2405
  this._gridAuto = false;
2063
2406
  this.camera = {
@@ -2394,6 +2737,13 @@ var StlViewer = class _StlViewer {
2394
2737
  finite(m[key], key);
2395
2738
  if (key.startsWith("scale")) positive(m[key], key);
2396
2739
  }
2740
+ if (supplied.size_state)
2741
+ m.size_state = validateSizeState(supplied.size_state);
2742
+ if (supplied.display_name != null) {
2743
+ if (typeof supplied.display_name !== "string" || supplied.display_name.length > 1024)
2744
+ throw new Error("Invalid model name");
2745
+ m.display_name = supplied.display_name;
2746
+ }
2397
2747
  m.opacity = supplied.opacity ?? supplied.mesh?.material?.opacity ?? 1;
2398
2748
  finite(m.opacity, "opacity");
2399
2749
  if (m.opacity < 0 || m.opacity > 1)
@@ -2545,7 +2895,7 @@ var StlViewer = class _StlViewer {
2545
2895
  format: supplied.remote_format,
2546
2896
  relay,
2547
2897
  source: typeof source === "string" ? new URL(source, document.baseURI).href : source,
2548
- name: m.filename,
2898
+ name: m.display_name || m.filename,
2549
2899
  limits,
2550
2900
  features: features.smooth || features.edges || features.wire ? features : null
2551
2901
  },
@@ -2595,6 +2945,7 @@ var StlViewer = class _StlViewer {
2595
2945
  }
2596
2946
  _deleteModel(m) {
2597
2947
  this._stopAnimation(m, "removed");
2948
+ for (const controller of m._measureJobs || []) controller.abort();
2598
2949
  for (const [controller, models] of this._printJobs || [])
2599
2950
  if (models.has(m)) controller.abort();
2600
2951
  this._printCache = null;
@@ -2622,6 +2973,8 @@ var StlViewer = class _StlViewer {
2622
2973
  if (this.killsign) return;
2623
2974
  this._generation++;
2624
2975
  this._printCache = null;
2976
+ this.camera_bookmarks = [];
2977
+ this.display_units = "mm";
2625
2978
  this._pool?.clear();
2626
2979
  for (const c of this._aborters) c.abort();
2627
2980
  this._aborters.clear();
@@ -3061,12 +3414,42 @@ var StlViewer = class _StlViewer {
3061
3414
  type
3062
3415
  );
3063
3416
  }
3064
- get_model_info(id) {
3417
+ get_model_size(id) {
3418
+ return modelSize(this._model(id));
3419
+ }
3420
+ resize_model(id, factors) {
3421
+ return resizeModel.call(this, id, factors);
3422
+ }
3423
+ set_file_units(id, units) {
3424
+ return setFileUnits.call(this, id, units);
3425
+ }
3426
+ reset_model_size(id) {
3427
+ return resetModelSize.call(this, id);
3428
+ }
3429
+ duplicate_model(id) {
3430
+ return duplicateModel.call(this, id);
3431
+ }
3432
+ set_camera_view(name) {
3433
+ return cameraView.call(this, name);
3434
+ }
3435
+ set_display_units(units) {
3436
+ this.display_units = validateUnits(units);
3437
+ }
3438
+ get_camera_bookmarks() {
3439
+ return structuredClone(this.camera_bookmarks || []);
3440
+ }
3441
+ set_camera_bookmarks(bookmarks) {
3442
+ this.camera_bookmarks = validateBookmarks(bookmarks);
3443
+ }
3444
+ measure_model(id, options) {
3445
+ return measureModel.call(this, id, options);
3446
+ }
3447
+ get_model_info(id, options = {}) {
3065
3448
  return this._guard(() => {
3066
3449
  const m = this._model(id), g = m.geometry;
3067
3450
  if (!g) return null;
3068
3451
  const s = m.scaleVector, key = s.join(",");
3069
- if (!m._stats || m._stats.key !== key)
3452
+ if (options.measurements !== false && (!m._stats || m._stats.key !== key))
3070
3453
  m._stats = { key, ...scaledStats(g, s) };
3071
3454
  const factor = m.units === "inch" ? 1 / 25.4 : 1;
3072
3455
  return {
@@ -3082,8 +3465,8 @@ var StlViewer = class _StlViewer {
3082
3465
  section: m.section ? { ...m.section } : null,
3083
3466
  color: m.color ?? (g.colors ? "#ffffff" : "#909090"),
3084
3467
  scale: { x: m.scalex, y: m.scaley, z: m.scalez },
3085
- volume: m._stats.volume * factor ** 3,
3086
- area: m._stats.area * factor ** 2,
3468
+ volume: options.measurements === false ? null : m._stats.volume * factor ** 3,
3469
+ area: options.measurements === false ? null : m._stats.area * factor ** 2,
3087
3470
  triangles: g.triangles,
3088
3471
  bounds: { min: [...g.bounds.min], max: [...g.bounds.max] },
3089
3472
  has_colors: !!g.colors,
@@ -3171,7 +3554,9 @@ var StlViewer = class _StlViewer {
3171
3554
  rotationx: 0,
3172
3555
  rotationy: 0,
3173
3556
  rotationz: 0,
3174
- scaleVector: [m.unitScale, m.unitScale, m.unitScale]
3557
+ scaleVector: [1, 1, 1].map(
3558
+ () => m.unitScale * (m.size_state?.unit_factor ?? 1)
3559
+ )
3175
3560
  };
3176
3561
  const matrix = modelMatrix(source), factor = settings.units === "inch" ? 1 / 25.4 : 1;
3177
3562
  for (let c = 0; c < 4; c++)
@@ -3239,8 +3624,8 @@ var StlViewer = class _StlViewer {
3239
3624
  );
3240
3625
  }
3241
3626
  async export_scene(options = {}) {
3242
- const format = String(options.format || "").toLowerCase();
3243
- if (!["vsb", "vsj"].includes(format))
3627
+ const format2 = String(options.format || "").toLowerCase();
3628
+ if (!["vsb", "vsj"].includes(format2))
3244
3629
  throw new Error("Scene format must be vsb or vsj");
3245
3630
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
3246
3631
  throw new Error("force_basename must be a boolean");
@@ -3263,7 +3648,7 @@ var StlViewer = class _StlViewer {
3263
3648
  if (!models.length) throw new Error("No models to export");
3264
3649
  if (models.some((m) => !m.geometry))
3265
3650
  throw new Error("A model has not finished loading");
3266
- if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3651
+ if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3267
3652
  throw new Error("Scene model count limit exceeded");
3268
3653
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3269
3654
  if (triangles > this.limits.max_triangles)
@@ -3271,19 +3656,19 @@ var StlViewer = class _StlViewer {
3271
3656
  const scene = this.get_vsj(
3272
3657
  true,
3273
3658
  !!options.force_basename,
3274
- format === "vsb"
3659
+ format2 === "vsb"
3275
3660
  );
3276
3661
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3277
3662
  if (manifestBytes > this.limits.max_file_bytes)
3278
3663
  throw new Error("Scene manifest size limit exceeded");
3279
- if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3664
+ if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3280
3665
  throw new Error("Scene archive limit exceeded");
3281
- const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3666
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3282
3667
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3283
3668
  this._exportBytes += working;
3284
3669
  let job, cancel, callbackError;
3285
3670
  try {
3286
- const snapshot = format === "vsb" ? models.map((m, index) => ({
3671
+ const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3287
3672
  id: m.id,
3288
3673
  triangles: m.geometry.triangles,
3289
3674
  positions: m.geometry.positions.slice(),
@@ -3298,7 +3683,7 @@ var StlViewer = class _StlViewer {
3298
3683
  });
3299
3684
  checkAbort();
3300
3685
  let blob;
3301
- if (format === "vsj") {
3686
+ if (format2 === "vsj") {
3302
3687
  blob = new Blob([json], { type: "application/json" });
3303
3688
  options.on_progress?.({
3304
3689
  loaded: models.length,
@@ -3338,8 +3723,8 @@ var StlViewer = class _StlViewer {
3338
3723
  checkAbort();
3339
3724
  return {
3340
3725
  blob,
3341
- format,
3342
- filename: exportFilename(options.filename, format),
3726
+ format: format2,
3727
+ filename: exportFilename(options.filename, format2),
3343
3728
  model_ids: models.map((m) => m.id),
3344
3729
  triangles,
3345
3730
  representation: "scene"
@@ -3361,6 +3746,8 @@ var StlViewer = class _StlViewer {
3361
3746
  canvas_height: this.canvas_height,
3362
3747
  bg_color: this.bg_color,
3363
3748
  camera_state: this.get_camera_state(),
3749
+ camera_bookmarks: this.get_camera_bookmarks(),
3750
+ display_units: this.display_units || "mm",
3364
3751
  auto_rotate: this.auto_rotate,
3365
3752
  mouse_zoom: this.mouse_zoom,
3366
3753
  auto_resize: this.auto_resize,
@@ -3381,6 +3768,11 @@ var StlViewer = class _StlViewer {
3381
3768
  id: for_vsb ? -1 : m.id,
3382
3769
  filename: for_vsb ? `${m.id}.stl` : force_basename ? basename(m.filename) : m.filename,
3383
3770
  units: m.units,
3771
+ size_state: {
3772
+ ...structuredClone(sizeState(m)),
3773
+ unit_factor: for_vsb && !m._animation ? 1 : m.unitScale * (sizeState(m).unit_factor ?? 1)
3774
+ },
3775
+ ...m.display_name ? { display_name: m.display_name } : {},
3384
3776
  display: m.display,
3385
3777
  opacity: m.opacity,
3386
3778
  view_edges: m.view_edges,
@@ -3432,7 +3824,7 @@ var StlViewer = class _StlViewer {
3432
3824
  null,
3433
3825
  scene.files.map((f) => f.bin)
3434
3826
  );
3435
- return new Blob([await job], { type: "application/zip" });
3827
+ return new Blob([await job], { type: "application/octet-stream" });
3436
3828
  }
3437
3829
  download_vsb(filename = "1.vsb") {
3438
3830
  return this.get_vsb_blob().then(
@@ -3503,6 +3895,13 @@ var StlViewer = class _StlViewer {
3503
3895
  );
3504
3896
  else if (scene.grid) this.set_grid(true);
3505
3897
  if (scene.camera_state) this._restoreCamera = scene.camera_state;
3898
+ const bookmarks = mergeBookmarks(
3899
+ this.get_camera_bookmarks(),
3900
+ scene.camera_bookmarks || []
3901
+ );
3902
+ const displayUnits = validateUnits(
3903
+ scene.display_units || this.display_units || "mm"
3904
+ );
3506
3905
  const incoming = [];
3507
3906
  try {
3508
3907
  for (const input of scene.models) {
@@ -3550,6 +3949,8 @@ var StlViewer = class _StlViewer {
3550
3949
  this._startAnimation(m, model.animation, model.animation_state);
3551
3950
  m._sceneImportPending = false;
3552
3951
  }
3952
+ this.camera_bookmarks = bookmarks;
3953
+ this.display_units = displayUnits;
3553
3954
  } catch (error) {
3554
3955
  for (const { m } of incoming)
3555
3956
  if (this._models.get(m.id) === m) this.remove_model(m.id);
@@ -3693,6 +4094,299 @@ var StlViewer = class _StlViewer {
3693
4094
  };
3694
4095
  var viewer_default = StlViewer;
3695
4096
 
4097
+ // src/app-tools.js
4098
+ var toolsControls = [
4099
+ "size-details",
4100
+ "size-fields",
4101
+ "size-x",
4102
+ "size-y",
4103
+ "size-z",
4104
+ "size-lock",
4105
+ "size-percent",
4106
+ "size-apply",
4107
+ "size-cancel",
4108
+ "size-reset",
4109
+ "size-error",
4110
+ "file-units",
4111
+ "file-units-note",
4112
+ "display-units",
4113
+ "size-unit-label",
4114
+ "duplicate-model",
4115
+ "camera-preset",
4116
+ "bookmark-name",
4117
+ "bookmark-list",
4118
+ "bookmark-save",
4119
+ "bookmark-restore",
4120
+ "bookmark-rename",
4121
+ "bookmark-delete",
4122
+ "camera-error",
4123
+ "info-area",
4124
+ "info-volume",
4125
+ "measure-model",
4126
+ "measure-cancel",
4127
+ "measure-status"
4128
+ ];
4129
+ var axes2 = ["x", "y", "z"];
4130
+ var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4131
+ function bindAppTools({
4132
+ $,
4133
+ listen,
4134
+ window: window2,
4135
+ getViewer,
4136
+ getState,
4137
+ changeSize,
4138
+ duplicate,
4139
+ refresh,
4140
+ announce
4141
+ }) {
4142
+ let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4143
+ const error = (id, message = "") => {
4144
+ $(id).textContent = message;
4145
+ $(id).hidden = !message;
4146
+ };
4147
+ const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4148
+ const value = (v) => Number(v.toPrecision(10));
4149
+ function syncInfo() {
4150
+ const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4151
+ const s = m?.scaleVector, cached = m?._measurements;
4152
+ const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4153
+ const unit = factor() === 1 ? "mm" : "in";
4154
+ const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4155
+ $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4156
+ $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4157
+ if (!measurement)
4158
+ $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4159
+ $("measure-model").disabled = busy || !m || !!measurement;
4160
+ $("measure-cancel").hidden = !measurement;
4161
+ }
4162
+ function syncBookmarks() {
4163
+ const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4164
+ $("bookmark-list").replaceChildren(
4165
+ ...bookmarks.map((b) => {
4166
+ const option = window2.document.createElement("option");
4167
+ option.value = b.name;
4168
+ option.textContent = b.name;
4169
+ return option;
4170
+ })
4171
+ );
4172
+ if (bookmarks.some((b) => b.name === previous))
4173
+ $("bookmark-list").value = previous;
4174
+ for (const id of [
4175
+ "bookmark-restore",
4176
+ "bookmark-rename",
4177
+ "bookmark-delete",
4178
+ "bookmark-list"
4179
+ ])
4180
+ $(id).disabled = !bookmarks.length;
4181
+ $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4182
+ }
4183
+ function sync() {
4184
+ if (disposed) return;
4185
+ const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4186
+ if (selectedId !== selected) {
4187
+ measurement?.abort();
4188
+ selected = selectedId;
4189
+ }
4190
+ draftKind = null;
4191
+ base = m?.geometry ? v.get_model_size(selectedId) : null;
4192
+ $("size-fields").disabled = busy || !base;
4193
+ $("duplicate-model").disabled = busy || !base;
4194
+ $("display-units").value = v?.display_units || "mm";
4195
+ $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4196
+ $("file-units").value = base?.file_units || "mm";
4197
+ $("file-units").disabled = busy || !base?.editable;
4198
+ $("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.";
4199
+ axes2.forEach((a, i) => {
4200
+ $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4201
+ $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4202
+ });
4203
+ const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4204
+ $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4205
+ error("size-error");
4206
+ syncInfo();
4207
+ syncBookmarks();
4208
+ }
4209
+ const attempt = (id, action) => {
4210
+ try {
4211
+ action();
4212
+ error(id);
4213
+ } catch (e) {
4214
+ error(id, e.message);
4215
+ }
4216
+ };
4217
+ axes2.forEach(
4218
+ (a, i) => listen($("size-" + a), "input", () => {
4219
+ if (!base) return;
4220
+ draftKind = "axis";
4221
+ lastAxis = i;
4222
+ const n = $("size-" + a).valueAsNumber;
4223
+ if (!Number.isFinite(n) || n <= 0) return;
4224
+ if ($("size-lock").checked)
4225
+ axes2.forEach((axis, j) => {
4226
+ if (i !== j)
4227
+ $("size-" + axis).value = value(
4228
+ n * base.dimensions_mm[j] / base.dimensions_mm[i]
4229
+ );
4230
+ });
4231
+ $("size-percent").value = "";
4232
+ })
4233
+ );
4234
+ listen($("size-percent"), "input", () => {
4235
+ draftKind = "percent";
4236
+ const n = $("size-percent").valueAsNumber;
4237
+ if (base && Number.isFinite(n) && n > 0)
4238
+ axes2.forEach(
4239
+ (a, i) => $("size-" + a).value = value(
4240
+ base.baseline_mm[i] * n / 100 / factor()
4241
+ )
4242
+ );
4243
+ });
4244
+ listen(
4245
+ $("size-apply"),
4246
+ "click",
4247
+ () => attempt("size-error", () => {
4248
+ if (!base) return;
4249
+ if (!draftKind) return;
4250
+ const current = getViewer().get_model_size(selected).dimensions_mm;
4251
+ let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4252
+ if (draftKind === "percent") {
4253
+ const percent = $("size-percent").valueAsNumber;
4254
+ if (!Number.isFinite(percent) || percent <= 0)
4255
+ throw new Error("Enter a positive scale percentage.");
4256
+ desired = base.baseline_mm.map((v) => v * percent / 100);
4257
+ } else if ($("size-lock").checked) {
4258
+ const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4259
+ desired = base.dimensions_mm.map((v) => v * ratio);
4260
+ }
4261
+ let ratios = desired.map((n, i) => {
4262
+ if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4263
+ throw new Error("Enter positive dimensions for each non-flat axis.");
4264
+ return current[i] ? n / current[i] : 1;
4265
+ });
4266
+ if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4267
+ ratios = ratios.map(() => ratios[0]);
4268
+ changeSize(() => getViewer().resize_model(selected, ratios));
4269
+ announce("Model size updated.");
4270
+ })
4271
+ );
4272
+ listen($("size-cancel"), "click", sync);
4273
+ listen(
4274
+ $("size-reset"),
4275
+ "click",
4276
+ () => attempt("size-error", () => {
4277
+ changeSize(() => getViewer().reset_model_size(selected));
4278
+ announce("Original size restored; file-unit correction retained.");
4279
+ })
4280
+ );
4281
+ listen(
4282
+ $("file-units"),
4283
+ "change",
4284
+ () => attempt("size-error", () => {
4285
+ changeSize(
4286
+ () => getViewer().set_file_units(selected, $("file-units").value)
4287
+ );
4288
+ announce("File units corrected.");
4289
+ })
4290
+ );
4291
+ listen($("display-units"), "change", () => {
4292
+ getViewer()?.set_display_units($("display-units").value);
4293
+ refresh();
4294
+ });
4295
+ listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4296
+ listen(
4297
+ $("camera-preset"),
4298
+ "change",
4299
+ () => attempt("camera-error", () => {
4300
+ if ($("camera-preset").value)
4301
+ getViewer().set_camera_view($("camera-preset").value);
4302
+ announce("Camera view updated.");
4303
+ $("camera-preset").value = "";
4304
+ })
4305
+ );
4306
+ const editBookmarks = (action) => attempt("camera-error", () => {
4307
+ const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4308
+ action(bookmarks, v);
4309
+ v.set_camera_bookmarks(bookmarks);
4310
+ syncBookmarks();
4311
+ });
4312
+ listen(
4313
+ $("bookmark-save"),
4314
+ "click",
4315
+ () => editBookmarks((b, v) => {
4316
+ b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4317
+ })
4318
+ );
4319
+ listen(
4320
+ $("bookmark-rename"),
4321
+ "click",
4322
+ () => editBookmarks((b) => {
4323
+ b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4324
+ })
4325
+ );
4326
+ listen(
4327
+ $("bookmark-delete"),
4328
+ "click",
4329
+ () => editBookmarks((b) => {
4330
+ b.splice(
4331
+ b.findIndex((b2) => b2.name === $("bookmark-list").value),
4332
+ 1
4333
+ );
4334
+ })
4335
+ );
4336
+ listen(
4337
+ $("bookmark-restore"),
4338
+ "click",
4339
+ () => attempt("camera-error", () => {
4340
+ const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4341
+ if (b) {
4342
+ getViewer().set_camera_state(b.camera);
4343
+ announce("Saved view restored.");
4344
+ }
4345
+ })
4346
+ );
4347
+ listen(
4348
+ $("bookmark-list"),
4349
+ "change",
4350
+ () => $("bookmark-name").value = $("bookmark-list").value
4351
+ );
4352
+ listen($("measure-cancel"), "click", () => measurement?.abort());
4353
+ listen($("measure-model"), "click", async () => {
4354
+ const id = selected, v = getViewer(), controller = new window2.AbortController();
4355
+ measurement = controller;
4356
+ syncInfo();
4357
+ $("measure-status").textContent = "Calculating area and checking volume\u2026";
4358
+ try {
4359
+ await v.measure_model(id, {
4360
+ signal: controller.signal,
4361
+ on_progress: (p) => {
4362
+ if (selected === id && !disposed)
4363
+ $("measure-status").textContent = p.phase + "\u2026";
4364
+ }
4365
+ });
4366
+ if (!disposed && selected === id) announce("Measurements ready.");
4367
+ } catch (e) {
4368
+ if (!disposed && selected === id)
4369
+ $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4370
+ } finally {
4371
+ const message = $("measure-status").textContent;
4372
+ if (measurement === controller) measurement = null;
4373
+ if (!disposed) {
4374
+ syncInfo();
4375
+ if (!v._models.get(id)?._measurements && selected === id)
4376
+ $("measure-status").textContent = message;
4377
+ }
4378
+ }
4379
+ });
4380
+ return {
4381
+ sync,
4382
+ syncInfo,
4383
+ dispose() {
4384
+ disposed = true;
4385
+ measurement?.abort();
4386
+ }
4387
+ };
4388
+ }
4389
+
3696
4390
  // src/memory-policy.js
3697
4391
  var GiB = 1024 ** 3;
3698
4392
  var MiB = 1024 ** 2;
@@ -3724,9 +4418,9 @@ async function estimateFile(file) {
3724
4418
  }
3725
4419
 
3726
4420
  // src/model-transform.js
3727
- var axes = ["x", "y", "z"];
3728
- var vector2 = (p) => axes.map((a) => p[a]);
3729
- var object2 = (p) => Object.fromEntries(axes.map((a, i) => [a, p[i]]));
4421
+ var axes3 = ["x", "y", "z"];
4422
+ var vector2 = (p) => axes3.map((a) => p[a]);
4423
+ var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
3730
4424
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
3731
4425
  var norm2 = (a) => {
3732
4426
  const n = Math.hypot(...a) || 1;
@@ -3758,7 +4452,7 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
3758
4452
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
3759
4453
  function modelCenter(model) {
3760
4454
  return object2(
3761
- centerOf(model.bounds).map((v, i) => v + model.position[axes[i]])
4455
+ centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
3762
4456
  );
3763
4457
  }
3764
4458
  function modelRadius(model) {
@@ -3769,7 +4463,7 @@ function modelRadius(model) {
3769
4463
  }
3770
4464
  function enginePosition(model) {
3771
4465
  const c = centerOf(model.bounds), rotated = transform2(rotationMatrix(model.rotation), c);
3772
- return object2(c.map((v, i) => model.position[axes[i]] + v - rotated[i]));
4466
+ return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
3773
4467
  }
3774
4468
  function modelBounds(model) {
3775
4469
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -4110,16 +4804,16 @@ function bindAppExport({
4110
4804
  $("save-error").textContent = "";
4111
4805
  };
4112
4806
  function formatChanged() {
4113
- const format = $("save-format").value;
4807
+ const format2 = $("save-format").value;
4114
4808
  if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
4115
4809
  suggestedName();
4116
4810
  }
4117
4811
  if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
4118
4812
  if ($("save-scene-note")) $("save-scene-note").hidden = !isScene();
4119
- $("save-colors-row").hidden = !["obj", "3mf"].includes(format);
4120
- $("save-cad-note").hidden = !["step", "iges"].includes(format);
4813
+ $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4814
+ $("save-cad-note").hidden = !["step", "iges"].includes(format2);
4121
4815
  $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
4122
- $("save-submit").textContent = format === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4816
+ $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4123
4817
  sync();
4124
4818
  }
4125
4819
  function suggestedName() {
@@ -4570,7 +5264,7 @@ var animationControls = [
4570
5264
  "animation-status",
4571
5265
  "animation-error"
4572
5266
  ];
4573
- var axes2 = ["x", "y", "z"];
5267
+ var axes4 = ["x", "y", "z"];
4574
5268
  function syncAnimatedRecord(record, model) {
4575
5269
  if (!record || !model?.geometry) return;
4576
5270
  record.bounds = Object.fromEntries(
@@ -4582,11 +5276,11 @@ function syncAnimatedRecord(record, model) {
4582
5276
  ])
4583
5277
  );
4584
5278
  record.rotation = Object.fromEntries(
4585
- axes2.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5279
+ axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
4586
5280
  );
4587
5281
  record.position = { x: 0, y: 0, z: 0 };
4588
5282
  const offset = enginePosition(record);
4589
- for (const a of axes2) record.position[a] = model[a] - offset[a];
5283
+ for (const a of axes4) record.position[a] = model[a] - offset[a];
4590
5284
  }
4591
5285
  function centerAnimationPivot(record, model) {
4592
5286
  syncAnimatedRecord(record, model);
@@ -4599,10 +5293,10 @@ function centerAnimationPivot(record, model) {
4599
5293
  const rotation2 = rotationMatrix(record.rotation);
4600
5294
  const original = record.originalPosition || { x: 0, y: 0, z: 0 };
4601
5295
  record.originalPosition = Object.fromEntries(
4602
- axes2.map((a, i) => [a, original[a] + delta[i]])
5296
+ axes4.map((a, i) => [a, original[a] + delta[i]])
4603
5297
  );
4604
5298
  for (let i = 0; i < 3; i++)
4605
- model[axes2[i]] += delta.reduce(
5299
+ model[axes4[i]] += delta.reduce(
4606
5300
  (sum, v, j) => sum + rotation2[i * 3 + j] * v,
4607
5301
  0
4608
5302
  );
@@ -4611,7 +5305,7 @@ function centerAnimationPivot(record, model) {
4611
5305
  syncAnimatedRecord(record, model);
4612
5306
  }
4613
5307
  function spinDefinition(axis, seconds, direction) {
4614
- if (!axes2.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5308
+ if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
4615
5309
  throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
4616
5310
  return {
4617
5311
  delta: {
@@ -4868,6 +5562,8 @@ function mountViewstlApp(root, options = {}) {
4868
5562
  missing.push(...printControls.filter((id) => !$(id)));
4869
5563
  if (animationControls.some((id) => $(id)))
4870
5564
  missing.push(...animationControls.filter((id) => !$(id)));
5565
+ if (toolsControls.some((id) => $(id)))
5566
+ missing.push(...toolsControls.filter((id) => !$(id)));
4871
5567
  if (missing.length)
4872
5568
  throw new Error("Missing ViewSTL controls: " + missing.join(", "));
4873
5569
  const abort = new window2.AbortController();
@@ -4896,8 +5592,9 @@ function mountViewstlApp(root, options = {}) {
4896
5592
  let exportUI;
4897
5593
  let printUI;
4898
5594
  let animationUI;
5595
+ let toolsUI;
4899
5596
  let sceneImport;
4900
- let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, transformTool = "move", modelMover, dragArrangement, background = "transparent";
5597
+ let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, selectionMode = false, selectionPointer = null, transformTool = "move", modelMover, dragArrangement, background = "transparent";
4901
5598
  const viewNames = {
4902
5599
  flat: "Solid",
4903
5600
  edges: "Solid + edges",
@@ -4908,6 +5605,7 @@ function mountViewstlApp(root, options = {}) {
4908
5605
  orientation: "Face orientation"
4909
5606
  };
4910
5607
  const modeInputs = [...root.querySelectorAll('[name="view-mode"]')];
5608
+ const shortcutButtons = [...root.querySelectorAll("[data-viewstl-shortcut]")];
4911
5609
  if (options.id_prefix)
4912
5610
  modeInputs.forEach(
4913
5611
  (input) => input.name = options.id_prefix + "view-mode"
@@ -4927,7 +5625,44 @@ function mountViewstlApp(root, options = {}) {
4927
5625
  }
4928
5626
  function syncSelection() {
4929
5627
  viewer?.set_selection(
4930
- !busy && records.has(modelID) && (moveMode || !$("scene-panel").hidden) ? modelID : null
5628
+ !busy && records.has(modelID) && (selectionMode || moveMode || !$("scene-panel").hidden) ? modelID : null
5629
+ );
5630
+ }
5631
+ function syncShortcuts() {
5632
+ for (const button of shortcutButtons) {
5633
+ const action = button.dataset.viewstlShortcut;
5634
+ button.disabled = busy || !records.size || action === "save" && !exportUI;
5635
+ if (action === "select")
5636
+ button.setAttribute("aria-pressed", String(selectionMode));
5637
+ }
5638
+ }
5639
+ function setSelectionMode(enabled) {
5640
+ if (enabled) setMoveMode(false);
5641
+ selectionMode = !!enabled && !busy && records.size > 0;
5642
+ selectionPointer = null;
5643
+ if (viewer) {
5644
+ viewer.controls.enabled = !selectionMode;
5645
+ viewer.canvas.setAttribute(
5646
+ "aria-label",
5647
+ selectionMode ? "Select a model with a click. Escape returns to viewing." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5648
+ );
5649
+ }
5650
+ $("workspace").classList.toggle("selecting-model-mode", selectionMode);
5651
+ syncShortcuts();
5652
+ syncSelection();
5653
+ }
5654
+ function editModelAt(event) {
5655
+ const hit = viewer.pick_model(event.clientX, event.clientY);
5656
+ if (!hit || !records.has(hit.id)) return;
5657
+ event.preventDefault();
5658
+ selectModel(hit.id);
5659
+ closePanels();
5660
+ $("scene-panel").hidden = false;
5661
+ $("scene-button").setAttribute("aria-expanded", "true");
5662
+ $("position-details").open = true;
5663
+ syncSelection();
5664
+ announce(
5665
+ records.get(hit.id).name + " selected. Choose Move, Rotate or Depth."
4931
5666
  );
4932
5667
  }
4933
5668
  function closePanel(name) {
@@ -4944,6 +5679,8 @@ function mountViewstlApp(root, options = {}) {
4944
5679
  $(name + "-panel").hidden = !open;
4945
5680
  $(name + "-button").setAttribute("aria-expanded", String(open));
4946
5681
  syncSelection();
5682
+ if (open && name === "info") populateInfo();
5683
+ if (open && (name === "scene" || name === "view")) toolsUI?.sync();
4947
5684
  }
4948
5685
  function fileSize(size) {
4949
5686
  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`;
@@ -4970,12 +5707,13 @@ function mountViewstlApp(root, options = {}) {
4970
5707
  }
4971
5708
  function syncControls() {
4972
5709
  syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
4973
- const info = modelID === null ? null : viewer.get_model_info(modelID), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
5710
+ const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
4974
5711
  for (const input of modeInputs) {
4975
5712
  input.checked = input.value === mode;
4976
5713
  input.disabled = busy;
4977
5714
  }
4978
- $("view-label").textContent = viewNames[mode];
5715
+ $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5716
+ $("view-button").title = "View \xB7 " + viewNames[mode];
4979
5717
  $("smooth-shading").checked = info?.smooth_shading || false;
4980
5718
  $("smooth-shading").disabled = busy || mode === "wireframe";
4981
5719
  $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
@@ -4999,14 +5737,18 @@ function mountViewstlApp(root, options = {}) {
4999
5737
  exportUI?.sync();
5000
5738
  printUI?.sync();
5001
5739
  animationUI?.sync();
5740
+ toolsUI?.sync();
5741
+ syncShortcuts();
5002
5742
  }
5003
5743
  function populateInfo() {
5004
5744
  if (modelID === null) return;
5005
- const info = viewer.get_model_info(modelID), record = records.get(modelID), units = record.units === "mm" ? " mm" : "";
5745
+ const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
5006
5746
  $("info-triangles").textContent = info.triangles.toLocaleString();
5007
- $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x)).join(" \xD7 ") + units;
5747
+ $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
5008
5748
  $("info-size").textContent = fileSize(record.size);
5009
- $("unit-note").textContent = units ? "Dimensions are shown in millimetres." : "Dimensions are in model units. STL and OBJ files do not specify physical units.";
5749
+ const size = viewer.get_model_size(modelID);
5750
+ $("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.";
5751
+ toolsUI?.syncInfo();
5010
5752
  }
5011
5753
  function renderList() {
5012
5754
  $("model-list").replaceChildren(
@@ -5037,6 +5779,7 @@ function mountViewstlApp(root, options = {}) {
5037
5779
  }
5038
5780
  function renderScene() {
5039
5781
  const has = records.size > 0;
5782
+ if (!has && selectionMode) setSelectionMode(false);
5040
5783
  $("workspace").classList.toggle("has-models", has);
5041
5784
  $("viewer").hidden = !has && !busy;
5042
5785
  $("empty-state").hidden = has || busy;
@@ -5055,6 +5798,7 @@ function mountViewstlApp(root, options = {}) {
5055
5798
  function setBusy(value) {
5056
5799
  if (value) exportUI?.close();
5057
5800
  if (disposed) return;
5801
+ if (value && selectionMode) setSelectionMode(false);
5058
5802
  if (value) setMoveMode(false);
5059
5803
  busy = value;
5060
5804
  $("loading-state").hidden = !value;
@@ -5108,7 +5852,7 @@ function mountViewstlApp(root, options = {}) {
5108
5852
  input.removeAttribute("aria-invalid");
5109
5853
  }
5110
5854
  $("position-error").hidden = true;
5111
- $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene." : record?.units === "mm" ? "Offset from file coordinates, in mm." : "Offset from file coordinates, in model units.";
5855
+ $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
5112
5856
  $("move-model-button").disabled = !record || busy;
5113
5857
  $("reset-position").disabled = !record;
5114
5858
  syncRotation(preserveFocus);
@@ -5191,11 +5935,12 @@ function mountViewstlApp(root, options = {}) {
5191
5935
  $("model-tool").value = transformTool;
5192
5936
  $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
5193
5937
  }
5194
- function setMoveMode(enabled, tool = transformTool) {
5938
+ function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
5939
+ if (enabled && selectionMode) setSelectionMode(false);
5195
5940
  modelMover?.setEnabled(false);
5196
5941
  transformTool = tool;
5197
5942
  moveMode = !!enabled && modelID !== null && !busy;
5198
- if (moveMode) prepareManual(modelID);
5943
+ if (moveMode && !retainPaused) prepareManual(modelID);
5199
5944
  modelMover?.setEnabled(moveMode);
5200
5945
  syncTool();
5201
5946
  $("move-mode").hidden = !moveMode;
@@ -5204,7 +5949,7 @@ function mountViewstlApp(root, options = {}) {
5204
5949
  if (viewer?.canvas)
5205
5950
  viewer.canvas.setAttribute(
5206
5951
  "aria-label",
5207
- moveMode ? toolHints[transformTool] + ". Scroll changes only its depth. Escape exits." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5952
+ moveMode ? toolHints[transformTool] + ". Scroll changes only its depth. Escape exits." : selectionMode ? "Select a model with a click. Escape returns to viewing." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5208
5953
  );
5209
5954
  if (moveMode) {
5210
5955
  closePanels();
@@ -5372,6 +6117,7 @@ function mountViewstlApp(root, options = {}) {
5372
6117
  function refreshAnimatedPose() {
5373
6118
  if (disposed || modelID === null) return;
5374
6119
  syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6120
+ if (!$("info-panel").hidden) populateInfo();
5375
6121
  if (!$("scene-panel").hidden && $("position-details").open)
5376
6122
  syncPosition(true);
5377
6123
  }
@@ -5441,20 +6187,39 @@ function mountViewstlApp(root, options = {}) {
5441
6187
  }
5442
6188
  });
5443
6189
  listen(viewer.canvas, "dblclick", (event) => {
5444
- if (busy || event.button !== 0) return;
5445
- const hit = viewer.pick_model(event.clientX, event.clientY);
5446
- if (!hit || !records.has(hit.id)) return;
6190
+ if (busy || selectionMode || event.button !== 0) return;
6191
+ editModelAt(event);
6192
+ });
6193
+ listen(viewer.canvas, "pointerdown", (event) => {
6194
+ if (!selectionMode || busy || event.button !== 0 || !event.isPrimary)
6195
+ return;
6196
+ selectionPointer = {
6197
+ id: event.pointerId,
6198
+ x: event.clientX,
6199
+ y: event.clientY,
6200
+ moved: false
6201
+ };
6202
+ viewer.canvas.focus({ preventScroll: true });
6203
+ viewer.canvas.setPointerCapture(event.pointerId);
5447
6204
  event.preventDefault();
5448
- selectModel(hit.id);
5449
- closePanels();
5450
- $("scene-panel").hidden = false;
5451
- $("scene-button").setAttribute("aria-expanded", "true");
5452
- $("position-details").open = true;
5453
- syncSelection();
5454
- announce(
5455
- records.get(hit.id).name + " selected. Choose Move, Rotate or Depth."
5456
- );
5457
6205
  });
6206
+ listen(viewer.canvas, "pointermove", (event) => {
6207
+ if (selectionPointer?.id === event.pointerId && Math.hypot(
6208
+ event.clientX - selectionPointer.x,
6209
+ event.clientY - selectionPointer.y
6210
+ ) > 6)
6211
+ selectionPointer.moved = true;
6212
+ });
6213
+ listen(viewer.canvas, "pointerup", (event) => {
6214
+ const pointer = selectionPointer;
6215
+ selectionPointer = null;
6216
+ if (selectionMode && !busy && pointer?.id === event.pointerId && !pointer.moved)
6217
+ editModelAt(event);
6218
+ });
6219
+ for (const event of ["pointercancel", "lostpointercapture"])
6220
+ listen(viewer.canvas, event, () => {
6221
+ selectionPointer = null;
6222
+ });
5458
6223
  return viewer;
5459
6224
  }).catch((error) => {
5460
6225
  modelMover?.dispose();
@@ -5496,7 +6261,7 @@ function mountViewstlApp(root, options = {}) {
5496
6261
  if (queue.length && !draining) drainPromise = drainQueue();
5497
6262
  return drainPromise || Promise.resolve();
5498
6263
  }
5499
- function openURL(value, format = "") {
6264
+ function openURL(value, format2 = "") {
5500
6265
  assertActive();
5501
6266
  if (!options.url_import_endpoint)
5502
6267
  throw new Error("URL imports are not configured.");
@@ -5505,7 +6270,7 @@ function mountViewstlApp(root, options = {}) {
5505
6270
  throw new Error(
5506
6271
  "Use a public HTTP or HTTPS file URL without login credentials."
5507
6272
  );
5508
- if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
6273
+ if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
5509
6274
  throw new Error("Choose a supported 3D format.");
5510
6275
  url.hash = "";
5511
6276
  const name = decodeURIComponent(
@@ -5514,9 +6279,9 @@ function mountViewstlApp(root, options = {}) {
5514
6279
  queue.push({
5515
6280
  name,
5516
6281
  remote: url.href,
5517
- format,
6282
+ format: format2,
5518
6283
  size: 0,
5519
- scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6284
+ scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
5520
6285
  });
5521
6286
  if (!draining) drainPromise = drainQueue();
5522
6287
  return drainPromise || Promise.resolve();
@@ -5585,7 +6350,7 @@ function mountViewstlApp(root, options = {}) {
5585
6350
  await engine.when_idle();
5586
6351
  if (token !== generation) continue;
5587
6352
  loadingID = null;
5588
- const loaded = lastLoadedID === id ? engine.get_model_info(id) : null;
6353
+ const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
5589
6354
  if (!loaded) {
5590
6355
  announce(source.name + " could not be opened.");
5591
6356
  continue;
@@ -5677,7 +6442,7 @@ function mountViewstlApp(root, options = {}) {
5677
6442
  );
5678
6443
  const record = {
5679
6444
  id,
5680
- name: String(m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6445
+ name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
5681
6446
  size: g.file_bytes ?? m.local_file?.size ?? null,
5682
6447
  units: g.units || m.units,
5683
6448
  bounds,
@@ -5719,6 +6484,39 @@ function mountViewstlApp(root, options = {}) {
5719
6484
  }
5720
6485
  }
5721
6486
  root.querySelectorAll("[data-open-file]").forEach((b) => listen(b, "click", () => $("file-input").click()));
6487
+ for (const button of shortcutButtons)
6488
+ listen(button, "click", () => {
6489
+ if (button.disabled || busy || !records.size) return;
6490
+ const action = button.dataset.viewstlShortcut;
6491
+ if (action === "save") $("save-button")?.click();
6492
+ else if (action === "select") {
6493
+ setSelectionMode(!selectionMode);
6494
+ closePanels();
6495
+ announce(
6496
+ selectionMode ? "Selection mode. Click a model; Escape returns to viewing." : "Viewing mode. Drag to rotate the scene."
6497
+ );
6498
+ } else if ([
6499
+ "front",
6500
+ "back",
6501
+ "left",
6502
+ "right",
6503
+ "top",
6504
+ "bottom",
6505
+ "isometric"
6506
+ ].includes(action)) {
6507
+ setMoveMode(false);
6508
+ setSelectionMode(false);
6509
+ viewer.set_camera_view(action);
6510
+ toolsUI?.sync();
6511
+ announce(button.title || action + " view");
6512
+ }
6513
+ });
6514
+ listen(window2, "resize", () => {
6515
+ if (selectionMode && !shortcutButtons.some(
6516
+ (button) => button.dataset.viewstlShortcut === "select" && button.getClientRects().length
6517
+ ))
6518
+ setSelectionMode(false);
6519
+ });
5722
6520
  listen($("file-input"), "change", () => {
5723
6521
  const files = [...$("file-input").files];
5724
6522
  $("file-input").value = "";
@@ -5930,6 +6728,12 @@ function mountViewstlApp(root, options = {}) {
5930
6728
  listen(root, "keydown", (event) => {
5931
6729
  if (event.key !== "Escape") return;
5932
6730
  if ($("save-dialog")?.open) return;
6731
+ if (selectionMode) {
6732
+ setSelectionMode(false);
6733
+ closePanels();
6734
+ announce("Viewing mode. Drag to rotate the scene.");
6735
+ return;
6736
+ }
5933
6737
  if (moveMode) {
5934
6738
  setMoveMode(false);
5935
6739
  announce("Move tool closed.");
@@ -6006,6 +6810,44 @@ function mountViewstlApp(root, options = {}) {
6006
6810
  refreshPose: refreshAnimatedPose,
6007
6811
  announce
6008
6812
  });
6813
+ if ($("size-details"))
6814
+ toolsUI = bindAppTools({
6815
+ $,
6816
+ listen,
6817
+ window: window2,
6818
+ getViewer: () => viewer,
6819
+ getState: () => ({ selectedId: modelID, busy }),
6820
+ refresh: renderScene,
6821
+ announce,
6822
+ changeSize(operation) {
6823
+ setMoveMode(false);
6824
+ const record = records.get(modelID);
6825
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6826
+ operation();
6827
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6828
+ $("scene-arrangement").value = "custom";
6829
+ renderScene();
6830
+ },
6831
+ duplicate() {
6832
+ const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6833
+ const record = {
6834
+ ...original,
6835
+ id,
6836
+ name: m.display_name,
6837
+ position: { ...original.position },
6838
+ rotation: { ...original.rotation }
6839
+ };
6840
+ syncAnimatedRecord(record, m);
6841
+ record.originalPosition = { ...record.position };
6842
+ record.originalRotation = { ...record.rotation };
6843
+ records.set(id, record);
6844
+ nextID = Math.max(nextID, id + 1);
6845
+ $("scene-arrangement").value = "custom";
6846
+ selectModel(id);
6847
+ setMoveMode(true, "move", true);
6848
+ announce("Copy selected at the same position. Drag to move it.");
6849
+ }
6850
+ });
6009
6851
  updateGrid();
6010
6852
  setBusy(false);
6011
6853
  setMoveMode(false);
@@ -6054,6 +6896,7 @@ function mountViewstlApp(root, options = {}) {
6054
6896
  exportUI?.dispose();
6055
6897
  printUI?.dispose();
6056
6898
  animationUI?.dispose();
6899
+ toolsUI?.dispose();
6057
6900
  cancelLoading();
6058
6901
  setMoveMode(false);
6059
6902
  appearanceRequest++;