@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.js CHANGED
@@ -1,50 +1,4 @@
1
- // src/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
- }
1
+ //=========== Stl Viewer v2.0.0-rc.7, by Omri Rips, Viewstl.com, November 2026 ; admin@viewstl.com ===========
48
2
 
49
3
  // src/math.js
50
4
  var add = (a, b) => a.map((v, i) => v + b[i]);
@@ -197,22 +151,606 @@ function rayTriangle(o, d, p, i) {
197
151
  const t = dot(c, q) / det;
198
152
  return t >= 0 ? t : Infinity;
199
153
  }
200
-
201
- // src/memory.js
202
- var MEMORY_OVERHEAD = 64 * 1024 * 1024;
203
- var TRIANGLE_MEMORY = 224;
204
- function geometryMemory(g) {
205
- if (!g) return 0;
206
- return g.triangles * TRIANGLE_MEMORY + (g.smoothNormals?.byteLength || 0) * 4 + ((g.edges?.byteLength || 0) + (g.wire?.byteLength || 0)) * 2;
154
+
155
+ // src/animation.js
156
+ var animationKeys = [
157
+ "x",
158
+ "y",
159
+ "z",
160
+ "rotationx",
161
+ "rotationy",
162
+ "rotationz",
163
+ "scalex",
164
+ "scaley",
165
+ "scalez"
166
+ ];
167
+ var isScale = (key) => key.startsWith("scale");
168
+ var number = (value, name, positive2 = false) => {
169
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
170
+ throw new Error(`Invalid animation ${name}`);
171
+ return value;
172
+ };
173
+ var object = (value, name) => {
174
+ if (!value || typeof value !== "object" || Array.isArray(value))
175
+ throw new Error(`Invalid animation ${name}`);
176
+ return value;
177
+ };
178
+ function animationPose(model) {
179
+ return Object.fromEntries(
180
+ animationKeys.map((key) => [
181
+ key,
182
+ number(
183
+ model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
184
+ key,
185
+ isScale(key)
186
+ )
187
+ ])
188
+ );
189
+ }
190
+ function normalizeAnimation(input) {
191
+ object(input, "definition");
192
+ const result = {}, claimed = /* @__PURE__ */ new Set();
193
+ for (const mode of ["delta", "exact"]) {
194
+ if (input[mode] == null) continue;
195
+ const source = object(input[mode], mode), values = {};
196
+ const msec = number(source.msec ?? 300, "duration", true);
197
+ if (source.loop !== void 0 && typeof source.loop !== "boolean")
198
+ throw new Error("Animation loop must be a boolean");
199
+ const loop = mode === "delta" && !!source.loop;
200
+ for (const key of animationKeys) {
201
+ const value = source[key] ?? (isScale(key) ? source.scale : void 0);
202
+ if (value === void 0) continue;
203
+ if (claimed.has(key))
204
+ throw new Error(`Animation delta/exact conflict: ${key}`);
205
+ number(value, key, mode === "exact" && isScale(key));
206
+ if (loop && isScale(key) && value < 0)
207
+ throw new Error("Looping scale deltas cannot be negative");
208
+ values[key] = value;
209
+ claimed.add(key);
210
+ }
211
+ if (!Object.keys(values).length)
212
+ throw new Error("Animation track needs a transform");
213
+ result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
214
+ }
215
+ if (!Object.keys(result).length)
216
+ throw new Error("Animation needs delta or exact");
217
+ return result;
218
+ }
219
+ function normalizePlaybackState(input) {
220
+ object(input, "playback state");
221
+ if (input.version !== 1 || typeof input.paused !== "boolean")
222
+ throw new Error("Invalid animation playback state version or paused flag");
223
+ const elapsed_ms = number(input.elapsed_ms, "elapsed time");
224
+ if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
225
+ object(input.origin, "origin");
226
+ for (const key of animationKeys) number(input.origin[key], key, isScale(key));
227
+ if (!Array.isArray(input.center) || input.center.length !== 3)
228
+ throw new Error("Invalid animation pivot");
229
+ return {
230
+ version: 1,
231
+ elapsed_ms,
232
+ paused: input.paused,
233
+ origin: animationPose(input.origin),
234
+ center: input.center.map((v) => number(v, "pivot"))
235
+ };
236
+ }
237
+ function createPlayback(definition, model, saved) {
238
+ const animation = normalizeAnimation(definition);
239
+ const state = saved == null ? null : normalizePlaybackState(saved);
240
+ const origin = state?.origin ?? animationPose(model);
241
+ const tracks = Object.entries(animation).map(([mode, a]) => {
242
+ const values = Object.entries(a).filter(
243
+ ([key]) => animationKeys.includes(key)
244
+ );
245
+ if (mode === "delta")
246
+ for (const [key, value] of values)
247
+ number(origin[key] + value, `${key} endpoint`, isScale(key));
248
+ return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
249
+ });
250
+ const playback = {
251
+ animation,
252
+ origin,
253
+ tracks,
254
+ elapsed_ms: state?.elapsed_ms ?? 0,
255
+ paused: state?.paused ?? false,
256
+ last_time: null
257
+ };
258
+ evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
259
+ return playback;
260
+ }
261
+ function evaluatePlayback(playback, model) {
262
+ const updates = [];
263
+ for (const track of playback.tracks) {
264
+ if (track.done) continue;
265
+ const fraction = playback.elapsed_ms / track.duration;
266
+ const p = track.loop ? fraction : Math.min(fraction, 1);
267
+ for (const [key, value] of track.values) {
268
+ const from = playback.origin[key];
269
+ const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
270
+ updates.push([key, number(to, `${key} result`, isScale(key))]);
271
+ }
272
+ }
273
+ let scaleChanged = false;
274
+ for (const [key, value] of updates) {
275
+ scaleChanged ||= isScale(key) && model[key] !== value;
276
+ model[key] = value;
277
+ }
278
+ for (const track of playback.tracks)
279
+ if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
280
+ if (scaleChanged) model._stats = null;
281
+ return playback.tracks.some((track) => !track.done);
282
+ }
283
+ function advancePlayback(playback, model, time) {
284
+ if (playback.paused) return true;
285
+ if (playback.last_time !== null)
286
+ playback.elapsed_ms = number(
287
+ playback.elapsed_ms + Math.max(0, time - playback.last_time),
288
+ "elapsed time"
289
+ );
290
+ playback.last_time = time;
291
+ return evaluatePlayback(playback, model);
292
+ }
293
+ function playbackState(playback, suspended = false) {
294
+ return {
295
+ status: playback.paused ? "paused" : "running",
296
+ elapsed_ms: playback.elapsed_ms,
297
+ suspended,
298
+ tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
299
+ mode: t.mode,
300
+ msec: t.duration,
301
+ loop: t.loop,
302
+ progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
303
+ }))
304
+ };
305
+ }
306
+ function savePlayback(model) {
307
+ const p = model._animation;
308
+ if (!p) return null;
309
+ const factor = model.unitScale ?? 1;
310
+ const animation = structuredClone(p.animation), origin = { ...p.origin };
311
+ for (const key of animationKeys.filter(isScale)) {
312
+ origin[key] *= factor;
313
+ for (const track of Object.values(animation))
314
+ if (track[key] !== void 0) track[key] *= factor;
315
+ }
316
+ const animation_state = normalizePlaybackState({
317
+ version: 1,
318
+ origin,
319
+ elapsed_ms: p.elapsed_ms,
320
+ paused: p.paused,
321
+ center: [...model.center]
322
+ });
323
+ return { animation: normalizeAnimation(animation), animation_state };
324
+ }
325
+
326
+ // src/memory.js
327
+ var MEMORY_OVERHEAD = 64 * 1024 * 1024;
328
+ var TRIANGLE_MEMORY = 224;
329
+ function geometryMemory(g) {
330
+ if (!g) return 0;
331
+ return g.triangles * TRIANGLE_MEMORY + (g.smoothNormals?.byteLength || 0) * 4 + ((g.edges?.byteLength || 0) + (g.wire?.byteLength || 0)) * 2;
332
+ }
333
+ function assertMemory(limits, bytes, operation = "model") {
334
+ if (bytes > (limits.max_memory_bytes ?? Number.MAX_SAFE_INTEGER))
335
+ throw new Error(
336
+ `Estimated memory for this ${operation} exceeds the device safety budget. Use a smaller model or a device with more memory.`
337
+ );
338
+ }
339
+ function derivedMemory(triangles, features) {
340
+ return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
341
+ }
342
+
343
+ // src/print-options.js
344
+ function printOptions(input = {}) {
345
+ const level = input.level ?? "basic";
346
+ if (!["basic", "detailed"].includes(level))
347
+ throw new Error("Choose basic or detailed print checks");
348
+ const number2 = (v, name, max = 1e9) => {
349
+ if (!Number.isFinite(v) || v <= 0 || v > max)
350
+ throw new Error(`Invalid ${name}`);
351
+ return v;
352
+ };
353
+ let profile = null;
354
+ if (input.profile != null) {
355
+ if (input.profile.technology !== "fdm")
356
+ throw new Error("Only FDM profiles are supported");
357
+ profile = { technology: "fdm" };
358
+ for (const key of ["min_wall_mm", "line_width_mm"])
359
+ if (input.profile[key] != null)
360
+ profile[key] = number2(input.profile[key], key);
361
+ if (input.profile.overhang_degrees != null)
362
+ profile.overhang_degrees = number2(
363
+ input.profile.overhang_degrees,
364
+ "overhang angle",
365
+ 89.9
366
+ );
367
+ if (input.profile.build_volume_mm != null) {
368
+ if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
369
+ throw new Error("Build volume requires X, Y and Z");
370
+ profile.build_volume_mm = input.profile.build_volume_mm.map(
371
+ (v) => number2(v, "build volume")
372
+ );
373
+ }
374
+ }
375
+ const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
376
+ return {
377
+ level,
378
+ profile,
379
+ apply_transforms: input.apply_transforms !== false,
380
+ units_confirmed: input.units_confirmed === true,
381
+ max_time_ms: time
382
+ };
383
+ }
384
+ var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
385
+ var printSignature = (model) => JSON.stringify([
386
+ model.x,
387
+ model.y,
388
+ model.z,
389
+ model.rotationx,
390
+ model.rotationy,
391
+ model.rotationz,
392
+ model.scaleVector,
393
+ model.center,
394
+ model.units,
395
+ model.unitScale
396
+ ]);
397
+
398
+ // src/model-tools.js
399
+ var axes = ["x", "y", "z"];
400
+ function validateUnits(units) {
401
+ if (!["mm", "inch"].includes(units)) throw new Error("Choose mm or inch");
402
+ return units;
403
+ }
404
+ function validateSizeState(value) {
405
+ 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))
406
+ throw new Error("Invalid saved model size");
407
+ return {
408
+ version: 1,
409
+ baseline_mm: [...value.baseline_mm],
410
+ file_units: validateUnits(value.file_units),
411
+ editable: value.editable,
412
+ unit_factor: value.unit_factor ?? 1
413
+ };
414
+ }
415
+ function sizeState(model) {
416
+ return model.size_state ??= {
417
+ version: 1,
418
+ baseline_mm: model.geometry.bounds.max.map(
419
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.unitScale
420
+ ),
421
+ file_units: "mm",
422
+ editable: !model.geometry.units,
423
+ unit_factor: 1
424
+ };
425
+ }
426
+ function modelSize(model) {
427
+ if (!model.geometry) throw new Error("Wait for the model to finish opening");
428
+ const state = sizeState(model);
429
+ return {
430
+ ...structuredClone(state),
431
+ dimensions_mm: model.geometry.bounds.max.map(
432
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.scaleVector[i]
433
+ )
434
+ };
435
+ }
436
+ function resizeModel(id, factors) {
437
+ const m = this._model(id);
438
+ modelSize(m);
439
+ if (!Array.isArray(factors) || factors.length !== 3 || factors.some((v) => !Number.isFinite(v) || v <= 0))
440
+ throw new Error("Scale must be a positive finite number");
441
+ const next = axes.map((a, i) => m["scale" + a] * factors[i]);
442
+ if (next.some((v) => !Number.isFinite(v) || v < 1e-12 || v > 1e12))
443
+ throw new Error("Scale is outside the supported range");
444
+ const center = m.geometry.bounds.min.map(
445
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
446
+ );
447
+ const before = transform(modelMatrix(m), center);
448
+ const cached = m._measurements;
449
+ const oldKey = m.scaleVector.join(",");
450
+ this._stopAnimation(m, "manual");
451
+ axes.forEach((a, i) => m["scale" + a] = next[i]);
452
+ const after = transform(modelMatrix(m), center);
453
+ axes.forEach((a, i) => m[a] += before[i] - after[i]);
454
+ m._stats = null;
455
+ if (cached?.key === oldKey && cached.positions === m.geometry.positions && factors.every((v) => v === factors[0])) {
456
+ m._measurements = {
457
+ ...cached,
458
+ key: m.scaleVector.join(","),
459
+ result: {
460
+ ...cached.result,
461
+ area_mm2: cached.result.area_mm2 * factors[0] ** 2,
462
+ volume_mm3: cached.result.volume_mm3 == null ? null : cached.result.volume_mm3 * factors[0] ** 3
463
+ }
464
+ };
465
+ }
466
+ this._invalidate();
467
+ return modelSize(m);
468
+ }
469
+ function setFileUnits(id, units) {
470
+ validateUnits(units);
471
+ const m = this._model(id), state = sizeState(m);
472
+ if (!state.editable)
473
+ throw new Error(
474
+ "This model has explicit file units, already converted to millimetres"
475
+ );
476
+ if (state.file_units === units) return modelSize(m);
477
+ const factor = units === "inch" ? 25.4 : 1 / 25.4;
478
+ const result = resizeModel.call(this, id, [factor, factor, factor]);
479
+ state.baseline_mm = state.baseline_mm.map((v) => v * factor);
480
+ state.file_units = units;
481
+ state.unit_factor = (state.unit_factor ?? 1) * factor;
482
+ return { ...result, ...structuredClone(state) };
483
+ }
484
+ function resetModelSize(id) {
485
+ const size = modelSize(this._model(id));
486
+ return resizeModel.call(
487
+ this,
488
+ id,
489
+ size.dimensions_mm.map((v, i) => v ? size.baseline_mm[i] / v : 1)
490
+ );
491
+ }
492
+ function duplicateModel(id) {
493
+ const source = this._model(id);
494
+ modelSize(source);
495
+ if (source._sceneImportPending)
496
+ throw new Error("Wait for the scene to finish opening");
497
+ if (this._models.size + 1 > this.limits.max_archive_entries || [...this._models.values()].reduce(
498
+ (n, m) => n + (m.geometry?.triangles || 0),
499
+ source.geometry.triangles
500
+ ) > this.limits.max_triangles)
501
+ throw new Error("Scene model or triangle limit exceeded");
502
+ assertMemory(
503
+ this.limits,
504
+ this._occupiedMemory() + geometryMemory(source.geometry),
505
+ "duplicate"
506
+ );
507
+ const copy = {};
508
+ for (const key of [
509
+ "filename",
510
+ "orig_filename",
511
+ "local_file",
512
+ "x",
513
+ "y",
514
+ "z",
515
+ "rotationx",
516
+ "rotationy",
517
+ "rotationz",
518
+ "opacity",
519
+ "units",
520
+ "display",
521
+ "view_edges",
522
+ "smooth_shading",
523
+ "color"
524
+ ])
525
+ copy[key] = source[key];
526
+ copy.id = this.max_model_id + 1;
527
+ if (!Number.isSafeInteger(copy.id))
528
+ throw new Error("Model ID limit exceeded");
529
+ copy.display_name = (source.display_name || source.orig_filename || source.filename || "Model").split(/[\\/]/).pop() + " \u2014 copy";
530
+ copy.center = [...source.center];
531
+ copy.unitScale = 1;
532
+ axes.forEach((a, i) => copy["scale" + a] = source.scaleVector[i]);
533
+ Object.defineProperty(copy, "scaleVector", {
534
+ get: () => axes.map((a) => copy["scale" + a] * copy.unitScale)
535
+ });
536
+ copy.geometry = { ...source.geometry };
537
+ copy.section = source.section ? { ...source.section } : null;
538
+ copy.size_state = structuredClone(sizeState(source));
539
+ copy.size_state.unit_factor = (copy.size_state.unit_factor ?? 1) * source.unitScale;
540
+ const saved = savePlayback(source);
541
+ this.max_model_id = copy.id;
542
+ this._models.set(copy.id, copy);
543
+ this.models_ref[copy.id] = this.models.length;
544
+ this.models.push(copy);
545
+ this.models_count = this._models.size;
546
+ if (saved)
547
+ this._startAnimation(copy, saved.animation, {
548
+ ...saved.animation_state,
549
+ paused: true
550
+ });
551
+ this._invalidate();
552
+ return copy.id;
553
+ }
554
+ var cameraDirections = {
555
+ front: [
556
+ [0, -1, 0],
557
+ [0, 0, 1]
558
+ ],
559
+ back: [
560
+ [0, 1, 0],
561
+ [0, 0, 1]
562
+ ],
563
+ left: [
564
+ [-1, 0, 0],
565
+ [0, 0, 1]
566
+ ],
567
+ right: [
568
+ [1, 0, 0],
569
+ [0, 0, 1]
570
+ ],
571
+ top: [
572
+ [0, 0, 1],
573
+ [0, 1, 0]
574
+ ],
575
+ bottom: [
576
+ [0, 0, -1],
577
+ [0, -1, 0]
578
+ ],
579
+ isometric: [
580
+ [1, -1, 1],
581
+ [0, 0, 1]
582
+ ]
583
+ };
584
+ function cameraView(name) {
585
+ const view = cameraDirections[name];
586
+ if (!view) throw new Error("Unknown camera view");
587
+ this.camera.position = this.camera.target.map((v, i) => v + view[0][i]);
588
+ this.camera.up = [...view[1]];
589
+ this.set_auto_zoom();
590
+ }
591
+ function validateBookmarks(bookmarks) {
592
+ if (!Array.isArray(bookmarks) || bookmarks.length > 32)
593
+ throw new Error("Save up to 32 camera views");
594
+ const names = /* @__PURE__ */ new Set();
595
+ return bookmarks.map((b) => {
596
+ const name = typeof b?.name === "string" ? b.name.trim() : "";
597
+ if (!name || name.length > 80 || /[\x00-\x1f]/.test(name) || names.has(name))
598
+ throw new Error("Use a unique view name of 1\u201380 characters");
599
+ names.add(name);
600
+ const state = {};
601
+ for (const key of ["position", "target", "up"]) {
602
+ if (!b.camera?.[key] || axes.some(
603
+ (a) => !Number.isFinite(b.camera[key][a]) || Math.abs(b.camera[key][a]) > 1e15
604
+ ))
605
+ throw new Error("Invalid saved camera");
606
+ state[key] = Object.fromEntries(axes.map((a) => [a, b.camera[key][a]]));
607
+ }
608
+ const d = axes.map((a) => state.position[a] - state.target[a]), u = axes.map((a) => state.up[a]);
609
+ if (Math.hypot(
610
+ d[1] * u[2] - d[2] * u[1],
611
+ d[2] * u[0] - d[0] * u[2],
612
+ d[0] * u[1] - d[1] * u[0]
613
+ ) < 1e-12)
614
+ throw new Error("Degenerate saved camera");
615
+ return { name, camera: state };
616
+ });
617
+ }
618
+ function mergeBookmarks(existing, incoming) {
619
+ const result = validateBookmarks(existing), names = new Set(result.map((b) => b.name));
620
+ for (const b of validateBookmarks(incoming)) {
621
+ let name = b.name, n = 2;
622
+ while (names.has(name)) name = b.name.slice(0, 70) + ` (${n++})`;
623
+ names.add(name);
624
+ result.push({ ...b, name });
625
+ }
626
+ return validateBookmarks(result);
627
+ }
628
+ async function measureModel(id, options = {}) {
629
+ await this.when_ready;
630
+ const m = this._model(id), g = m.geometry;
631
+ if (!g || m._sceneImportPending)
632
+ throw new Error("Wait for the model to finish opening");
633
+ const scale = m.scaleVector, key = scale.join(","), cached = m._measurements;
634
+ if (options.signal?.aborted)
635
+ throw new DOMException("Measurement cancelled", "AbortError");
636
+ if (cached?.positions === g.positions && cached.key === key)
637
+ return { ...cached.result };
638
+ const working = printMemory(g.triangles), occupied = this._occupiedMemory();
639
+ assertMemory(this.limits, occupied + working, "measurements");
640
+ const release = this._reserveMemory(
641
+ m,
642
+ working + geometryMemory(g),
643
+ "measurements"
644
+ );
645
+ const controller = new AbortController(), cancel = () => controller.abort();
646
+ this._aborters.add(controller);
647
+ (m._measureJobs ??= /* @__PURE__ */ new Set()).add(controller);
648
+ options.signal?.addEventListener("abort", cancel, { once: true });
649
+ let job, callbackError;
650
+ const check = () => {
651
+ if (controller.signal.aborted || this.killsign)
652
+ throw new DOMException("Measurement cancelled", "AbortError");
653
+ if (this._models.get(id) !== m || m.geometry.positions !== g.positions || m.scaleVector.join(",") !== key)
654
+ throw new Error("Model size changed. Calculate again.");
655
+ };
656
+ try {
657
+ const positions = new Float32Array(g.positions.length);
658
+ for (let start = 0; start < positions.length; start += 262144) {
659
+ check();
660
+ positions.set(g.positions.subarray(start, start + 262144), start);
661
+ if (start + 262144 < positions.length)
662
+ await new Promise((r) => setTimeout(r, 0));
663
+ }
664
+ job = this._pool.run(
665
+ {
666
+ kind: "measure",
667
+ positions,
668
+ scale,
669
+ area: g.area,
670
+ max_time_ms: options.max_time_ms ?? 3e4,
671
+ limits: {
672
+ ...this.limits,
673
+ max_memory_bytes: this.limits.max_memory_bytes - occupied
674
+ }
675
+ },
676
+ (progress) => {
677
+ try {
678
+ options.on_progress?.(progress);
679
+ } catch (error) {
680
+ callbackError = error;
681
+ controller.abort();
682
+ }
683
+ },
684
+ [positions.buffer]
685
+ );
686
+ controller.signal.addEventListener("abort", () => job.cancel(), {
687
+ once: true
688
+ });
689
+ check();
690
+ let result;
691
+ try {
692
+ result = await job;
693
+ } catch (error) {
694
+ throw callbackError || error;
695
+ }
696
+ check();
697
+ m._measurements = { positions: g.positions, key, result };
698
+ return { ...result };
699
+ } finally {
700
+ job?.cancel?.();
701
+ release();
702
+ this._aborters.delete(controller);
703
+ m._measureJobs.delete(controller);
704
+ options.signal?.removeEventListener("abort", cancel);
705
+ }
706
+ }
707
+
708
+ // src/appearance.js
709
+ var DISPLAYS = [
710
+ "flat",
711
+ "smooth",
712
+ "wireframe",
713
+ "edges",
714
+ "mesh",
715
+ "xray",
716
+ "studio",
717
+ "orientation"
718
+ ];
719
+ function validateView(display, smooth) {
720
+ if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
721
+ if (typeof smooth !== "boolean")
722
+ throw new Error("smooth_shading must be a boolean");
723
+ return { display, smooth_shading: display === "smooth" || smooth };
724
+ }
725
+ function viewFeatures(view) {
726
+ return {
727
+ smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
728
+ edges: view.view_edges || view.display === "edges",
729
+ wire: view.display === "wireframe",
730
+ edgeAngle: view.display === "edges" ? 30 : 1
731
+ };
207
732
  }
208
- function assertMemory(limits, bytes, operation = "model") {
209
- if (bytes > (limits.max_memory_bytes ?? Number.MAX_SAFE_INTEGER))
210
- throw new Error(
211
- `Estimated memory for this ${operation} exceeds the device safety budget. Use a smaller model or a device with more memory.`
212
- );
733
+ function validateSection(section) {
734
+ if (section == null) return null;
735
+ if (!["x", "y", "z"].includes(section.axis))
736
+ throw new Error("Section axis must be x, y or z");
737
+ if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
738
+ throw new Error("Section position must be between 0 and 1");
739
+ if (section.flip !== void 0 && typeof section.flip !== "boolean")
740
+ throw new Error("Section flip must be a boolean");
741
+ return {
742
+ axis: section.axis,
743
+ position: section.position,
744
+ flip: section.flip ?? false
745
+ };
213
746
  }
214
- function derivedMemory(triangles, features) {
215
- return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
747
+ function sectionPlane(bounds, section) {
748
+ if (!section) return [0, 0, 0, 0];
749
+ const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
750
+ const plane = [0, 0, 0, 0];
751
+ plane[axis] = sign;
752
+ plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
753
+ return plane;
216
754
  }
217
755
 
218
756
  // src/geometry.js
@@ -509,6 +1047,7 @@ var Renderer = class {
509
1047
  this.canvas = canvas;
510
1048
  this.domElement = canvas;
511
1049
  this.resources = /* @__PURE__ */ new Map();
1050
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
512
1051
  this.frames = 0;
513
1052
  this.lost = false;
514
1053
  this.gl = canvas.getContext("webgl2", {
@@ -525,6 +1064,7 @@ var Renderer = class {
525
1064
  e.preventDefault();
526
1065
  this.lost = true;
527
1066
  this.resources.clear();
1067
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
528
1068
  this.gridResource = null;
529
1069
  this.infiniteGrid = null;
530
1070
  this.selectionResource = null;
@@ -632,7 +1172,12 @@ var Renderer = class {
632
1172
  if (this.diagnostic?.model === model) this.setDiagnostic(null);
633
1173
  const resources = this.resources.get(model);
634
1174
  if (resources)
635
- for (const resource of Object.values(resources)) this.free(resource);
1175
+ for (const resource of Object.values(resources)) {
1176
+ if (--resource.users === 0) {
1177
+ this.sharedResources.get(resource.source)?.delete(resource);
1178
+ this.free(resource);
1179
+ }
1180
+ }
636
1181
  this.resources.delete(model);
637
1182
  }
638
1183
  clear() {
@@ -646,8 +1191,10 @@ var Renderer = class {
646
1191
  }
647
1192
  get liveBuffers() {
648
1193
  let n = (this.gridResource?.buffers.length || 0) + (this.selectionResource?.buffers.length || 0) + (this.diagnosticResource?.buffers.length || 0);
1194
+ const unique = /* @__PURE__ */ new Set();
649
1195
  for (const r of this.resources.values())
650
- for (const b of Object.values(r)) n += b.buffers.length;
1196
+ for (const b of Object.values(r)) unique.add(b);
1197
+ for (const b of unique) n += b.buffers.length;
651
1198
  return n;
652
1199
  }
653
1200
  setDiagnostic(value) {
@@ -713,12 +1260,23 @@ var Renderer = class {
713
1260
  this.resources.set(model, r);
714
1261
  }
715
1262
  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
1263
+ 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;
1264
+ let entries = this.sharedResources.get(source);
1265
+ if (!entries) this.sharedResources.set(source, entries = /* @__PURE__ */ new Set());
1266
+ let resource = [...entries].find(
1267
+ (r2) => r2.normals === normals && r2.colors === colors
721
1268
  );
1269
+ if (!resource) {
1270
+ resource = Object.assign(this.upload(source, normals, colors), {
1271
+ source,
1272
+ normals,
1273
+ colors,
1274
+ users: 0
1275
+ });
1276
+ entries.add(resource);
1277
+ }
1278
+ resource.users++;
1279
+ r[mode] = resource;
722
1280
  }
723
1281
  return r[mode];
724
1282
  }
@@ -1053,213 +1611,42 @@ var WorkerPool = class {
1053
1611
  this.pump();
1054
1612
  };
1055
1613
  try {
1056
- job.worker = this.factory ? this.factory() : new Worker(this.url, { type: "module", name: "ViewSTL parser" });
1057
- job.worker.onmessage = ({ data }) => {
1058
- if (data.id !== job.id) return;
1059
- if (data.progress) {
1060
- job.progress?.(data.progress);
1061
- return;
1062
- }
1063
- end(data.error ? new Error(data.error) : null, data.result);
1064
- };
1065
- job.worker.onerror = (e) => {
1066
- e.preventDefault();
1067
- end(
1068
- new Error(
1069
- e.message || "Worker failed; check worker URL and Content Security Policy"
1070
- )
1071
- );
1072
- };
1073
- job.worker.onmessageerror = () => end(new Error("Invalid worker response"));
1074
- job.timer = setTimeout(
1075
- () => end(new Error("File processing timed out")),
1076
- this.timeout
1077
- );
1078
- job.worker.postMessage({ id: job.id, task: job.task }, job.transfer);
1079
- } catch (error) {
1080
- end(error);
1081
- }
1082
- }
1083
- }
1084
- clear() {
1085
- for (const job of [...this.queue, ...this.active]) this.cancel(job);
1086
- }
1087
- dispose() {
1088
- this.disposed = true;
1089
- this.clear();
1090
- }
1091
- };
1092
-
1093
- // src/animation.js
1094
- var animationKeys = [
1095
- "x",
1096
- "y",
1097
- "z",
1098
- "rotationx",
1099
- "rotationy",
1100
- "rotationz",
1101
- "scalex",
1102
- "scaley",
1103
- "scalez"
1104
- ];
1105
- var isScale = (key) => key.startsWith("scale");
1106
- var number = (value, name, positive2 = false) => {
1107
- if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
1108
- throw new Error(`Invalid animation ${name}`);
1109
- return value;
1110
- };
1111
- var object = (value, name) => {
1112
- if (!value || typeof value !== "object" || Array.isArray(value))
1113
- throw new Error(`Invalid animation ${name}`);
1114
- return value;
1115
- };
1116
- function animationPose(model) {
1117
- return Object.fromEntries(
1118
- animationKeys.map((key) => [
1119
- key,
1120
- number(
1121
- model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
1122
- key,
1123
- isScale(key)
1124
- )
1125
- ])
1126
- );
1127
- }
1128
- function normalizeAnimation(input) {
1129
- object(input, "definition");
1130
- const result = {}, claimed = /* @__PURE__ */ new Set();
1131
- for (const mode of ["delta", "exact"]) {
1132
- if (input[mode] == null) continue;
1133
- const source = object(input[mode], mode), values = {};
1134
- const msec = number(source.msec ?? 300, "duration", true);
1135
- if (source.loop !== void 0 && typeof source.loop !== "boolean")
1136
- throw new Error("Animation loop must be a boolean");
1137
- const loop = mode === "delta" && !!source.loop;
1138
- for (const key of animationKeys) {
1139
- const value = source[key] ?? (isScale(key) ? source.scale : void 0);
1140
- if (value === void 0) continue;
1141
- if (claimed.has(key))
1142
- throw new Error(`Animation delta/exact conflict: ${key}`);
1143
- number(value, key, mode === "exact" && isScale(key));
1144
- if (loop && isScale(key) && value < 0)
1145
- throw new Error("Looping scale deltas cannot be negative");
1146
- values[key] = value;
1147
- claimed.add(key);
1148
- }
1149
- if (!Object.keys(values).length)
1150
- throw new Error("Animation track needs a transform");
1151
- result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
1152
- }
1153
- if (!Object.keys(result).length)
1154
- throw new Error("Animation needs delta or exact");
1155
- return result;
1156
- }
1157
- function normalizePlaybackState(input) {
1158
- object(input, "playback state");
1159
- if (input.version !== 1 || typeof input.paused !== "boolean")
1160
- throw new Error("Invalid animation playback state version or paused flag");
1161
- const elapsed_ms = number(input.elapsed_ms, "elapsed time");
1162
- if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
1163
- object(input.origin, "origin");
1164
- for (const key of animationKeys) number(input.origin[key], key, isScale(key));
1165
- if (!Array.isArray(input.center) || input.center.length !== 3)
1166
- throw new Error("Invalid animation pivot");
1167
- return {
1168
- version: 1,
1169
- elapsed_ms,
1170
- paused: input.paused,
1171
- origin: animationPose(input.origin),
1172
- center: input.center.map((v) => number(v, "pivot"))
1173
- };
1174
- }
1175
- function createPlayback(definition, model, saved) {
1176
- const animation = normalizeAnimation(definition);
1177
- const state = saved == null ? null : normalizePlaybackState(saved);
1178
- const origin = state?.origin ?? animationPose(model);
1179
- const tracks = Object.entries(animation).map(([mode, a]) => {
1180
- const values = Object.entries(a).filter(
1181
- ([key]) => animationKeys.includes(key)
1182
- );
1183
- if (mode === "delta")
1184
- for (const [key, value] of values)
1185
- number(origin[key] + value, `${key} endpoint`, isScale(key));
1186
- return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
1187
- });
1188
- const playback = {
1189
- animation,
1190
- origin,
1191
- tracks,
1192
- elapsed_ms: state?.elapsed_ms ?? 0,
1193
- paused: state?.paused ?? false,
1194
- last_time: null
1195
- };
1196
- evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
1197
- return playback;
1198
- }
1199
- function evaluatePlayback(playback, model) {
1200
- const updates = [];
1201
- for (const track of playback.tracks) {
1202
- if (track.done) continue;
1203
- const fraction = playback.elapsed_ms / track.duration;
1204
- const p = track.loop ? fraction : Math.min(fraction, 1);
1205
- for (const [key, value] of track.values) {
1206
- const from = playback.origin[key];
1207
- const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
1208
- updates.push([key, number(to, `${key} result`, isScale(key))]);
1209
- }
1210
- }
1211
- let scaleChanged = false;
1212
- for (const [key, value] of updates) {
1213
- scaleChanged ||= isScale(key) && model[key] !== value;
1214
- model[key] = value;
1215
- }
1216
- for (const track of playback.tracks)
1217
- if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
1218
- if (scaleChanged) model._stats = null;
1219
- return playback.tracks.some((track) => !track.done);
1220
- }
1221
- function advancePlayback(playback, model, time) {
1222
- if (playback.paused) return true;
1223
- if (playback.last_time !== null)
1224
- playback.elapsed_ms = number(
1225
- playback.elapsed_ms + Math.max(0, time - playback.last_time),
1226
- "elapsed time"
1227
- );
1228
- playback.last_time = time;
1229
- return evaluatePlayback(playback, model);
1230
- }
1231
- function playbackState(playback, suspended = false) {
1232
- return {
1233
- status: playback.paused ? "paused" : "running",
1234
- elapsed_ms: playback.elapsed_ms,
1235
- suspended,
1236
- tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
1237
- mode: t.mode,
1238
- msec: t.duration,
1239
- loop: t.loop,
1240
- progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
1241
- }))
1242
- };
1243
- }
1244
- function savePlayback(model) {
1245
- const p = model._animation;
1246
- if (!p) return null;
1247
- const factor = model.unitScale ?? 1;
1248
- const animation = structuredClone(p.animation), origin = { ...p.origin };
1249
- for (const key of animationKeys.filter(isScale)) {
1250
- origin[key] *= factor;
1251
- for (const track of Object.values(animation))
1252
- if (track[key] !== void 0) track[key] *= factor;
1614
+ job.worker = this.factory ? this.factory() : new Worker(this.url, { type: "module", name: "ViewSTL parser" });
1615
+ job.worker.onmessage = ({ data }) => {
1616
+ if (data.id !== job.id) return;
1617
+ if (data.progress) {
1618
+ job.progress?.(data.progress);
1619
+ return;
1620
+ }
1621
+ end(data.error ? new Error(data.error) : null, data.result);
1622
+ };
1623
+ job.worker.onerror = (e) => {
1624
+ e.preventDefault();
1625
+ end(
1626
+ new Error(
1627
+ e.message || "Worker failed; check worker URL and Content Security Policy"
1628
+ )
1629
+ );
1630
+ };
1631
+ job.worker.onmessageerror = () => end(new Error("Invalid worker response"));
1632
+ job.timer = setTimeout(
1633
+ () => end(new Error("File processing timed out")),
1634
+ this.timeout
1635
+ );
1636
+ job.worker.postMessage({ id: job.id, task: job.task }, job.transfer);
1637
+ } catch (error) {
1638
+ end(error);
1639
+ }
1640
+ }
1253
1641
  }
1254
- const animation_state = normalizePlaybackState({
1255
- version: 1,
1256
- origin,
1257
- elapsed_ms: p.elapsed_ms,
1258
- paused: p.paused,
1259
- center: [...model.center]
1260
- });
1261
- return { animation: normalizeAnimation(animation), animation_state };
1262
- }
1642
+ clear() {
1643
+ for (const job of [...this.queue, ...this.active]) this.cancel(job);
1644
+ }
1645
+ dispose() {
1646
+ this.disposed = true;
1647
+ this.clear();
1648
+ }
1649
+ };
1263
1650
 
1264
1651
  // src/scene-import.js
1265
1652
  var sceneFormat = (name) => String(name || "").match(/\.(vsb|vsj)(?:[?#]|$)/i)?.[1].toLowerCase() || "";
@@ -1320,8 +1707,8 @@ function importScene(source, options = {}) {
1320
1707
  await this.when_ready;
1321
1708
  check();
1322
1709
  oldCamera = this.get_camera_state();
1323
- const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1324
- if (!["vsb", "vsj"].includes(format))
1710
+ const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1711
+ if (!["vsb", "vsj"].includes(format2))
1325
1712
  throw new Error("Choose VSB or VSJ for a scene");
1326
1713
  let remote, relay;
1327
1714
  if (typeof source === "string") {
@@ -1354,8 +1741,8 @@ function importScene(source, options = {}) {
1354
1741
  source,
1355
1742
  remote,
1356
1743
  relay,
1357
- format,
1358
- name: `scene.${format}`,
1744
+ format: format2,
1745
+ name: `scene.${format2}`,
1359
1746
  limits
1360
1747
  },
1361
1748
  (p) => {
@@ -1376,6 +1763,10 @@ function importScene(source, options = {}) {
1376
1763
  job = null;
1377
1764
  check();
1378
1765
  const { scene, files, base } = documentData;
1766
+ const bookmarks = mergeBookmarks(
1767
+ this.get_camera_bookmarks(),
1768
+ scene.camera_bookmarks || []
1769
+ );
1379
1770
  this._sceneBytes -= held;
1380
1771
  held = Object.values(files || {}).reduce((n, b) => n + b.byteLength, 0) + JSON.stringify(scene).length * 2;
1381
1772
  this._sceneBytes += held;
@@ -1467,6 +1858,8 @@ function importScene(source, options = {}) {
1467
1858
  if (scene.auto_rotate !== void 0)
1468
1859
  this.set_auto_rotate(scene.auto_rotate);
1469
1860
  this._restoreCamera = scene.camera_state || (hadModels ? oldCamera : null);
1861
+ this.camera_bookmarks = bookmarks;
1862
+ if (scene.display_units) this.display_units = scene.display_units;
1470
1863
  committed = true;
1471
1864
  return { model_ids: ids, scene, used_files: [...usedFiles] };
1472
1865
  })().catch((error) => {
@@ -1487,61 +1880,6 @@ function importScene(source, options = {}) {
1487
1880
  return promise;
1488
1881
  }
1489
1882
 
1490
- // src/print-options.js
1491
- function printOptions(input = {}) {
1492
- const level = input.level ?? "basic";
1493
- if (!["basic", "detailed"].includes(level))
1494
- throw new Error("Choose basic or detailed print checks");
1495
- const number2 = (v, name, max = 1e9) => {
1496
- if (!Number.isFinite(v) || v <= 0 || v > max)
1497
- throw new Error(`Invalid ${name}`);
1498
- return v;
1499
- };
1500
- let profile = null;
1501
- if (input.profile != null) {
1502
- if (input.profile.technology !== "fdm")
1503
- throw new Error("Only FDM profiles are supported");
1504
- profile = { technology: "fdm" };
1505
- for (const key of ["min_wall_mm", "line_width_mm"])
1506
- if (input.profile[key] != null)
1507
- profile[key] = number2(input.profile[key], key);
1508
- if (input.profile.overhang_degrees != null)
1509
- profile.overhang_degrees = number2(
1510
- input.profile.overhang_degrees,
1511
- "overhang angle",
1512
- 89.9
1513
- );
1514
- if (input.profile.build_volume_mm != null) {
1515
- if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
1516
- throw new Error("Build volume requires X, Y and Z");
1517
- profile.build_volume_mm = input.profile.build_volume_mm.map(
1518
- (v) => number2(v, "build volume")
1519
- );
1520
- }
1521
- }
1522
- const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
1523
- return {
1524
- level,
1525
- profile,
1526
- apply_transforms: input.apply_transforms !== false,
1527
- units_confirmed: input.units_confirmed === true,
1528
- max_time_ms: time
1529
- };
1530
- }
1531
- var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
1532
- var printSignature = (model) => JSON.stringify([
1533
- model.x,
1534
- model.y,
1535
- model.z,
1536
- model.rotationx,
1537
- model.rotationy,
1538
- model.rotationz,
1539
- model.scaleVector,
1540
- model.center,
1541
- model.units,
1542
- model.unitScale
1543
- ]);
1544
-
1545
1883
  // src/print-controller.js
1546
1884
  var reports = /* @__PURE__ */ new WeakMap();
1547
1885
  function reportCurrent(viewer, report) {
@@ -1639,7 +1977,9 @@ async function checkPrintability(options = {}) {
1639
1977
  rotationy: 0,
1640
1978
  rotationz: 0,
1641
1979
  center: [0, 0, 0],
1642
- scaleVector: [model.unitScale, model.unitScale, model.unitScale]
1980
+ scaleVector: [1, 1, 1].map(
1981
+ () => model.unitScale * (model.size_state?.unit_factor ?? 1)
1982
+ )
1643
1983
  };
1644
1984
  progress({
1645
1985
  phase: "Starting",
@@ -1763,8 +2103,8 @@ function exportFilename(filename, extension) {
1763
2103
  function exportOptions(options = {}) {
1764
2104
  const aliases = { stp: "step", igs: "iges" };
1765
2105
  const requested = String(options.format || "").toLowerCase();
1766
- const format = aliases[requested] || requested;
1767
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2106
+ const format2 = aliases[requested] || requested;
2107
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
1768
2108
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
1769
2109
  const units = options.units ?? "mm";
1770
2110
  if (!["mm", "inch"].includes(units))
@@ -1774,10 +2114,10 @@ function exportOptions(options = {}) {
1774
2114
  throw new Error(`${key} must be a boolean`);
1775
2115
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
1776
2116
  throw new Error("on_progress must be a function");
1777
- const colors = options.colors !== false && ["obj", "3mf"].includes(format);
1778
- const extension = format === "obj" && colors ? "zip" : format;
2117
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2118
+ const extension = format2 === "obj" && colors ? "zip" : format2;
1779
2119
  return {
1780
- format,
2120
+ format: format2,
1781
2121
  units,
1782
2122
  colors,
1783
2123
  apply_transforms: options.apply_transforms !== false,
@@ -1970,7 +2310,9 @@ var modelKeys = [
1970
2310
  "section",
1971
2311
  "units",
1972
2312
  "animation",
1973
- "animation_state"
2313
+ "animation_state",
2314
+ "size_state",
2315
+ "display_name"
1974
2316
  ];
1975
2317
  var finite = (v, name) => {
1976
2318
  if (typeof v !== "number" || !Number.isFinite(v))
@@ -2002,7 +2344,7 @@ function modelOptions(input) {
2002
2344
  return out;
2003
2345
  }
2004
2346
  var StlViewer = class _StlViewer {
2005
- static version = "2.0.0-rc.5";
2347
+ static version = "2.0.0-rc.7";
2006
2348
  static workerURL = null;
2007
2349
  constructor(parent, options = {}) {
2008
2350
  if (!parent?.appendChild)
@@ -2031,6 +2373,8 @@ var StlViewer = class _StlViewer {
2031
2373
  this._sessionActive = false;
2032
2374
  this._frame = 0;
2033
2375
  this._lastTime = 0;
2376
+ this.camera_bookmarks = [];
2377
+ this.display_units = "mm";
2034
2378
  this.grid = null;
2035
2379
  this._gridAuto = false;
2036
2380
  this.camera = {
@@ -2367,6 +2711,13 @@ var StlViewer = class _StlViewer {
2367
2711
  finite(m[key], key);
2368
2712
  if (key.startsWith("scale")) positive(m[key], key);
2369
2713
  }
2714
+ if (supplied.size_state)
2715
+ m.size_state = validateSizeState(supplied.size_state);
2716
+ if (supplied.display_name != null) {
2717
+ if (typeof supplied.display_name !== "string" || supplied.display_name.length > 1024)
2718
+ throw new Error("Invalid model name");
2719
+ m.display_name = supplied.display_name;
2720
+ }
2370
2721
  m.opacity = supplied.opacity ?? supplied.mesh?.material?.opacity ?? 1;
2371
2722
  finite(m.opacity, "opacity");
2372
2723
  if (m.opacity < 0 || m.opacity > 1)
@@ -2518,7 +2869,7 @@ var StlViewer = class _StlViewer {
2518
2869
  format: supplied.remote_format,
2519
2870
  relay,
2520
2871
  source: typeof source === "string" ? new URL(source, document.baseURI).href : source,
2521
- name: m.filename,
2872
+ name: m.display_name || m.filename,
2522
2873
  limits,
2523
2874
  features: features.smooth || features.edges || features.wire ? features : null
2524
2875
  },
@@ -2568,6 +2919,7 @@ var StlViewer = class _StlViewer {
2568
2919
  }
2569
2920
  _deleteModel(m) {
2570
2921
  this._stopAnimation(m, "removed");
2922
+ for (const controller of m._measureJobs || []) controller.abort();
2571
2923
  for (const [controller, models] of this._printJobs || [])
2572
2924
  if (models.has(m)) controller.abort();
2573
2925
  this._printCache = null;
@@ -2595,6 +2947,8 @@ var StlViewer = class _StlViewer {
2595
2947
  if (this.killsign) return;
2596
2948
  this._generation++;
2597
2949
  this._printCache = null;
2950
+ this.camera_bookmarks = [];
2951
+ this.display_units = "mm";
2598
2952
  this._pool?.clear();
2599
2953
  for (const c of this._aborters) c.abort();
2600
2954
  this._aborters.clear();
@@ -3034,12 +3388,42 @@ var StlViewer = class _StlViewer {
3034
3388
  type
3035
3389
  );
3036
3390
  }
3037
- get_model_info(id) {
3391
+ get_model_size(id) {
3392
+ return modelSize(this._model(id));
3393
+ }
3394
+ resize_model(id, factors) {
3395
+ return resizeModel.call(this, id, factors);
3396
+ }
3397
+ set_file_units(id, units) {
3398
+ return setFileUnits.call(this, id, units);
3399
+ }
3400
+ reset_model_size(id) {
3401
+ return resetModelSize.call(this, id);
3402
+ }
3403
+ duplicate_model(id) {
3404
+ return duplicateModel.call(this, id);
3405
+ }
3406
+ set_camera_view(name) {
3407
+ return cameraView.call(this, name);
3408
+ }
3409
+ set_display_units(units) {
3410
+ this.display_units = validateUnits(units);
3411
+ }
3412
+ get_camera_bookmarks() {
3413
+ return structuredClone(this.camera_bookmarks || []);
3414
+ }
3415
+ set_camera_bookmarks(bookmarks) {
3416
+ this.camera_bookmarks = validateBookmarks(bookmarks);
3417
+ }
3418
+ measure_model(id, options) {
3419
+ return measureModel.call(this, id, options);
3420
+ }
3421
+ get_model_info(id, options = {}) {
3038
3422
  return this._guard(() => {
3039
3423
  const m = this._model(id), g = m.geometry;
3040
3424
  if (!g) return null;
3041
3425
  const s = m.scaleVector, key = s.join(",");
3042
- if (!m._stats || m._stats.key !== key)
3426
+ if (options.measurements !== false && (!m._stats || m._stats.key !== key))
3043
3427
  m._stats = { key, ...scaledStats(g, s) };
3044
3428
  const factor = m.units === "inch" ? 1 / 25.4 : 1;
3045
3429
  return {
@@ -3055,8 +3439,8 @@ var StlViewer = class _StlViewer {
3055
3439
  section: m.section ? { ...m.section } : null,
3056
3440
  color: m.color ?? (g.colors ? "#ffffff" : "#909090"),
3057
3441
  scale: { x: m.scalex, y: m.scaley, z: m.scalez },
3058
- volume: m._stats.volume * factor ** 3,
3059
- area: m._stats.area * factor ** 2,
3442
+ volume: options.measurements === false ? null : m._stats.volume * factor ** 3,
3443
+ area: options.measurements === false ? null : m._stats.area * factor ** 2,
3060
3444
  triangles: g.triangles,
3061
3445
  bounds: { min: [...g.bounds.min], max: [...g.bounds.max] },
3062
3446
  has_colors: !!g.colors,
@@ -3144,7 +3528,9 @@ var StlViewer = class _StlViewer {
3144
3528
  rotationx: 0,
3145
3529
  rotationy: 0,
3146
3530
  rotationz: 0,
3147
- scaleVector: [m.unitScale, m.unitScale, m.unitScale]
3531
+ scaleVector: [1, 1, 1].map(
3532
+ () => m.unitScale * (m.size_state?.unit_factor ?? 1)
3533
+ )
3148
3534
  };
3149
3535
  const matrix = modelMatrix(source), factor = settings.units === "inch" ? 1 / 25.4 : 1;
3150
3536
  for (let c = 0; c < 4; c++)
@@ -3212,8 +3598,8 @@ var StlViewer = class _StlViewer {
3212
3598
  );
3213
3599
  }
3214
3600
  async export_scene(options = {}) {
3215
- const format = String(options.format || "").toLowerCase();
3216
- if (!["vsb", "vsj"].includes(format))
3601
+ const format2 = String(options.format || "").toLowerCase();
3602
+ if (!["vsb", "vsj"].includes(format2))
3217
3603
  throw new Error("Scene format must be vsb or vsj");
3218
3604
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
3219
3605
  throw new Error("force_basename must be a boolean");
@@ -3236,7 +3622,7 @@ var StlViewer = class _StlViewer {
3236
3622
  if (!models.length) throw new Error("No models to export");
3237
3623
  if (models.some((m) => !m.geometry))
3238
3624
  throw new Error("A model has not finished loading");
3239
- if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3625
+ if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3240
3626
  throw new Error("Scene model count limit exceeded");
3241
3627
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3242
3628
  if (triangles > this.limits.max_triangles)
@@ -3244,19 +3630,19 @@ var StlViewer = class _StlViewer {
3244
3630
  const scene = this.get_vsj(
3245
3631
  true,
3246
3632
  !!options.force_basename,
3247
- format === "vsb"
3633
+ format2 === "vsb"
3248
3634
  );
3249
3635
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3250
3636
  if (manifestBytes > this.limits.max_file_bytes)
3251
3637
  throw new Error("Scene manifest size limit exceeded");
3252
- if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3638
+ if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3253
3639
  throw new Error("Scene archive limit exceeded");
3254
- const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3640
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3255
3641
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3256
3642
  this._exportBytes += working;
3257
3643
  let job, cancel, callbackError;
3258
3644
  try {
3259
- const snapshot = format === "vsb" ? models.map((m, index) => ({
3645
+ const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3260
3646
  id: m.id,
3261
3647
  triangles: m.geometry.triangles,
3262
3648
  positions: m.geometry.positions.slice(),
@@ -3271,7 +3657,7 @@ var StlViewer = class _StlViewer {
3271
3657
  });
3272
3658
  checkAbort();
3273
3659
  let blob;
3274
- if (format === "vsj") {
3660
+ if (format2 === "vsj") {
3275
3661
  blob = new Blob([json], { type: "application/json" });
3276
3662
  options.on_progress?.({
3277
3663
  loaded: models.length,
@@ -3311,8 +3697,8 @@ var StlViewer = class _StlViewer {
3311
3697
  checkAbort();
3312
3698
  return {
3313
3699
  blob,
3314
- format,
3315
- filename: exportFilename(options.filename, format),
3700
+ format: format2,
3701
+ filename: exportFilename(options.filename, format2),
3316
3702
  model_ids: models.map((m) => m.id),
3317
3703
  triangles,
3318
3704
  representation: "scene"
@@ -3334,6 +3720,8 @@ var StlViewer = class _StlViewer {
3334
3720
  canvas_height: this.canvas_height,
3335
3721
  bg_color: this.bg_color,
3336
3722
  camera_state: this.get_camera_state(),
3723
+ camera_bookmarks: this.get_camera_bookmarks(),
3724
+ display_units: this.display_units || "mm",
3337
3725
  auto_rotate: this.auto_rotate,
3338
3726
  mouse_zoom: this.mouse_zoom,
3339
3727
  auto_resize: this.auto_resize,
@@ -3354,6 +3742,11 @@ var StlViewer = class _StlViewer {
3354
3742
  id: for_vsb ? -1 : m.id,
3355
3743
  filename: for_vsb ? `${m.id}.stl` : force_basename ? basename(m.filename) : m.filename,
3356
3744
  units: m.units,
3745
+ size_state: {
3746
+ ...structuredClone(sizeState(m)),
3747
+ unit_factor: for_vsb && !m._animation ? 1 : m.unitScale * (sizeState(m).unit_factor ?? 1)
3748
+ },
3749
+ ...m.display_name ? { display_name: m.display_name } : {},
3357
3750
  display: m.display,
3358
3751
  opacity: m.opacity,
3359
3752
  view_edges: m.view_edges,
@@ -3405,7 +3798,7 @@ var StlViewer = class _StlViewer {
3405
3798
  null,
3406
3799
  scene.files.map((f) => f.bin)
3407
3800
  );
3408
- return new Blob([await job], { type: "application/zip" });
3801
+ return new Blob([await job], { type: "application/octet-stream" });
3409
3802
  }
3410
3803
  download_vsb(filename = "1.vsb") {
3411
3804
  return this.get_vsb_blob().then(
@@ -3476,6 +3869,13 @@ var StlViewer = class _StlViewer {
3476
3869
  );
3477
3870
  else if (scene.grid) this.set_grid(true);
3478
3871
  if (scene.camera_state) this._restoreCamera = scene.camera_state;
3872
+ const bookmarks = mergeBookmarks(
3873
+ this.get_camera_bookmarks(),
3874
+ scene.camera_bookmarks || []
3875
+ );
3876
+ const displayUnits = validateUnits(
3877
+ scene.display_units || this.display_units || "mm"
3878
+ );
3479
3879
  const incoming = [];
3480
3880
  try {
3481
3881
  for (const input of scene.models) {
@@ -3523,6 +3923,8 @@ var StlViewer = class _StlViewer {
3523
3923
  this._startAnimation(m, model.animation, model.animation_state);
3524
3924
  m._sceneImportPending = false;
3525
3925
  }
3926
+ this.camera_bookmarks = bookmarks;
3927
+ this.display_units = displayUnits;
3526
3928
  } catch (error) {
3527
3929
  for (const { m } of incoming)
3528
3930
  if (this._models.get(m.id) === m) this.remove_model(m.id);
@@ -3665,6 +4067,299 @@ var StlViewer = class _StlViewer {
3665
4067
  }
3666
4068
  };
3667
4069
 
4070
+ // src/app-tools.js
4071
+ var toolsControls = [
4072
+ "size-details",
4073
+ "size-fields",
4074
+ "size-x",
4075
+ "size-y",
4076
+ "size-z",
4077
+ "size-lock",
4078
+ "size-percent",
4079
+ "size-apply",
4080
+ "size-cancel",
4081
+ "size-reset",
4082
+ "size-error",
4083
+ "file-units",
4084
+ "file-units-note",
4085
+ "display-units",
4086
+ "size-unit-label",
4087
+ "duplicate-model",
4088
+ "camera-preset",
4089
+ "bookmark-name",
4090
+ "bookmark-list",
4091
+ "bookmark-save",
4092
+ "bookmark-restore",
4093
+ "bookmark-rename",
4094
+ "bookmark-delete",
4095
+ "camera-error",
4096
+ "info-area",
4097
+ "info-volume",
4098
+ "measure-model",
4099
+ "measure-cancel",
4100
+ "measure-status"
4101
+ ];
4102
+ var axes2 = ["x", "y", "z"];
4103
+ var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4104
+ function bindAppTools({
4105
+ $,
4106
+ listen,
4107
+ window: window2,
4108
+ getViewer,
4109
+ getState,
4110
+ changeSize,
4111
+ duplicate,
4112
+ refresh,
4113
+ announce
4114
+ }) {
4115
+ let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4116
+ const error = (id, message = "") => {
4117
+ $(id).textContent = message;
4118
+ $(id).hidden = !message;
4119
+ };
4120
+ const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4121
+ const value = (v) => Number(v.toPrecision(10));
4122
+ function syncInfo() {
4123
+ const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4124
+ const s = m?.scaleVector, cached = m?._measurements;
4125
+ const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4126
+ const unit = factor() === 1 ? "mm" : "in";
4127
+ const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4128
+ $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4129
+ $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4130
+ if (!measurement)
4131
+ $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4132
+ $("measure-model").disabled = busy || !m || !!measurement;
4133
+ $("measure-cancel").hidden = !measurement;
4134
+ }
4135
+ function syncBookmarks() {
4136
+ const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4137
+ $("bookmark-list").replaceChildren(
4138
+ ...bookmarks.map((b) => {
4139
+ const option = window2.document.createElement("option");
4140
+ option.value = b.name;
4141
+ option.textContent = b.name;
4142
+ return option;
4143
+ })
4144
+ );
4145
+ if (bookmarks.some((b) => b.name === previous))
4146
+ $("bookmark-list").value = previous;
4147
+ for (const id of [
4148
+ "bookmark-restore",
4149
+ "bookmark-rename",
4150
+ "bookmark-delete",
4151
+ "bookmark-list"
4152
+ ])
4153
+ $(id).disabled = !bookmarks.length;
4154
+ $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4155
+ }
4156
+ function sync() {
4157
+ if (disposed) return;
4158
+ const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4159
+ if (selectedId !== selected) {
4160
+ measurement?.abort();
4161
+ selected = selectedId;
4162
+ }
4163
+ draftKind = null;
4164
+ base = m?.geometry ? v.get_model_size(selectedId) : null;
4165
+ $("size-fields").disabled = busy || !base;
4166
+ $("duplicate-model").disabled = busy || !base;
4167
+ $("display-units").value = v?.display_units || "mm";
4168
+ $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4169
+ $("file-units").value = base?.file_units || "mm";
4170
+ $("file-units").disabled = busy || !base?.editable;
4171
+ $("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.";
4172
+ axes2.forEach((a, i) => {
4173
+ $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4174
+ $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4175
+ });
4176
+ const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4177
+ $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4178
+ error("size-error");
4179
+ syncInfo();
4180
+ syncBookmarks();
4181
+ }
4182
+ const attempt = (id, action) => {
4183
+ try {
4184
+ action();
4185
+ error(id);
4186
+ } catch (e) {
4187
+ error(id, e.message);
4188
+ }
4189
+ };
4190
+ axes2.forEach(
4191
+ (a, i) => listen($("size-" + a), "input", () => {
4192
+ if (!base) return;
4193
+ draftKind = "axis";
4194
+ lastAxis = i;
4195
+ const n = $("size-" + a).valueAsNumber;
4196
+ if (!Number.isFinite(n) || n <= 0) return;
4197
+ if ($("size-lock").checked)
4198
+ axes2.forEach((axis, j) => {
4199
+ if (i !== j)
4200
+ $("size-" + axis).value = value(
4201
+ n * base.dimensions_mm[j] / base.dimensions_mm[i]
4202
+ );
4203
+ });
4204
+ $("size-percent").value = "";
4205
+ })
4206
+ );
4207
+ listen($("size-percent"), "input", () => {
4208
+ draftKind = "percent";
4209
+ const n = $("size-percent").valueAsNumber;
4210
+ if (base && Number.isFinite(n) && n > 0)
4211
+ axes2.forEach(
4212
+ (a, i) => $("size-" + a).value = value(
4213
+ base.baseline_mm[i] * n / 100 / factor()
4214
+ )
4215
+ );
4216
+ });
4217
+ listen(
4218
+ $("size-apply"),
4219
+ "click",
4220
+ () => attempt("size-error", () => {
4221
+ if (!base) return;
4222
+ if (!draftKind) return;
4223
+ const current = getViewer().get_model_size(selected).dimensions_mm;
4224
+ let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4225
+ if (draftKind === "percent") {
4226
+ const percent = $("size-percent").valueAsNumber;
4227
+ if (!Number.isFinite(percent) || percent <= 0)
4228
+ throw new Error("Enter a positive scale percentage.");
4229
+ desired = base.baseline_mm.map((v) => v * percent / 100);
4230
+ } else if ($("size-lock").checked) {
4231
+ const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4232
+ desired = base.dimensions_mm.map((v) => v * ratio);
4233
+ }
4234
+ let ratios = desired.map((n, i) => {
4235
+ if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4236
+ throw new Error("Enter positive dimensions for each non-flat axis.");
4237
+ return current[i] ? n / current[i] : 1;
4238
+ });
4239
+ if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4240
+ ratios = ratios.map(() => ratios[0]);
4241
+ changeSize(() => getViewer().resize_model(selected, ratios));
4242
+ announce("Model size updated.");
4243
+ })
4244
+ );
4245
+ listen($("size-cancel"), "click", sync);
4246
+ listen(
4247
+ $("size-reset"),
4248
+ "click",
4249
+ () => attempt("size-error", () => {
4250
+ changeSize(() => getViewer().reset_model_size(selected));
4251
+ announce("Original size restored; file-unit correction retained.");
4252
+ })
4253
+ );
4254
+ listen(
4255
+ $("file-units"),
4256
+ "change",
4257
+ () => attempt("size-error", () => {
4258
+ changeSize(
4259
+ () => getViewer().set_file_units(selected, $("file-units").value)
4260
+ );
4261
+ announce("File units corrected.");
4262
+ })
4263
+ );
4264
+ listen($("display-units"), "change", () => {
4265
+ getViewer()?.set_display_units($("display-units").value);
4266
+ refresh();
4267
+ });
4268
+ listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4269
+ listen(
4270
+ $("camera-preset"),
4271
+ "change",
4272
+ () => attempt("camera-error", () => {
4273
+ if ($("camera-preset").value)
4274
+ getViewer().set_camera_view($("camera-preset").value);
4275
+ announce("Camera view updated.");
4276
+ $("camera-preset").value = "";
4277
+ })
4278
+ );
4279
+ const editBookmarks = (action) => attempt("camera-error", () => {
4280
+ const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4281
+ action(bookmarks, v);
4282
+ v.set_camera_bookmarks(bookmarks);
4283
+ syncBookmarks();
4284
+ });
4285
+ listen(
4286
+ $("bookmark-save"),
4287
+ "click",
4288
+ () => editBookmarks((b, v) => {
4289
+ b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4290
+ })
4291
+ );
4292
+ listen(
4293
+ $("bookmark-rename"),
4294
+ "click",
4295
+ () => editBookmarks((b) => {
4296
+ b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4297
+ })
4298
+ );
4299
+ listen(
4300
+ $("bookmark-delete"),
4301
+ "click",
4302
+ () => editBookmarks((b) => {
4303
+ b.splice(
4304
+ b.findIndex((b2) => b2.name === $("bookmark-list").value),
4305
+ 1
4306
+ );
4307
+ })
4308
+ );
4309
+ listen(
4310
+ $("bookmark-restore"),
4311
+ "click",
4312
+ () => attempt("camera-error", () => {
4313
+ const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4314
+ if (b) {
4315
+ getViewer().set_camera_state(b.camera);
4316
+ announce("Saved view restored.");
4317
+ }
4318
+ })
4319
+ );
4320
+ listen(
4321
+ $("bookmark-list"),
4322
+ "change",
4323
+ () => $("bookmark-name").value = $("bookmark-list").value
4324
+ );
4325
+ listen($("measure-cancel"), "click", () => measurement?.abort());
4326
+ listen($("measure-model"), "click", async () => {
4327
+ const id = selected, v = getViewer(), controller = new window2.AbortController();
4328
+ measurement = controller;
4329
+ syncInfo();
4330
+ $("measure-status").textContent = "Calculating area and checking volume\u2026";
4331
+ try {
4332
+ await v.measure_model(id, {
4333
+ signal: controller.signal,
4334
+ on_progress: (p) => {
4335
+ if (selected === id && !disposed)
4336
+ $("measure-status").textContent = p.phase + "\u2026";
4337
+ }
4338
+ });
4339
+ if (!disposed && selected === id) announce("Measurements ready.");
4340
+ } catch (e) {
4341
+ if (!disposed && selected === id)
4342
+ $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4343
+ } finally {
4344
+ const message = $("measure-status").textContent;
4345
+ if (measurement === controller) measurement = null;
4346
+ if (!disposed) {
4347
+ syncInfo();
4348
+ if (!v._models.get(id)?._measurements && selected === id)
4349
+ $("measure-status").textContent = message;
4350
+ }
4351
+ }
4352
+ });
4353
+ return {
4354
+ sync,
4355
+ syncInfo,
4356
+ dispose() {
4357
+ disposed = true;
4358
+ measurement?.abort();
4359
+ }
4360
+ };
4361
+ }
4362
+
3668
4363
  // src/memory-policy.js
3669
4364
  var GiB = 1024 ** 3;
3670
4365
  var MiB = 1024 ** 2;
@@ -3696,9 +4391,9 @@ async function estimateFile(file) {
3696
4391
  }
3697
4392
 
3698
4393
  // src/model-transform.js
3699
- var axes = ["x", "y", "z"];
3700
- var vector2 = (p) => axes.map((a) => p[a]);
3701
- var object2 = (p) => Object.fromEntries(axes.map((a, i) => [a, p[i]]));
4394
+ var axes3 = ["x", "y", "z"];
4395
+ var vector2 = (p) => axes3.map((a) => p[a]);
4396
+ var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
3702
4397
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
3703
4398
  var norm2 = (a) => {
3704
4399
  const n = Math.hypot(...a) || 1;
@@ -3730,7 +4425,7 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
3730
4425
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
3731
4426
  function modelCenter(model) {
3732
4427
  return object2(
3733
- centerOf(model.bounds).map((v, i) => v + model.position[axes[i]])
4428
+ centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
3734
4429
  );
3735
4430
  }
3736
4431
  function modelRadius(model) {
@@ -3741,7 +4436,7 @@ function modelRadius(model) {
3741
4436
  }
3742
4437
  function enginePosition(model) {
3743
4438
  const c = centerOf(model.bounds), rotated = transform2(rotationMatrix(model.rotation), c);
3744
- return object2(c.map((v, i) => model.position[axes[i]] + v - rotated[i]));
4439
+ return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
3745
4440
  }
3746
4441
  function modelBounds(model) {
3747
4442
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -4082,16 +4777,16 @@ function bindAppExport({
4082
4777
  $("save-error").textContent = "";
4083
4778
  };
4084
4779
  function formatChanged() {
4085
- const format = $("save-format").value;
4780
+ const format2 = $("save-format").value;
4086
4781
  if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
4087
4782
  suggestedName();
4088
4783
  }
4089
4784
  if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
4090
4785
  if ($("save-scene-note")) $("save-scene-note").hidden = !isScene();
4091
- $("save-colors-row").hidden = !["obj", "3mf"].includes(format);
4092
- $("save-cad-note").hidden = !["step", "iges"].includes(format);
4786
+ $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4787
+ $("save-cad-note").hidden = !["step", "iges"].includes(format2);
4093
4788
  $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
4094
- $("save-submit").textContent = format === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4789
+ $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4095
4790
  sync();
4096
4791
  }
4097
4792
  function suggestedName() {
@@ -4542,7 +5237,7 @@ var animationControls = [
4542
5237
  "animation-status",
4543
5238
  "animation-error"
4544
5239
  ];
4545
- var axes2 = ["x", "y", "z"];
5240
+ var axes4 = ["x", "y", "z"];
4546
5241
  function syncAnimatedRecord(record, model) {
4547
5242
  if (!record || !model?.geometry) return;
4548
5243
  record.bounds = Object.fromEntries(
@@ -4554,11 +5249,11 @@ function syncAnimatedRecord(record, model) {
4554
5249
  ])
4555
5250
  );
4556
5251
  record.rotation = Object.fromEntries(
4557
- axes2.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5252
+ axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
4558
5253
  );
4559
5254
  record.position = { x: 0, y: 0, z: 0 };
4560
5255
  const offset = enginePosition(record);
4561
- for (const a of axes2) record.position[a] = model[a] - offset[a];
5256
+ for (const a of axes4) record.position[a] = model[a] - offset[a];
4562
5257
  }
4563
5258
  function centerAnimationPivot(record, model) {
4564
5259
  syncAnimatedRecord(record, model);
@@ -4571,10 +5266,10 @@ function centerAnimationPivot(record, model) {
4571
5266
  const rotation2 = rotationMatrix(record.rotation);
4572
5267
  const original = record.originalPosition || { x: 0, y: 0, z: 0 };
4573
5268
  record.originalPosition = Object.fromEntries(
4574
- axes2.map((a, i) => [a, original[a] + delta[i]])
5269
+ axes4.map((a, i) => [a, original[a] + delta[i]])
4575
5270
  );
4576
5271
  for (let i = 0; i < 3; i++)
4577
- model[axes2[i]] += delta.reduce(
5272
+ model[axes4[i]] += delta.reduce(
4578
5273
  (sum, v, j) => sum + rotation2[i * 3 + j] * v,
4579
5274
  0
4580
5275
  );
@@ -4583,7 +5278,7 @@ function centerAnimationPivot(record, model) {
4583
5278
  syncAnimatedRecord(record, model);
4584
5279
  }
4585
5280
  function spinDefinition(axis, seconds, direction) {
4586
- if (!axes2.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5281
+ if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
4587
5282
  throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
4588
5283
  return {
4589
5284
  delta: {
@@ -4840,6 +5535,8 @@ function mountViewstlApp(root, options = {}) {
4840
5535
  missing.push(...printControls.filter((id) => !$(id)));
4841
5536
  if (animationControls.some((id) => $(id)))
4842
5537
  missing.push(...animationControls.filter((id) => !$(id)));
5538
+ if (toolsControls.some((id) => $(id)))
5539
+ missing.push(...toolsControls.filter((id) => !$(id)));
4843
5540
  if (missing.length)
4844
5541
  throw new Error("Missing ViewSTL controls: " + missing.join(", "));
4845
5542
  const abort = new window2.AbortController();
@@ -4868,8 +5565,9 @@ function mountViewstlApp(root, options = {}) {
4868
5565
  let exportUI;
4869
5566
  let printUI;
4870
5567
  let animationUI;
5568
+ let toolsUI;
4871
5569
  let sceneImport;
4872
- let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, transformTool = "move", modelMover, dragArrangement, background = "transparent";
5570
+ let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, selectionMode = false, selectionPointer = null, transformTool = "move", modelMover, dragArrangement, background = "transparent";
4873
5571
  const viewNames = {
4874
5572
  flat: "Solid",
4875
5573
  edges: "Solid + edges",
@@ -4880,6 +5578,7 @@ function mountViewstlApp(root, options = {}) {
4880
5578
  orientation: "Face orientation"
4881
5579
  };
4882
5580
  const modeInputs = [...root.querySelectorAll('[name="view-mode"]')];
5581
+ const shortcutButtons = [...root.querySelectorAll("[data-viewstl-shortcut]")];
4883
5582
  if (options.id_prefix)
4884
5583
  modeInputs.forEach(
4885
5584
  (input) => input.name = options.id_prefix + "view-mode"
@@ -4899,7 +5598,44 @@ function mountViewstlApp(root, options = {}) {
4899
5598
  }
4900
5599
  function syncSelection() {
4901
5600
  viewer?.set_selection(
4902
- !busy && records.has(modelID) && (moveMode || !$("scene-panel").hidden) ? modelID : null
5601
+ !busy && records.has(modelID) && (selectionMode || moveMode || !$("scene-panel").hidden) ? modelID : null
5602
+ );
5603
+ }
5604
+ function syncShortcuts() {
5605
+ for (const button of shortcutButtons) {
5606
+ const action = button.dataset.viewstlShortcut;
5607
+ button.disabled = busy || !records.size || action === "save" && !exportUI;
5608
+ if (action === "select")
5609
+ button.setAttribute("aria-pressed", String(selectionMode));
5610
+ }
5611
+ }
5612
+ function setSelectionMode(enabled) {
5613
+ if (enabled) setMoveMode(false);
5614
+ selectionMode = !!enabled && !busy && records.size > 0;
5615
+ selectionPointer = null;
5616
+ if (viewer) {
5617
+ viewer.controls.enabled = !selectionMode;
5618
+ viewer.canvas.setAttribute(
5619
+ "aria-label",
5620
+ selectionMode ? "Select a model with a click. Escape returns to viewing." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5621
+ );
5622
+ }
5623
+ $("workspace").classList.toggle("selecting-model-mode", selectionMode);
5624
+ syncShortcuts();
5625
+ syncSelection();
5626
+ }
5627
+ function editModelAt(event) {
5628
+ const hit = viewer.pick_model(event.clientX, event.clientY);
5629
+ if (!hit || !records.has(hit.id)) return;
5630
+ event.preventDefault();
5631
+ selectModel(hit.id);
5632
+ closePanels();
5633
+ $("scene-panel").hidden = false;
5634
+ $("scene-button").setAttribute("aria-expanded", "true");
5635
+ $("position-details").open = true;
5636
+ syncSelection();
5637
+ announce(
5638
+ records.get(hit.id).name + " selected. Choose Move, Rotate or Depth."
4903
5639
  );
4904
5640
  }
4905
5641
  function closePanel(name) {
@@ -4916,6 +5652,8 @@ function mountViewstlApp(root, options = {}) {
4916
5652
  $(name + "-panel").hidden = !open;
4917
5653
  $(name + "-button").setAttribute("aria-expanded", String(open));
4918
5654
  syncSelection();
5655
+ if (open && name === "info") populateInfo();
5656
+ if (open && (name === "scene" || name === "view")) toolsUI?.sync();
4919
5657
  }
4920
5658
  function fileSize(size) {
4921
5659
  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`;
@@ -4942,12 +5680,13 @@ function mountViewstlApp(root, options = {}) {
4942
5680
  }
4943
5681
  function syncControls() {
4944
5682
  syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
4945
- const info = modelID === null ? null : viewer.get_model_info(modelID), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
5683
+ const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
4946
5684
  for (const input of modeInputs) {
4947
5685
  input.checked = input.value === mode;
4948
5686
  input.disabled = busy;
4949
5687
  }
4950
- $("view-label").textContent = viewNames[mode];
5688
+ $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5689
+ $("view-button").title = "View \xB7 " + viewNames[mode];
4951
5690
  $("smooth-shading").checked = info?.smooth_shading || false;
4952
5691
  $("smooth-shading").disabled = busy || mode === "wireframe";
4953
5692
  $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
@@ -4971,14 +5710,18 @@ function mountViewstlApp(root, options = {}) {
4971
5710
  exportUI?.sync();
4972
5711
  printUI?.sync();
4973
5712
  animationUI?.sync();
5713
+ toolsUI?.sync();
5714
+ syncShortcuts();
4974
5715
  }
4975
5716
  function populateInfo() {
4976
5717
  if (modelID === null) return;
4977
- const info = viewer.get_model_info(modelID), record = records.get(modelID), units = record.units === "mm" ? " mm" : "";
5718
+ const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
4978
5719
  $("info-triangles").textContent = info.triangles.toLocaleString();
4979
- $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x)).join(" \xD7 ") + units;
5720
+ $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
4980
5721
  $("info-size").textContent = fileSize(record.size);
4981
- $("unit-note").textContent = units ? "Dimensions are shown in millimetres." : "Dimensions are in model units. STL and OBJ files do not specify physical units.";
5722
+ const size = viewer.get_model_size(modelID);
5723
+ $("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.";
5724
+ toolsUI?.syncInfo();
4982
5725
  }
4983
5726
  function renderList() {
4984
5727
  $("model-list").replaceChildren(
@@ -5009,6 +5752,7 @@ function mountViewstlApp(root, options = {}) {
5009
5752
  }
5010
5753
  function renderScene() {
5011
5754
  const has = records.size > 0;
5755
+ if (!has && selectionMode) setSelectionMode(false);
5012
5756
  $("workspace").classList.toggle("has-models", has);
5013
5757
  $("viewer").hidden = !has && !busy;
5014
5758
  $("empty-state").hidden = has || busy;
@@ -5027,6 +5771,7 @@ function mountViewstlApp(root, options = {}) {
5027
5771
  function setBusy(value) {
5028
5772
  if (value) exportUI?.close();
5029
5773
  if (disposed) return;
5774
+ if (value && selectionMode) setSelectionMode(false);
5030
5775
  if (value) setMoveMode(false);
5031
5776
  busy = value;
5032
5777
  $("loading-state").hidden = !value;
@@ -5080,7 +5825,7 @@ function mountViewstlApp(root, options = {}) {
5080
5825
  input.removeAttribute("aria-invalid");
5081
5826
  }
5082
5827
  $("position-error").hidden = true;
5083
- $("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.";
5828
+ $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
5084
5829
  $("move-model-button").disabled = !record || busy;
5085
5830
  $("reset-position").disabled = !record;
5086
5831
  syncRotation(preserveFocus);
@@ -5163,11 +5908,12 @@ function mountViewstlApp(root, options = {}) {
5163
5908
  $("model-tool").value = transformTool;
5164
5909
  $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
5165
5910
  }
5166
- function setMoveMode(enabled, tool = transformTool) {
5911
+ function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
5912
+ if (enabled && selectionMode) setSelectionMode(false);
5167
5913
  modelMover?.setEnabled(false);
5168
5914
  transformTool = tool;
5169
5915
  moveMode = !!enabled && modelID !== null && !busy;
5170
- if (moveMode) prepareManual(modelID);
5916
+ if (moveMode && !retainPaused) prepareManual(modelID);
5171
5917
  modelMover?.setEnabled(moveMode);
5172
5918
  syncTool();
5173
5919
  $("move-mode").hidden = !moveMode;
@@ -5176,7 +5922,7 @@ function mountViewstlApp(root, options = {}) {
5176
5922
  if (viewer?.canvas)
5177
5923
  viewer.canvas.setAttribute(
5178
5924
  "aria-label",
5179
- moveMode ? toolHints[transformTool] + ". Scroll changes only its depth. Escape exits." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5925
+ 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."
5180
5926
  );
5181
5927
  if (moveMode) {
5182
5928
  closePanels();
@@ -5344,6 +6090,7 @@ function mountViewstlApp(root, options = {}) {
5344
6090
  function refreshAnimatedPose() {
5345
6091
  if (disposed || modelID === null) return;
5346
6092
  syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6093
+ if (!$("info-panel").hidden) populateInfo();
5347
6094
  if (!$("scene-panel").hidden && $("position-details").open)
5348
6095
  syncPosition(true);
5349
6096
  }
@@ -5413,20 +6160,39 @@ function mountViewstlApp(root, options = {}) {
5413
6160
  }
5414
6161
  });
5415
6162
  listen(viewer.canvas, "dblclick", (event) => {
5416
- if (busy || event.button !== 0) return;
5417
- const hit = viewer.pick_model(event.clientX, event.clientY);
5418
- if (!hit || !records.has(hit.id)) return;
6163
+ if (busy || selectionMode || event.button !== 0) return;
6164
+ editModelAt(event);
6165
+ });
6166
+ listen(viewer.canvas, "pointerdown", (event) => {
6167
+ if (!selectionMode || busy || event.button !== 0 || !event.isPrimary)
6168
+ return;
6169
+ selectionPointer = {
6170
+ id: event.pointerId,
6171
+ x: event.clientX,
6172
+ y: event.clientY,
6173
+ moved: false
6174
+ };
6175
+ viewer.canvas.focus({ preventScroll: true });
6176
+ viewer.canvas.setPointerCapture(event.pointerId);
5419
6177
  event.preventDefault();
5420
- selectModel(hit.id);
5421
- closePanels();
5422
- $("scene-panel").hidden = false;
5423
- $("scene-button").setAttribute("aria-expanded", "true");
5424
- $("position-details").open = true;
5425
- syncSelection();
5426
- announce(
5427
- records.get(hit.id).name + " selected. Choose Move, Rotate or Depth."
5428
- );
5429
6178
  });
6179
+ listen(viewer.canvas, "pointermove", (event) => {
6180
+ if (selectionPointer?.id === event.pointerId && Math.hypot(
6181
+ event.clientX - selectionPointer.x,
6182
+ event.clientY - selectionPointer.y
6183
+ ) > 6)
6184
+ selectionPointer.moved = true;
6185
+ });
6186
+ listen(viewer.canvas, "pointerup", (event) => {
6187
+ const pointer = selectionPointer;
6188
+ selectionPointer = null;
6189
+ if (selectionMode && !busy && pointer?.id === event.pointerId && !pointer.moved)
6190
+ editModelAt(event);
6191
+ });
6192
+ for (const event of ["pointercancel", "lostpointercapture"])
6193
+ listen(viewer.canvas, event, () => {
6194
+ selectionPointer = null;
6195
+ });
5430
6196
  return viewer;
5431
6197
  }).catch((error) => {
5432
6198
  modelMover?.dispose();
@@ -5468,7 +6234,7 @@ function mountViewstlApp(root, options = {}) {
5468
6234
  if (queue.length && !draining) drainPromise = drainQueue();
5469
6235
  return drainPromise || Promise.resolve();
5470
6236
  }
5471
- function openURL(value, format = "") {
6237
+ function openURL(value, format2 = "") {
5472
6238
  assertActive();
5473
6239
  if (!options.url_import_endpoint)
5474
6240
  throw new Error("URL imports are not configured.");
@@ -5477,7 +6243,7 @@ function mountViewstlApp(root, options = {}) {
5477
6243
  throw new Error(
5478
6244
  "Use a public HTTP or HTTPS file URL without login credentials."
5479
6245
  );
5480
- if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
6246
+ if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
5481
6247
  throw new Error("Choose a supported 3D format.");
5482
6248
  url.hash = "";
5483
6249
  const name = decodeURIComponent(
@@ -5486,9 +6252,9 @@ function mountViewstlApp(root, options = {}) {
5486
6252
  queue.push({
5487
6253
  name,
5488
6254
  remote: url.href,
5489
- format,
6255
+ format: format2,
5490
6256
  size: 0,
5491
- scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6257
+ scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
5492
6258
  });
5493
6259
  if (!draining) drainPromise = drainQueue();
5494
6260
  return drainPromise || Promise.resolve();
@@ -5557,7 +6323,7 @@ function mountViewstlApp(root, options = {}) {
5557
6323
  await engine.when_idle();
5558
6324
  if (token !== generation) continue;
5559
6325
  loadingID = null;
5560
- const loaded = lastLoadedID === id ? engine.get_model_info(id) : null;
6326
+ const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
5561
6327
  if (!loaded) {
5562
6328
  announce(source.name + " could not be opened.");
5563
6329
  continue;
@@ -5649,7 +6415,7 @@ function mountViewstlApp(root, options = {}) {
5649
6415
  );
5650
6416
  const record = {
5651
6417
  id,
5652
- name: String(m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6418
+ name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
5653
6419
  size: g.file_bytes ?? m.local_file?.size ?? null,
5654
6420
  units: g.units || m.units,
5655
6421
  bounds,
@@ -5691,6 +6457,39 @@ function mountViewstlApp(root, options = {}) {
5691
6457
  }
5692
6458
  }
5693
6459
  root.querySelectorAll("[data-open-file]").forEach((b) => listen(b, "click", () => $("file-input").click()));
6460
+ for (const button of shortcutButtons)
6461
+ listen(button, "click", () => {
6462
+ if (button.disabled || busy || !records.size) return;
6463
+ const action = button.dataset.viewstlShortcut;
6464
+ if (action === "save") $("save-button")?.click();
6465
+ else if (action === "select") {
6466
+ setSelectionMode(!selectionMode);
6467
+ closePanels();
6468
+ announce(
6469
+ selectionMode ? "Selection mode. Click a model; Escape returns to viewing." : "Viewing mode. Drag to rotate the scene."
6470
+ );
6471
+ } else if ([
6472
+ "front",
6473
+ "back",
6474
+ "left",
6475
+ "right",
6476
+ "top",
6477
+ "bottom",
6478
+ "isometric"
6479
+ ].includes(action)) {
6480
+ setMoveMode(false);
6481
+ setSelectionMode(false);
6482
+ viewer.set_camera_view(action);
6483
+ toolsUI?.sync();
6484
+ announce(button.title || action + " view");
6485
+ }
6486
+ });
6487
+ listen(window2, "resize", () => {
6488
+ if (selectionMode && !shortcutButtons.some(
6489
+ (button) => button.dataset.viewstlShortcut === "select" && button.getClientRects().length
6490
+ ))
6491
+ setSelectionMode(false);
6492
+ });
5694
6493
  listen($("file-input"), "change", () => {
5695
6494
  const files = [...$("file-input").files];
5696
6495
  $("file-input").value = "";
@@ -5902,6 +6701,12 @@ function mountViewstlApp(root, options = {}) {
5902
6701
  listen(root, "keydown", (event) => {
5903
6702
  if (event.key !== "Escape") return;
5904
6703
  if ($("save-dialog")?.open) return;
6704
+ if (selectionMode) {
6705
+ setSelectionMode(false);
6706
+ closePanels();
6707
+ announce("Viewing mode. Drag to rotate the scene.");
6708
+ return;
6709
+ }
5905
6710
  if (moveMode) {
5906
6711
  setMoveMode(false);
5907
6712
  announce("Move tool closed.");
@@ -5978,6 +6783,44 @@ function mountViewstlApp(root, options = {}) {
5978
6783
  refreshPose: refreshAnimatedPose,
5979
6784
  announce
5980
6785
  });
6786
+ if ($("size-details"))
6787
+ toolsUI = bindAppTools({
6788
+ $,
6789
+ listen,
6790
+ window: window2,
6791
+ getViewer: () => viewer,
6792
+ getState: () => ({ selectedId: modelID, busy }),
6793
+ refresh: renderScene,
6794
+ announce,
6795
+ changeSize(operation) {
6796
+ setMoveMode(false);
6797
+ const record = records.get(modelID);
6798
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6799
+ operation();
6800
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6801
+ $("scene-arrangement").value = "custom";
6802
+ renderScene();
6803
+ },
6804
+ duplicate() {
6805
+ const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6806
+ const record = {
6807
+ ...original,
6808
+ id,
6809
+ name: m.display_name,
6810
+ position: { ...original.position },
6811
+ rotation: { ...original.rotation }
6812
+ };
6813
+ syncAnimatedRecord(record, m);
6814
+ record.originalPosition = { ...record.position };
6815
+ record.originalRotation = { ...record.rotation };
6816
+ records.set(id, record);
6817
+ nextID = Math.max(nextID, id + 1);
6818
+ $("scene-arrangement").value = "custom";
6819
+ selectModel(id);
6820
+ setMoveMode(true, "move", true);
6821
+ announce("Copy selected at the same position. Drag to move it.");
6822
+ }
6823
+ });
5981
6824
  updateGrid();
5982
6825
  setBusy(false);
5983
6826
  setMoveMode(false);
@@ -6026,6 +6869,7 @@ function mountViewstlApp(root, options = {}) {
6026
6869
  exportUI?.dispose();
6027
6870
  printUI?.dispose();
6028
6871
  animationUI?.dispose();
6872
+ toolsUI?.dispose();
6029
6873
  cancelLoading();
6030
6874
  setMoveMode(false);
6031
6875
  appearanceRequest++;