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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) 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-ZIXMUNLI.js → scene-export-FPPL466I.js} +1 -1
  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 +5 -5
  21. package/build/viewstl.cjs +1134 -392
  22. package/build/viewstl.d.cts +48 -0
  23. package/build/viewstl.d.ts +48 -0
  24. package/build/viewstl.js +1134 -392
  25. package/build/viewstl.min.js +5 -5
  26. package/build/viewstl.worker.js +1 -1
  27. package/docs/releases/2.0.0-rc.6.md +7 -0
  28. package/docs/v2-usage.md +24 -0
  29. package/docs/v2-verification.md +28 -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-reader-JGHERXR2.js +0 -1
  36. package/docs/releases/2.0.0-rc.5.md +0 -46
package/build/viewstl.cjs CHANGED
@@ -25,54 +25,6 @@ __export(commonjs_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(commonjs_exports);
27
27
 
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
28
  // src/math.js
77
29
  var add = (a, b) => a.map((v, i) => v + b[i]);
78
30
  var sub = (a, b) => a.map((v, i) => v - b[i]);
@@ -205,41 +157,625 @@ function rayBox(origin, direction, min, max) {
205
157
  if (origin[a] < min[a] || origin[a] > max[a]) return false;
206
158
  continue;
207
159
  }
208
- let x = (min[a] - origin[a]) / direction[a], y = (max[a] - origin[a]) / direction[a];
209
- if (x > y) [x, y] = [y, x];
210
- near = Math.max(near, x);
211
- far = Math.min(far, y);
212
- if (far < near) return false;
160
+ let x = (min[a] - origin[a]) / direction[a], y = (max[a] - origin[a]) / direction[a];
161
+ if (x > y) [x, y] = [y, x];
162
+ near = Math.max(near, x);
163
+ far = Math.min(far, y);
164
+ if (far < near) return false;
165
+ }
166
+ return true;
167
+ }
168
+ function rayTriangle(o, d, p, i) {
169
+ const a = [p[i], p[i + 1], p[i + 2]], b = sub([p[i + 3], p[i + 4], p[i + 5]], a), c = sub([p[i + 6], p[i + 7], p[i + 8]], a);
170
+ const h = cross(d, c), det = dot(b, h);
171
+ if (Math.abs(det) < 1e-12) return Infinity;
172
+ const s = sub(o, a), u = dot(s, h) / det;
173
+ if (u < 0 || u > 1) return Infinity;
174
+ const q = cross(s, b), v = dot(d, q) / det;
175
+ if (v < 0 || u + v > 1) return Infinity;
176
+ const t = dot(c, q) / det;
177
+ return t >= 0 ? t : Infinity;
178
+ }
179
+
180
+ // src/animation.js
181
+ var animationKeys = [
182
+ "x",
183
+ "y",
184
+ "z",
185
+ "rotationx",
186
+ "rotationy",
187
+ "rotationz",
188
+ "scalex",
189
+ "scaley",
190
+ "scalez"
191
+ ];
192
+ var isScale = (key) => key.startsWith("scale");
193
+ var number = (value, name, positive2 = false) => {
194
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
195
+ throw new Error(`Invalid animation ${name}`);
196
+ return value;
197
+ };
198
+ var object = (value, name) => {
199
+ if (!value || typeof value !== "object" || Array.isArray(value))
200
+ throw new Error(`Invalid animation ${name}`);
201
+ return value;
202
+ };
203
+ function animationPose(model) {
204
+ return Object.fromEntries(
205
+ animationKeys.map((key) => [
206
+ key,
207
+ number(
208
+ model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
209
+ key,
210
+ isScale(key)
211
+ )
212
+ ])
213
+ );
214
+ }
215
+ function normalizeAnimation(input) {
216
+ object(input, "definition");
217
+ const result = {}, claimed = /* @__PURE__ */ new Set();
218
+ for (const mode of ["delta", "exact"]) {
219
+ if (input[mode] == null) continue;
220
+ const source = object(input[mode], mode), values = {};
221
+ const msec = number(source.msec ?? 300, "duration", true);
222
+ if (source.loop !== void 0 && typeof source.loop !== "boolean")
223
+ throw new Error("Animation loop must be a boolean");
224
+ const loop = mode === "delta" && !!source.loop;
225
+ for (const key of animationKeys) {
226
+ const value = source[key] ?? (isScale(key) ? source.scale : void 0);
227
+ if (value === void 0) continue;
228
+ if (claimed.has(key))
229
+ throw new Error(`Animation delta/exact conflict: ${key}`);
230
+ number(value, key, mode === "exact" && isScale(key));
231
+ if (loop && isScale(key) && value < 0)
232
+ throw new Error("Looping scale deltas cannot be negative");
233
+ values[key] = value;
234
+ claimed.add(key);
235
+ }
236
+ if (!Object.keys(values).length)
237
+ throw new Error("Animation track needs a transform");
238
+ result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
239
+ }
240
+ if (!Object.keys(result).length)
241
+ throw new Error("Animation needs delta or exact");
242
+ return result;
243
+ }
244
+ function normalizePlaybackState(input) {
245
+ object(input, "playback state");
246
+ if (input.version !== 1 || typeof input.paused !== "boolean")
247
+ throw new Error("Invalid animation playback state version or paused flag");
248
+ const elapsed_ms = number(input.elapsed_ms, "elapsed time");
249
+ if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
250
+ object(input.origin, "origin");
251
+ for (const key of animationKeys) number(input.origin[key], key, isScale(key));
252
+ if (!Array.isArray(input.center) || input.center.length !== 3)
253
+ throw new Error("Invalid animation pivot");
254
+ return {
255
+ version: 1,
256
+ elapsed_ms,
257
+ paused: input.paused,
258
+ origin: animationPose(input.origin),
259
+ center: input.center.map((v) => number(v, "pivot"))
260
+ };
261
+ }
262
+ function createPlayback(definition, model, saved) {
263
+ const animation = normalizeAnimation(definition);
264
+ const state = saved == null ? null : normalizePlaybackState(saved);
265
+ const origin = state?.origin ?? animationPose(model);
266
+ const tracks = Object.entries(animation).map(([mode, a]) => {
267
+ const values = Object.entries(a).filter(
268
+ ([key]) => animationKeys.includes(key)
269
+ );
270
+ if (mode === "delta")
271
+ for (const [key, value] of values)
272
+ number(origin[key] + value, `${key} endpoint`, isScale(key));
273
+ return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
274
+ });
275
+ const playback = {
276
+ animation,
277
+ origin,
278
+ tracks,
279
+ elapsed_ms: state?.elapsed_ms ?? 0,
280
+ paused: state?.paused ?? false,
281
+ last_time: null
282
+ };
283
+ evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
284
+ return playback;
285
+ }
286
+ function evaluatePlayback(playback, model) {
287
+ const updates = [];
288
+ for (const track of playback.tracks) {
289
+ if (track.done) continue;
290
+ const fraction = playback.elapsed_ms / track.duration;
291
+ const p = track.loop ? fraction : Math.min(fraction, 1);
292
+ for (const [key, value] of track.values) {
293
+ const from = playback.origin[key];
294
+ const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
295
+ updates.push([key, number(to, `${key} result`, isScale(key))]);
296
+ }
297
+ }
298
+ let scaleChanged = false;
299
+ for (const [key, value] of updates) {
300
+ scaleChanged ||= isScale(key) && model[key] !== value;
301
+ model[key] = value;
302
+ }
303
+ for (const track of playback.tracks)
304
+ if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
305
+ if (scaleChanged) model._stats = null;
306
+ return playback.tracks.some((track) => !track.done);
307
+ }
308
+ function advancePlayback(playback, model, time) {
309
+ if (playback.paused) return true;
310
+ if (playback.last_time !== null)
311
+ playback.elapsed_ms = number(
312
+ playback.elapsed_ms + Math.max(0, time - playback.last_time),
313
+ "elapsed time"
314
+ );
315
+ playback.last_time = time;
316
+ return evaluatePlayback(playback, model);
317
+ }
318
+ function playbackState(playback, suspended = false) {
319
+ return {
320
+ status: playback.paused ? "paused" : "running",
321
+ elapsed_ms: playback.elapsed_ms,
322
+ suspended,
323
+ tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
324
+ mode: t.mode,
325
+ msec: t.duration,
326
+ loop: t.loop,
327
+ progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
328
+ }))
329
+ };
330
+ }
331
+ function savePlayback(model) {
332
+ const p = model._animation;
333
+ if (!p) return null;
334
+ const factor = model.unitScale ?? 1;
335
+ const animation = structuredClone(p.animation), origin = { ...p.origin };
336
+ for (const key of animationKeys.filter(isScale)) {
337
+ origin[key] *= factor;
338
+ for (const track of Object.values(animation))
339
+ if (track[key] !== void 0) track[key] *= factor;
340
+ }
341
+ const animation_state = normalizePlaybackState({
342
+ version: 1,
343
+ origin,
344
+ elapsed_ms: p.elapsed_ms,
345
+ paused: p.paused,
346
+ center: [...model.center]
347
+ });
348
+ return { animation: normalizeAnimation(animation), animation_state };
349
+ }
350
+
351
+ // src/memory.js
352
+ var MEMORY_OVERHEAD = 64 * 1024 * 1024;
353
+ var TRIANGLE_MEMORY = 224;
354
+ function geometryMemory(g) {
355
+ if (!g) return 0;
356
+ return g.triangles * TRIANGLE_MEMORY + (g.smoothNormals?.byteLength || 0) * 4 + ((g.edges?.byteLength || 0) + (g.wire?.byteLength || 0)) * 2;
357
+ }
358
+ function assertMemory(limits, bytes, operation = "model") {
359
+ if (bytes > (limits.max_memory_bytes ?? Number.MAX_SAFE_INTEGER))
360
+ throw new Error(
361
+ `Estimated memory for this ${operation} exceeds the device safety budget. Use a smaller model or a device with more memory.`
362
+ );
363
+ }
364
+ function derivedMemory(triangles, features) {
365
+ return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
366
+ }
367
+
368
+ // src/print-options.js
369
+ function printOptions(input = {}) {
370
+ const level = input.level ?? "basic";
371
+ if (!["basic", "detailed"].includes(level))
372
+ throw new Error("Choose basic or detailed print checks");
373
+ const number2 = (v, name, max = 1e9) => {
374
+ if (!Number.isFinite(v) || v <= 0 || v > max)
375
+ throw new Error(`Invalid ${name}`);
376
+ return v;
377
+ };
378
+ let profile = null;
379
+ if (input.profile != null) {
380
+ if (input.profile.technology !== "fdm")
381
+ throw new Error("Only FDM profiles are supported");
382
+ profile = { technology: "fdm" };
383
+ for (const key of ["min_wall_mm", "line_width_mm"])
384
+ if (input.profile[key] != null)
385
+ profile[key] = number2(input.profile[key], key);
386
+ if (input.profile.overhang_degrees != null)
387
+ profile.overhang_degrees = number2(
388
+ input.profile.overhang_degrees,
389
+ "overhang angle",
390
+ 89.9
391
+ );
392
+ if (input.profile.build_volume_mm != null) {
393
+ if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
394
+ throw new Error("Build volume requires X, Y and Z");
395
+ profile.build_volume_mm = input.profile.build_volume_mm.map(
396
+ (v) => number2(v, "build volume")
397
+ );
398
+ }
399
+ }
400
+ const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
401
+ return {
402
+ level,
403
+ profile,
404
+ apply_transforms: input.apply_transforms !== false,
405
+ units_confirmed: input.units_confirmed === true,
406
+ max_time_ms: time
407
+ };
408
+ }
409
+ var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
410
+ var printSignature = (model) => JSON.stringify([
411
+ model.x,
412
+ model.y,
413
+ model.z,
414
+ model.rotationx,
415
+ model.rotationy,
416
+ model.rotationz,
417
+ model.scaleVector,
418
+ model.center,
419
+ model.units,
420
+ model.unitScale
421
+ ]);
422
+
423
+ // src/model-tools.js
424
+ var axes = ["x", "y", "z"];
425
+ function validateUnits(units) {
426
+ if (!["mm", "inch"].includes(units)) throw new Error("Choose mm or inch");
427
+ return units;
428
+ }
429
+ function validateSizeState(value) {
430
+ if (!value || value.version !== 1 || !Array.isArray(value.baseline_mm) || value.baseline_mm.length !== 3 || value.baseline_mm.some((v) => !Number.isFinite(v) || v < 0 || v > 1e12) || typeof value.editable !== "boolean" || value.unit_factor !== void 0 && (!Number.isFinite(value.unit_factor) || value.unit_factor <= 0 || value.unit_factor > 1e12))
431
+ throw new Error("Invalid saved model size");
432
+ return {
433
+ version: 1,
434
+ baseline_mm: [...value.baseline_mm],
435
+ file_units: validateUnits(value.file_units),
436
+ editable: value.editable,
437
+ unit_factor: value.unit_factor ?? 1
438
+ };
439
+ }
440
+ function sizeState(model) {
441
+ return model.size_state ??= {
442
+ version: 1,
443
+ baseline_mm: model.geometry.bounds.max.map(
444
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.unitScale
445
+ ),
446
+ file_units: "mm",
447
+ editable: !model.geometry.units,
448
+ unit_factor: 1
449
+ };
450
+ }
451
+ function modelSize(model) {
452
+ if (!model.geometry) throw new Error("Wait for the model to finish opening");
453
+ const state = sizeState(model);
454
+ return {
455
+ ...structuredClone(state),
456
+ dimensions_mm: model.geometry.bounds.max.map(
457
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.scaleVector[i]
458
+ )
459
+ };
460
+ }
461
+ function resizeModel(id, factors) {
462
+ const m = this._model(id);
463
+ modelSize(m);
464
+ if (!Array.isArray(factors) || factors.length !== 3 || factors.some((v) => !Number.isFinite(v) || v <= 0))
465
+ throw new Error("Scale must be a positive finite number");
466
+ const next = axes.map((a, i) => m["scale" + a] * factors[i]);
467
+ if (next.some((v) => !Number.isFinite(v) || v < 1e-12 || v > 1e12))
468
+ throw new Error("Scale is outside the supported range");
469
+ const center = m.geometry.bounds.min.map(
470
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
471
+ );
472
+ const before = transform(modelMatrix(m), center);
473
+ const cached = m._measurements;
474
+ const oldKey = m.scaleVector.join(",");
475
+ this._stopAnimation(m, "manual");
476
+ axes.forEach((a, i) => m["scale" + a] = next[i]);
477
+ const after = transform(modelMatrix(m), center);
478
+ axes.forEach((a, i) => m[a] += before[i] - after[i]);
479
+ m._stats = null;
480
+ if (cached?.key === oldKey && cached.positions === m.geometry.positions && factors.every((v) => v === factors[0])) {
481
+ m._measurements = {
482
+ ...cached,
483
+ key: m.scaleVector.join(","),
484
+ result: {
485
+ ...cached.result,
486
+ area_mm2: cached.result.area_mm2 * factors[0] ** 2,
487
+ volume_mm3: cached.result.volume_mm3 == null ? null : cached.result.volume_mm3 * factors[0] ** 3
488
+ }
489
+ };
490
+ }
491
+ this._invalidate();
492
+ return modelSize(m);
493
+ }
494
+ function setFileUnits(id, units) {
495
+ validateUnits(units);
496
+ const m = this._model(id), state = sizeState(m);
497
+ if (!state.editable)
498
+ throw new Error(
499
+ "This model has explicit file units, already converted to millimetres"
500
+ );
501
+ if (state.file_units === units) return modelSize(m);
502
+ const factor = units === "inch" ? 25.4 : 1 / 25.4;
503
+ const result = resizeModel.call(this, id, [factor, factor, factor]);
504
+ state.baseline_mm = state.baseline_mm.map((v) => v * factor);
505
+ state.file_units = units;
506
+ state.unit_factor = (state.unit_factor ?? 1) * factor;
507
+ return { ...result, ...structuredClone(state) };
508
+ }
509
+ function resetModelSize(id) {
510
+ const size = modelSize(this._model(id));
511
+ return resizeModel.call(
512
+ this,
513
+ id,
514
+ size.dimensions_mm.map((v, i) => v ? size.baseline_mm[i] / v : 1)
515
+ );
516
+ }
517
+ function duplicateModel(id) {
518
+ const source = this._model(id);
519
+ modelSize(source);
520
+ if (source._sceneImportPending)
521
+ throw new Error("Wait for the scene to finish opening");
522
+ if (this._models.size + 1 > this.limits.max_archive_entries || [...this._models.values()].reduce(
523
+ (n, m) => n + (m.geometry?.triangles || 0),
524
+ source.geometry.triangles
525
+ ) > this.limits.max_triangles)
526
+ throw new Error("Scene model or triangle limit exceeded");
527
+ assertMemory(
528
+ this.limits,
529
+ this._occupiedMemory() + geometryMemory(source.geometry),
530
+ "duplicate"
531
+ );
532
+ const copy = {};
533
+ for (const key of [
534
+ "filename",
535
+ "orig_filename",
536
+ "local_file",
537
+ "x",
538
+ "y",
539
+ "z",
540
+ "rotationx",
541
+ "rotationy",
542
+ "rotationz",
543
+ "opacity",
544
+ "units",
545
+ "display",
546
+ "view_edges",
547
+ "smooth_shading",
548
+ "color"
549
+ ])
550
+ copy[key] = source[key];
551
+ copy.id = this.max_model_id + 1;
552
+ if (!Number.isSafeInteger(copy.id))
553
+ throw new Error("Model ID limit exceeded");
554
+ copy.display_name = (source.display_name || source.orig_filename || source.filename || "Model").split(/[\\/]/).pop() + " \u2014 copy";
555
+ copy.center = [...source.center];
556
+ copy.unitScale = 1;
557
+ axes.forEach((a, i) => copy["scale" + a] = source.scaleVector[i]);
558
+ Object.defineProperty(copy, "scaleVector", {
559
+ get: () => axes.map((a) => copy["scale" + a] * copy.unitScale)
560
+ });
561
+ copy.geometry = { ...source.geometry };
562
+ copy.section = source.section ? { ...source.section } : null;
563
+ copy.size_state = structuredClone(sizeState(source));
564
+ copy.size_state.unit_factor = (copy.size_state.unit_factor ?? 1) * source.unitScale;
565
+ const saved = savePlayback(source);
566
+ this.max_model_id = copy.id;
567
+ this._models.set(copy.id, copy);
568
+ this.models_ref[copy.id] = this.models.length;
569
+ this.models.push(copy);
570
+ this.models_count = this._models.size;
571
+ if (saved)
572
+ this._startAnimation(copy, saved.animation, {
573
+ ...saved.animation_state,
574
+ paused: true
575
+ });
576
+ this._invalidate();
577
+ return copy.id;
578
+ }
579
+ var cameraDirections = {
580
+ front: [
581
+ [0, -1, 0],
582
+ [0, 0, 1]
583
+ ],
584
+ back: [
585
+ [0, 1, 0],
586
+ [0, 0, 1]
587
+ ],
588
+ left: [
589
+ [-1, 0, 0],
590
+ [0, 0, 1]
591
+ ],
592
+ right: [
593
+ [1, 0, 0],
594
+ [0, 0, 1]
595
+ ],
596
+ top: [
597
+ [0, 0, 1],
598
+ [0, 1, 0]
599
+ ],
600
+ bottom: [
601
+ [0, 0, -1],
602
+ [0, -1, 0]
603
+ ],
604
+ isometric: [
605
+ [1, -1, 1],
606
+ [0, 0, 1]
607
+ ]
608
+ };
609
+ function cameraView(name) {
610
+ const view = cameraDirections[name];
611
+ if (!view) throw new Error("Unknown camera view");
612
+ this.camera.position = this.camera.target.map((v, i) => v + view[0][i]);
613
+ this.camera.up = [...view[1]];
614
+ this.set_auto_zoom();
615
+ }
616
+ function validateBookmarks(bookmarks) {
617
+ if (!Array.isArray(bookmarks) || bookmarks.length > 32)
618
+ throw new Error("Save up to 32 camera views");
619
+ const names = /* @__PURE__ */ new Set();
620
+ return bookmarks.map((b) => {
621
+ const name = typeof b?.name === "string" ? b.name.trim() : "";
622
+ if (!name || name.length > 80 || /[\x00-\x1f]/.test(name) || names.has(name))
623
+ throw new Error("Use a unique view name of 1\u201380 characters");
624
+ names.add(name);
625
+ const state = {};
626
+ for (const key of ["position", "target", "up"]) {
627
+ if (!b.camera?.[key] || axes.some(
628
+ (a) => !Number.isFinite(b.camera[key][a]) || Math.abs(b.camera[key][a]) > 1e15
629
+ ))
630
+ throw new Error("Invalid saved camera");
631
+ state[key] = Object.fromEntries(axes.map((a) => [a, b.camera[key][a]]));
632
+ }
633
+ const d = axes.map((a) => state.position[a] - state.target[a]), u = axes.map((a) => state.up[a]);
634
+ if (Math.hypot(
635
+ d[1] * u[2] - d[2] * u[1],
636
+ d[2] * u[0] - d[0] * u[2],
637
+ d[0] * u[1] - d[1] * u[0]
638
+ ) < 1e-12)
639
+ throw new Error("Degenerate saved camera");
640
+ return { name, camera: state };
641
+ });
642
+ }
643
+ function mergeBookmarks(existing, incoming) {
644
+ const result = validateBookmarks(existing), names = new Set(result.map((b) => b.name));
645
+ for (const b of validateBookmarks(incoming)) {
646
+ let name = b.name, n = 2;
647
+ while (names.has(name)) name = b.name.slice(0, 70) + ` (${n++})`;
648
+ names.add(name);
649
+ result.push({ ...b, name });
650
+ }
651
+ return validateBookmarks(result);
652
+ }
653
+ async function measureModel(id, options = {}) {
654
+ await this.when_ready;
655
+ const m = this._model(id), g = m.geometry;
656
+ if (!g || m._sceneImportPending)
657
+ throw new Error("Wait for the model to finish opening");
658
+ const scale = m.scaleVector, key = scale.join(","), cached = m._measurements;
659
+ if (options.signal?.aborted)
660
+ throw new DOMException("Measurement cancelled", "AbortError");
661
+ if (cached?.positions === g.positions && cached.key === key)
662
+ return { ...cached.result };
663
+ const working = printMemory(g.triangles), occupied = this._occupiedMemory();
664
+ assertMemory(this.limits, occupied + working, "measurements");
665
+ const release = this._reserveMemory(
666
+ m,
667
+ working + geometryMemory(g),
668
+ "measurements"
669
+ );
670
+ const controller = new AbortController(), cancel = () => controller.abort();
671
+ this._aborters.add(controller);
672
+ (m._measureJobs ??= /* @__PURE__ */ new Set()).add(controller);
673
+ options.signal?.addEventListener("abort", cancel, { once: true });
674
+ let job, callbackError;
675
+ const check = () => {
676
+ if (controller.signal.aborted || this.killsign)
677
+ throw new DOMException("Measurement cancelled", "AbortError");
678
+ if (this._models.get(id) !== m || m.geometry.positions !== g.positions || m.scaleVector.join(",") !== key)
679
+ throw new Error("Model size changed. Calculate again.");
680
+ };
681
+ try {
682
+ const positions = new Float32Array(g.positions.length);
683
+ for (let start = 0; start < positions.length; start += 262144) {
684
+ check();
685
+ positions.set(g.positions.subarray(start, start + 262144), start);
686
+ if (start + 262144 < positions.length)
687
+ await new Promise((r) => setTimeout(r, 0));
688
+ }
689
+ job = this._pool.run(
690
+ {
691
+ kind: "measure",
692
+ positions,
693
+ scale,
694
+ area: g.area,
695
+ max_time_ms: options.max_time_ms ?? 3e4,
696
+ limits: {
697
+ ...this.limits,
698
+ max_memory_bytes: this.limits.max_memory_bytes - occupied
699
+ }
700
+ },
701
+ (progress) => {
702
+ try {
703
+ options.on_progress?.(progress);
704
+ } catch (error) {
705
+ callbackError = error;
706
+ controller.abort();
707
+ }
708
+ },
709
+ [positions.buffer]
710
+ );
711
+ controller.signal.addEventListener("abort", () => job.cancel(), {
712
+ once: true
713
+ });
714
+ check();
715
+ let result;
716
+ try {
717
+ result = await job;
718
+ } catch (error) {
719
+ throw callbackError || error;
720
+ }
721
+ check();
722
+ m._measurements = { positions: g.positions, key, result };
723
+ return { ...result };
724
+ } finally {
725
+ job?.cancel?.();
726
+ release();
727
+ this._aborters.delete(controller);
728
+ m._measureJobs.delete(controller);
729
+ options.signal?.removeEventListener("abort", cancel);
213
730
  }
214
- return true;
215
- }
216
- function rayTriangle(o, d, p, i) {
217
- const a = [p[i], p[i + 1], p[i + 2]], b = sub([p[i + 3], p[i + 4], p[i + 5]], a), c = sub([p[i + 6], p[i + 7], p[i + 8]], a);
218
- const h = cross(d, c), det = dot(b, h);
219
- if (Math.abs(det) < 1e-12) return Infinity;
220
- const s = sub(o, a), u = dot(s, h) / det;
221
- if (u < 0 || u > 1) return Infinity;
222
- const q = cross(s, b), v = dot(d, q) / det;
223
- if (v < 0 || u + v > 1) return Infinity;
224
- const t = dot(c, q) / det;
225
- return t >= 0 ? t : Infinity;
226
731
  }
227
732
 
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;
733
+ // src/appearance.js
734
+ var DISPLAYS = [
735
+ "flat",
736
+ "smooth",
737
+ "wireframe",
738
+ "edges",
739
+ "mesh",
740
+ "xray",
741
+ "studio",
742
+ "orientation"
743
+ ];
744
+ function validateView(display, smooth) {
745
+ if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
746
+ if (typeof smooth !== "boolean")
747
+ throw new Error("smooth_shading must be a boolean");
748
+ return { display, smooth_shading: display === "smooth" || smooth };
234
749
  }
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
- );
750
+ function viewFeatures(view) {
751
+ return {
752
+ smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
753
+ edges: view.view_edges || view.display === "edges",
754
+ wire: view.display === "wireframe",
755
+ edgeAngle: view.display === "edges" ? 30 : 1
756
+ };
240
757
  }
241
- function derivedMemory(triangles, features) {
242
- return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
758
+ function validateSection(section) {
759
+ if (section == null) return null;
760
+ if (!["x", "y", "z"].includes(section.axis))
761
+ throw new Error("Section axis must be x, y or z");
762
+ if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
763
+ throw new Error("Section position must be between 0 and 1");
764
+ if (section.flip !== void 0 && typeof section.flip !== "boolean")
765
+ throw new Error("Section flip must be a boolean");
766
+ return {
767
+ axis: section.axis,
768
+ position: section.position,
769
+ flip: section.flip ?? false
770
+ };
771
+ }
772
+ function sectionPlane(bounds, section) {
773
+ if (!section) return [0, 0, 0, 0];
774
+ const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
775
+ const plane = [0, 0, 0, 0];
776
+ plane[axis] = sign;
777
+ plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
778
+ return plane;
243
779
  }
244
780
 
245
781
  // src/geometry.js
@@ -536,6 +1072,7 @@ var Renderer = class {
536
1072
  this.canvas = canvas;
537
1073
  this.domElement = canvas;
538
1074
  this.resources = /* @__PURE__ */ new Map();
1075
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
539
1076
  this.frames = 0;
540
1077
  this.lost = false;
541
1078
  this.gl = canvas.getContext("webgl2", {
@@ -552,6 +1089,7 @@ var Renderer = class {
552
1089
  e.preventDefault();
553
1090
  this.lost = true;
554
1091
  this.resources.clear();
1092
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
555
1093
  this.gridResource = null;
556
1094
  this.infiniteGrid = null;
557
1095
  this.selectionResource = null;
@@ -659,7 +1197,12 @@ var Renderer = class {
659
1197
  if (this.diagnostic?.model === model) this.setDiagnostic(null);
660
1198
  const resources = this.resources.get(model);
661
1199
  if (resources)
662
- for (const resource of Object.values(resources)) this.free(resource);
1200
+ for (const resource of Object.values(resources)) {
1201
+ if (--resource.users === 0) {
1202
+ this.sharedResources.get(resource.source)?.delete(resource);
1203
+ this.free(resource);
1204
+ }
1205
+ }
663
1206
  this.resources.delete(model);
664
1207
  }
665
1208
  clear() {
@@ -673,8 +1216,10 @@ var Renderer = class {
673
1216
  }
674
1217
  get liveBuffers() {
675
1218
  let n = (this.gridResource?.buffers.length || 0) + (this.selectionResource?.buffers.length || 0) + (this.diagnosticResource?.buffers.length || 0);
1219
+ const unique = /* @__PURE__ */ new Set();
676
1220
  for (const r of this.resources.values())
677
- for (const b of Object.values(r)) n += b.buffers.length;
1221
+ for (const b of Object.values(r)) unique.add(b);
1222
+ for (const b of unique) n += b.buffers.length;
678
1223
  return n;
679
1224
  }
680
1225
  setDiagnostic(value) {
@@ -740,12 +1285,23 @@ var Renderer = class {
740
1285
  this.resources.set(model, r);
741
1286
  }
742
1287
  if (!r[mode]) {
743
- const g = model.geometry;
744
- r[mode] = mode === "edges" || mode === "wire" ? this.upload(g[mode]) : this.upload(
745
- g.positions,
746
- mode === "smooth" ? g.smoothNormals : g.normals,
747
- g.colors
1288
+ const g = model.geometry, lines = mode === "edges" || mode === "wire", source = lines ? g[mode] : g.positions, normals = lines ? void 0 : mode === "smooth" ? g.smoothNormals : g.normals, colors = lines ? void 0 : g.colors;
1289
+ let entries = this.sharedResources.get(source);
1290
+ if (!entries) this.sharedResources.set(source, entries = /* @__PURE__ */ new Set());
1291
+ let resource = [...entries].find(
1292
+ (r2) => r2.normals === normals && r2.colors === colors
748
1293
  );
1294
+ if (!resource) {
1295
+ resource = Object.assign(this.upload(source, normals, colors), {
1296
+ source,
1297
+ normals,
1298
+ colors,
1299
+ users: 0
1300
+ });
1301
+ entries.add(resource);
1302
+ }
1303
+ resource.users++;
1304
+ r[mode] = resource;
749
1305
  }
750
1306
  return r[mode];
751
1307
  }
@@ -1088,205 +1644,34 @@ var WorkerPool = class {
1088
1644
  return;
1089
1645
  }
1090
1646
  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))]);
1647
+ };
1648
+ job.worker.onerror = (e) => {
1649
+ e.preventDefault();
1650
+ end(
1651
+ new Error(
1652
+ e.message || "Worker failed; check worker URL and Content Security Policy"
1653
+ )
1654
+ );
1655
+ };
1656
+ job.worker.onmessageerror = () => end(new Error("Invalid worker response"));
1657
+ job.timer = setTimeout(
1658
+ () => end(new Error("File processing timed out")),
1659
+ this.timeout
1660
+ );
1661
+ job.worker.postMessage({ id: job.id, task: job.task }, job.transfer);
1662
+ } catch (error) {
1663
+ end(error);
1664
+ }
1236
1665
  }
1237
1666
  }
1238
- let scaleChanged = false;
1239
- for (const [key, value] of updates) {
1240
- scaleChanged ||= isScale(key) && model[key] !== value;
1241
- model[key] = value;
1667
+ clear() {
1668
+ for (const job of [...this.queue, ...this.active]) this.cancel(job);
1242
1669
  }
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;
1670
+ dispose() {
1671
+ this.disposed = true;
1672
+ this.clear();
1280
1673
  }
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
- }
1674
+ };
1290
1675
 
1291
1676
  // src/scene-import.js
1292
1677
  var sceneFormat = (name) => String(name || "").match(/\.(vsb|vsj)(?:[?#]|$)/i)?.[1].toLowerCase() || "";
@@ -1347,8 +1732,8 @@ function importScene(source, options = {}) {
1347
1732
  await this.when_ready;
1348
1733
  check();
1349
1734
  oldCamera = this.get_camera_state();
1350
- const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1351
- if (!["vsb", "vsj"].includes(format))
1735
+ const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1736
+ if (!["vsb", "vsj"].includes(format2))
1352
1737
  throw new Error("Choose VSB or VSJ for a scene");
1353
1738
  let remote, relay;
1354
1739
  if (typeof source === "string") {
@@ -1381,8 +1766,8 @@ function importScene(source, options = {}) {
1381
1766
  source,
1382
1767
  remote,
1383
1768
  relay,
1384
- format,
1385
- name: `scene.${format}`,
1769
+ format: format2,
1770
+ name: `scene.${format2}`,
1386
1771
  limits
1387
1772
  },
1388
1773
  (p) => {
@@ -1403,6 +1788,10 @@ function importScene(source, options = {}) {
1403
1788
  job = null;
1404
1789
  check();
1405
1790
  const { scene, files, base } = documentData;
1791
+ const bookmarks = mergeBookmarks(
1792
+ this.get_camera_bookmarks(),
1793
+ scene.camera_bookmarks || []
1794
+ );
1406
1795
  this._sceneBytes -= held;
1407
1796
  held = Object.values(files || {}).reduce((n, b) => n + b.byteLength, 0) + JSON.stringify(scene).length * 2;
1408
1797
  this._sceneBytes += held;
@@ -1494,6 +1883,8 @@ function importScene(source, options = {}) {
1494
1883
  if (scene.auto_rotate !== void 0)
1495
1884
  this.set_auto_rotate(scene.auto_rotate);
1496
1885
  this._restoreCamera = scene.camera_state || (hadModels ? oldCamera : null);
1886
+ this.camera_bookmarks = bookmarks;
1887
+ if (scene.display_units) this.display_units = scene.display_units;
1497
1888
  committed = true;
1498
1889
  return { model_ids: ids, scene, used_files: [...usedFiles] };
1499
1890
  })().catch((error) => {
@@ -1514,61 +1905,6 @@ function importScene(source, options = {}) {
1514
1905
  return promise;
1515
1906
  }
1516
1907
 
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
1908
  // src/print-controller.js
1573
1909
  var reports = /* @__PURE__ */ new WeakMap();
1574
1910
  function reportCurrent(viewer, report) {
@@ -1666,7 +2002,9 @@ async function checkPrintability(options = {}) {
1666
2002
  rotationy: 0,
1667
2003
  rotationz: 0,
1668
2004
  center: [0, 0, 0],
1669
- scaleVector: [model.unitScale, model.unitScale, model.unitScale]
2005
+ scaleVector: [1, 1, 1].map(
2006
+ () => model.unitScale * (model.size_state?.unit_factor ?? 1)
2007
+ )
1670
2008
  };
1671
2009
  progress({
1672
2010
  phase: "Starting",
@@ -1790,8 +2128,8 @@ function exportFilename(filename, extension) {
1790
2128
  function exportOptions(options = {}) {
1791
2129
  const aliases = { stp: "step", igs: "iges" };
1792
2130
  const requested = String(options.format || "").toLowerCase();
1793
- const format = aliases[requested] || requested;
1794
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2131
+ const format2 = aliases[requested] || requested;
2132
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
1795
2133
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
1796
2134
  const units = options.units ?? "mm";
1797
2135
  if (!["mm", "inch"].includes(units))
@@ -1801,10 +2139,10 @@ function exportOptions(options = {}) {
1801
2139
  throw new Error(`${key} must be a boolean`);
1802
2140
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
1803
2141
  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;
2142
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2143
+ const extension = format2 === "obj" && colors ? "zip" : format2;
1806
2144
  return {
1807
- format,
2145
+ format: format2,
1808
2146
  units,
1809
2147
  colors,
1810
2148
  apply_transforms: options.apply_transforms !== false,
@@ -1997,7 +2335,9 @@ var modelKeys = [
1997
2335
  "section",
1998
2336
  "units",
1999
2337
  "animation",
2000
- "animation_state"
2338
+ "animation_state",
2339
+ "size_state",
2340
+ "display_name"
2001
2341
  ];
2002
2342
  var finite = (v, name) => {
2003
2343
  if (typeof v !== "number" || !Number.isFinite(v))
@@ -2029,7 +2369,7 @@ function modelOptions(input) {
2029
2369
  return out;
2030
2370
  }
2031
2371
  var StlViewer = class _StlViewer {
2032
- static version = "2.0.0-rc.5";
2372
+ static version = "2.0.0-rc.6";
2033
2373
  static workerURL = null;
2034
2374
  constructor(parent, options = {}) {
2035
2375
  if (!parent?.appendChild)
@@ -2058,6 +2398,8 @@ var StlViewer = class _StlViewer {
2058
2398
  this._sessionActive = false;
2059
2399
  this._frame = 0;
2060
2400
  this._lastTime = 0;
2401
+ this.camera_bookmarks = [];
2402
+ this.display_units = "mm";
2061
2403
  this.grid = null;
2062
2404
  this._gridAuto = false;
2063
2405
  this.camera = {
@@ -2394,6 +2736,13 @@ var StlViewer = class _StlViewer {
2394
2736
  finite(m[key], key);
2395
2737
  if (key.startsWith("scale")) positive(m[key], key);
2396
2738
  }
2739
+ if (supplied.size_state)
2740
+ m.size_state = validateSizeState(supplied.size_state);
2741
+ if (supplied.display_name != null) {
2742
+ if (typeof supplied.display_name !== "string" || supplied.display_name.length > 1024)
2743
+ throw new Error("Invalid model name");
2744
+ m.display_name = supplied.display_name;
2745
+ }
2397
2746
  m.opacity = supplied.opacity ?? supplied.mesh?.material?.opacity ?? 1;
2398
2747
  finite(m.opacity, "opacity");
2399
2748
  if (m.opacity < 0 || m.opacity > 1)
@@ -2545,7 +2894,7 @@ var StlViewer = class _StlViewer {
2545
2894
  format: supplied.remote_format,
2546
2895
  relay,
2547
2896
  source: typeof source === "string" ? new URL(source, document.baseURI).href : source,
2548
- name: m.filename,
2897
+ name: m.display_name || m.filename,
2549
2898
  limits,
2550
2899
  features: features.smooth || features.edges || features.wire ? features : null
2551
2900
  },
@@ -2595,6 +2944,7 @@ var StlViewer = class _StlViewer {
2595
2944
  }
2596
2945
  _deleteModel(m) {
2597
2946
  this._stopAnimation(m, "removed");
2947
+ for (const controller of m._measureJobs || []) controller.abort();
2598
2948
  for (const [controller, models] of this._printJobs || [])
2599
2949
  if (models.has(m)) controller.abort();
2600
2950
  this._printCache = null;
@@ -2622,6 +2972,8 @@ var StlViewer = class _StlViewer {
2622
2972
  if (this.killsign) return;
2623
2973
  this._generation++;
2624
2974
  this._printCache = null;
2975
+ this.camera_bookmarks = [];
2976
+ this.display_units = "mm";
2625
2977
  this._pool?.clear();
2626
2978
  for (const c of this._aborters) c.abort();
2627
2979
  this._aborters.clear();
@@ -3061,12 +3413,42 @@ var StlViewer = class _StlViewer {
3061
3413
  type
3062
3414
  );
3063
3415
  }
3064
- get_model_info(id) {
3416
+ get_model_size(id) {
3417
+ return modelSize(this._model(id));
3418
+ }
3419
+ resize_model(id, factors) {
3420
+ return resizeModel.call(this, id, factors);
3421
+ }
3422
+ set_file_units(id, units) {
3423
+ return setFileUnits.call(this, id, units);
3424
+ }
3425
+ reset_model_size(id) {
3426
+ return resetModelSize.call(this, id);
3427
+ }
3428
+ duplicate_model(id) {
3429
+ return duplicateModel.call(this, id);
3430
+ }
3431
+ set_camera_view(name) {
3432
+ return cameraView.call(this, name);
3433
+ }
3434
+ set_display_units(units) {
3435
+ this.display_units = validateUnits(units);
3436
+ }
3437
+ get_camera_bookmarks() {
3438
+ return structuredClone(this.camera_bookmarks || []);
3439
+ }
3440
+ set_camera_bookmarks(bookmarks) {
3441
+ this.camera_bookmarks = validateBookmarks(bookmarks);
3442
+ }
3443
+ measure_model(id, options) {
3444
+ return measureModel.call(this, id, options);
3445
+ }
3446
+ get_model_info(id, options = {}) {
3065
3447
  return this._guard(() => {
3066
3448
  const m = this._model(id), g = m.geometry;
3067
3449
  if (!g) return null;
3068
3450
  const s = m.scaleVector, key = s.join(",");
3069
- if (!m._stats || m._stats.key !== key)
3451
+ if (options.measurements !== false && (!m._stats || m._stats.key !== key))
3070
3452
  m._stats = { key, ...scaledStats(g, s) };
3071
3453
  const factor = m.units === "inch" ? 1 / 25.4 : 1;
3072
3454
  return {
@@ -3082,8 +3464,8 @@ var StlViewer = class _StlViewer {
3082
3464
  section: m.section ? { ...m.section } : null,
3083
3465
  color: m.color ?? (g.colors ? "#ffffff" : "#909090"),
3084
3466
  scale: { x: m.scalex, y: m.scaley, z: m.scalez },
3085
- volume: m._stats.volume * factor ** 3,
3086
- area: m._stats.area * factor ** 2,
3467
+ volume: options.measurements === false ? null : m._stats.volume * factor ** 3,
3468
+ area: options.measurements === false ? null : m._stats.area * factor ** 2,
3087
3469
  triangles: g.triangles,
3088
3470
  bounds: { min: [...g.bounds.min], max: [...g.bounds.max] },
3089
3471
  has_colors: !!g.colors,
@@ -3171,7 +3553,9 @@ var StlViewer = class _StlViewer {
3171
3553
  rotationx: 0,
3172
3554
  rotationy: 0,
3173
3555
  rotationz: 0,
3174
- scaleVector: [m.unitScale, m.unitScale, m.unitScale]
3556
+ scaleVector: [1, 1, 1].map(
3557
+ () => m.unitScale * (m.size_state?.unit_factor ?? 1)
3558
+ )
3175
3559
  };
3176
3560
  const matrix = modelMatrix(source), factor = settings.units === "inch" ? 1 / 25.4 : 1;
3177
3561
  for (let c = 0; c < 4; c++)
@@ -3239,8 +3623,8 @@ var StlViewer = class _StlViewer {
3239
3623
  );
3240
3624
  }
3241
3625
  async export_scene(options = {}) {
3242
- const format = String(options.format || "").toLowerCase();
3243
- if (!["vsb", "vsj"].includes(format))
3626
+ const format2 = String(options.format || "").toLowerCase();
3627
+ if (!["vsb", "vsj"].includes(format2))
3244
3628
  throw new Error("Scene format must be vsb or vsj");
3245
3629
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
3246
3630
  throw new Error("force_basename must be a boolean");
@@ -3263,7 +3647,7 @@ var StlViewer = class _StlViewer {
3263
3647
  if (!models.length) throw new Error("No models to export");
3264
3648
  if (models.some((m) => !m.geometry))
3265
3649
  throw new Error("A model has not finished loading");
3266
- if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3650
+ if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3267
3651
  throw new Error("Scene model count limit exceeded");
3268
3652
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3269
3653
  if (triangles > this.limits.max_triangles)
@@ -3271,19 +3655,19 @@ var StlViewer = class _StlViewer {
3271
3655
  const scene = this.get_vsj(
3272
3656
  true,
3273
3657
  !!options.force_basename,
3274
- format === "vsb"
3658
+ format2 === "vsb"
3275
3659
  );
3276
3660
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3277
3661
  if (manifestBytes > this.limits.max_file_bytes)
3278
3662
  throw new Error("Scene manifest size limit exceeded");
3279
- if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3663
+ if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3280
3664
  throw new Error("Scene archive limit exceeded");
3281
- const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3665
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3282
3666
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3283
3667
  this._exportBytes += working;
3284
3668
  let job, cancel, callbackError;
3285
3669
  try {
3286
- const snapshot = format === "vsb" ? models.map((m, index) => ({
3670
+ const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3287
3671
  id: m.id,
3288
3672
  triangles: m.geometry.triangles,
3289
3673
  positions: m.geometry.positions.slice(),
@@ -3298,7 +3682,7 @@ var StlViewer = class _StlViewer {
3298
3682
  });
3299
3683
  checkAbort();
3300
3684
  let blob;
3301
- if (format === "vsj") {
3685
+ if (format2 === "vsj") {
3302
3686
  blob = new Blob([json], { type: "application/json" });
3303
3687
  options.on_progress?.({
3304
3688
  loaded: models.length,
@@ -3338,8 +3722,8 @@ var StlViewer = class _StlViewer {
3338
3722
  checkAbort();
3339
3723
  return {
3340
3724
  blob,
3341
- format,
3342
- filename: exportFilename(options.filename, format),
3725
+ format: format2,
3726
+ filename: exportFilename(options.filename, format2),
3343
3727
  model_ids: models.map((m) => m.id),
3344
3728
  triangles,
3345
3729
  representation: "scene"
@@ -3361,6 +3745,8 @@ var StlViewer = class _StlViewer {
3361
3745
  canvas_height: this.canvas_height,
3362
3746
  bg_color: this.bg_color,
3363
3747
  camera_state: this.get_camera_state(),
3748
+ camera_bookmarks: this.get_camera_bookmarks(),
3749
+ display_units: this.display_units || "mm",
3364
3750
  auto_rotate: this.auto_rotate,
3365
3751
  mouse_zoom: this.mouse_zoom,
3366
3752
  auto_resize: this.auto_resize,
@@ -3381,6 +3767,11 @@ var StlViewer = class _StlViewer {
3381
3767
  id: for_vsb ? -1 : m.id,
3382
3768
  filename: for_vsb ? `${m.id}.stl` : force_basename ? basename(m.filename) : m.filename,
3383
3769
  units: m.units,
3770
+ size_state: {
3771
+ ...structuredClone(sizeState(m)),
3772
+ unit_factor: for_vsb && !m._animation ? 1 : m.unitScale * (sizeState(m).unit_factor ?? 1)
3773
+ },
3774
+ ...m.display_name ? { display_name: m.display_name } : {},
3384
3775
  display: m.display,
3385
3776
  opacity: m.opacity,
3386
3777
  view_edges: m.view_edges,
@@ -3503,6 +3894,13 @@ var StlViewer = class _StlViewer {
3503
3894
  );
3504
3895
  else if (scene.grid) this.set_grid(true);
3505
3896
  if (scene.camera_state) this._restoreCamera = scene.camera_state;
3897
+ const bookmarks = mergeBookmarks(
3898
+ this.get_camera_bookmarks(),
3899
+ scene.camera_bookmarks || []
3900
+ );
3901
+ const displayUnits = validateUnits(
3902
+ scene.display_units || this.display_units || "mm"
3903
+ );
3506
3904
  const incoming = [];
3507
3905
  try {
3508
3906
  for (const input of scene.models) {
@@ -3550,6 +3948,8 @@ var StlViewer = class _StlViewer {
3550
3948
  this._startAnimation(m, model.animation, model.animation_state);
3551
3949
  m._sceneImportPending = false;
3552
3950
  }
3951
+ this.camera_bookmarks = bookmarks;
3952
+ this.display_units = displayUnits;
3553
3953
  } catch (error) {
3554
3954
  for (const { m } of incoming)
3555
3955
  if (this._models.get(m.id) === m) this.remove_model(m.id);
@@ -3693,6 +4093,299 @@ var StlViewer = class _StlViewer {
3693
4093
  };
3694
4094
  var viewer_default = StlViewer;
3695
4095
 
4096
+ // src/app-tools.js
4097
+ var toolsControls = [
4098
+ "size-details",
4099
+ "size-fields",
4100
+ "size-x",
4101
+ "size-y",
4102
+ "size-z",
4103
+ "size-lock",
4104
+ "size-percent",
4105
+ "size-apply",
4106
+ "size-cancel",
4107
+ "size-reset",
4108
+ "size-error",
4109
+ "file-units",
4110
+ "file-units-note",
4111
+ "display-units",
4112
+ "size-unit-label",
4113
+ "duplicate-model",
4114
+ "camera-preset",
4115
+ "bookmark-name",
4116
+ "bookmark-list",
4117
+ "bookmark-save",
4118
+ "bookmark-restore",
4119
+ "bookmark-rename",
4120
+ "bookmark-delete",
4121
+ "camera-error",
4122
+ "info-area",
4123
+ "info-volume",
4124
+ "measure-model",
4125
+ "measure-cancel",
4126
+ "measure-status"
4127
+ ];
4128
+ var axes2 = ["x", "y", "z"];
4129
+ var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4130
+ function bindAppTools({
4131
+ $,
4132
+ listen,
4133
+ window: window2,
4134
+ getViewer,
4135
+ getState,
4136
+ changeSize,
4137
+ duplicate,
4138
+ refresh,
4139
+ announce
4140
+ }) {
4141
+ let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4142
+ const error = (id, message = "") => {
4143
+ $(id).textContent = message;
4144
+ $(id).hidden = !message;
4145
+ };
4146
+ const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4147
+ const value = (v) => Number(v.toPrecision(10));
4148
+ function syncInfo() {
4149
+ const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4150
+ const s = m?.scaleVector, cached = m?._measurements;
4151
+ const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4152
+ const unit = factor() === 1 ? "mm" : "in";
4153
+ const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4154
+ $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4155
+ $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4156
+ if (!measurement)
4157
+ $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4158
+ $("measure-model").disabled = busy || !m || !!measurement;
4159
+ $("measure-cancel").hidden = !measurement;
4160
+ }
4161
+ function syncBookmarks() {
4162
+ const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4163
+ $("bookmark-list").replaceChildren(
4164
+ ...bookmarks.map((b) => {
4165
+ const option = window2.document.createElement("option");
4166
+ option.value = b.name;
4167
+ option.textContent = b.name;
4168
+ return option;
4169
+ })
4170
+ );
4171
+ if (bookmarks.some((b) => b.name === previous))
4172
+ $("bookmark-list").value = previous;
4173
+ for (const id of [
4174
+ "bookmark-restore",
4175
+ "bookmark-rename",
4176
+ "bookmark-delete",
4177
+ "bookmark-list"
4178
+ ])
4179
+ $(id).disabled = !bookmarks.length;
4180
+ $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4181
+ }
4182
+ function sync() {
4183
+ if (disposed) return;
4184
+ const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4185
+ if (selectedId !== selected) {
4186
+ measurement?.abort();
4187
+ selected = selectedId;
4188
+ }
4189
+ draftKind = null;
4190
+ base = m?.geometry ? v.get_model_size(selectedId) : null;
4191
+ $("size-fields").disabled = busy || !base;
4192
+ $("duplicate-model").disabled = busy || !base;
4193
+ $("display-units").value = v?.display_units || "mm";
4194
+ $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4195
+ $("file-units").value = base?.file_units || "mm";
4196
+ $("file-units").disabled = busy || !base?.editable;
4197
+ $("file-units-note").textContent = base?.editable ? "STL/OBJ have no units. Millimetres are assumed; choose inches to correct the physical size." : "Explicit file units were converted to millimetres on import.";
4198
+ axes2.forEach((a, i) => {
4199
+ $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4200
+ $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4201
+ });
4202
+ const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4203
+ $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4204
+ error("size-error");
4205
+ syncInfo();
4206
+ syncBookmarks();
4207
+ }
4208
+ const attempt = (id, action) => {
4209
+ try {
4210
+ action();
4211
+ error(id);
4212
+ } catch (e) {
4213
+ error(id, e.message);
4214
+ }
4215
+ };
4216
+ axes2.forEach(
4217
+ (a, i) => listen($("size-" + a), "input", () => {
4218
+ if (!base) return;
4219
+ draftKind = "axis";
4220
+ lastAxis = i;
4221
+ const n = $("size-" + a).valueAsNumber;
4222
+ if (!Number.isFinite(n) || n <= 0) return;
4223
+ if ($("size-lock").checked)
4224
+ axes2.forEach((axis, j) => {
4225
+ if (i !== j)
4226
+ $("size-" + axis).value = value(
4227
+ n * base.dimensions_mm[j] / base.dimensions_mm[i]
4228
+ );
4229
+ });
4230
+ $("size-percent").value = "";
4231
+ })
4232
+ );
4233
+ listen($("size-percent"), "input", () => {
4234
+ draftKind = "percent";
4235
+ const n = $("size-percent").valueAsNumber;
4236
+ if (base && Number.isFinite(n) && n > 0)
4237
+ axes2.forEach(
4238
+ (a, i) => $("size-" + a).value = value(
4239
+ base.baseline_mm[i] * n / 100 / factor()
4240
+ )
4241
+ );
4242
+ });
4243
+ listen(
4244
+ $("size-apply"),
4245
+ "click",
4246
+ () => attempt("size-error", () => {
4247
+ if (!base) return;
4248
+ if (!draftKind) return;
4249
+ const current = getViewer().get_model_size(selected).dimensions_mm;
4250
+ let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4251
+ if (draftKind === "percent") {
4252
+ const percent = $("size-percent").valueAsNumber;
4253
+ if (!Number.isFinite(percent) || percent <= 0)
4254
+ throw new Error("Enter a positive scale percentage.");
4255
+ desired = base.baseline_mm.map((v) => v * percent / 100);
4256
+ } else if ($("size-lock").checked) {
4257
+ const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4258
+ desired = base.dimensions_mm.map((v) => v * ratio);
4259
+ }
4260
+ let ratios = desired.map((n, i) => {
4261
+ if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4262
+ throw new Error("Enter positive dimensions for each non-flat axis.");
4263
+ return current[i] ? n / current[i] : 1;
4264
+ });
4265
+ if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4266
+ ratios = ratios.map(() => ratios[0]);
4267
+ changeSize(() => getViewer().resize_model(selected, ratios));
4268
+ announce("Model size updated.");
4269
+ })
4270
+ );
4271
+ listen($("size-cancel"), "click", sync);
4272
+ listen(
4273
+ $("size-reset"),
4274
+ "click",
4275
+ () => attempt("size-error", () => {
4276
+ changeSize(() => getViewer().reset_model_size(selected));
4277
+ announce("Original size restored; file-unit correction retained.");
4278
+ })
4279
+ );
4280
+ listen(
4281
+ $("file-units"),
4282
+ "change",
4283
+ () => attempt("size-error", () => {
4284
+ changeSize(
4285
+ () => getViewer().set_file_units(selected, $("file-units").value)
4286
+ );
4287
+ announce("File units corrected.");
4288
+ })
4289
+ );
4290
+ listen($("display-units"), "change", () => {
4291
+ getViewer()?.set_display_units($("display-units").value);
4292
+ refresh();
4293
+ });
4294
+ listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4295
+ listen(
4296
+ $("camera-preset"),
4297
+ "change",
4298
+ () => attempt("camera-error", () => {
4299
+ if ($("camera-preset").value)
4300
+ getViewer().set_camera_view($("camera-preset").value);
4301
+ announce("Camera view updated.");
4302
+ $("camera-preset").value = "";
4303
+ })
4304
+ );
4305
+ const editBookmarks = (action) => attempt("camera-error", () => {
4306
+ const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4307
+ action(bookmarks, v);
4308
+ v.set_camera_bookmarks(bookmarks);
4309
+ syncBookmarks();
4310
+ });
4311
+ listen(
4312
+ $("bookmark-save"),
4313
+ "click",
4314
+ () => editBookmarks((b, v) => {
4315
+ b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4316
+ })
4317
+ );
4318
+ listen(
4319
+ $("bookmark-rename"),
4320
+ "click",
4321
+ () => editBookmarks((b) => {
4322
+ b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4323
+ })
4324
+ );
4325
+ listen(
4326
+ $("bookmark-delete"),
4327
+ "click",
4328
+ () => editBookmarks((b) => {
4329
+ b.splice(
4330
+ b.findIndex((b2) => b2.name === $("bookmark-list").value),
4331
+ 1
4332
+ );
4333
+ })
4334
+ );
4335
+ listen(
4336
+ $("bookmark-restore"),
4337
+ "click",
4338
+ () => attempt("camera-error", () => {
4339
+ const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4340
+ if (b) {
4341
+ getViewer().set_camera_state(b.camera);
4342
+ announce("Saved view restored.");
4343
+ }
4344
+ })
4345
+ );
4346
+ listen(
4347
+ $("bookmark-list"),
4348
+ "change",
4349
+ () => $("bookmark-name").value = $("bookmark-list").value
4350
+ );
4351
+ listen($("measure-cancel"), "click", () => measurement?.abort());
4352
+ listen($("measure-model"), "click", async () => {
4353
+ const id = selected, v = getViewer(), controller = new window2.AbortController();
4354
+ measurement = controller;
4355
+ syncInfo();
4356
+ $("measure-status").textContent = "Calculating area and checking volume\u2026";
4357
+ try {
4358
+ await v.measure_model(id, {
4359
+ signal: controller.signal,
4360
+ on_progress: (p) => {
4361
+ if (selected === id && !disposed)
4362
+ $("measure-status").textContent = p.phase + "\u2026";
4363
+ }
4364
+ });
4365
+ if (!disposed && selected === id) announce("Measurements ready.");
4366
+ } catch (e) {
4367
+ if (!disposed && selected === id)
4368
+ $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4369
+ } finally {
4370
+ const message = $("measure-status").textContent;
4371
+ if (measurement === controller) measurement = null;
4372
+ if (!disposed) {
4373
+ syncInfo();
4374
+ if (!v._models.get(id)?._measurements && selected === id)
4375
+ $("measure-status").textContent = message;
4376
+ }
4377
+ }
4378
+ });
4379
+ return {
4380
+ sync,
4381
+ syncInfo,
4382
+ dispose() {
4383
+ disposed = true;
4384
+ measurement?.abort();
4385
+ }
4386
+ };
4387
+ }
4388
+
3696
4389
  // src/memory-policy.js
3697
4390
  var GiB = 1024 ** 3;
3698
4391
  var MiB = 1024 ** 2;
@@ -3724,9 +4417,9 @@ async function estimateFile(file) {
3724
4417
  }
3725
4418
 
3726
4419
  // 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]]));
4420
+ var axes3 = ["x", "y", "z"];
4421
+ var vector2 = (p) => axes3.map((a) => p[a]);
4422
+ var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
3730
4423
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
3731
4424
  var norm2 = (a) => {
3732
4425
  const n = Math.hypot(...a) || 1;
@@ -3758,7 +4451,7 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
3758
4451
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
3759
4452
  function modelCenter(model) {
3760
4453
  return object2(
3761
- centerOf(model.bounds).map((v, i) => v + model.position[axes[i]])
4454
+ centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
3762
4455
  );
3763
4456
  }
3764
4457
  function modelRadius(model) {
@@ -3769,7 +4462,7 @@ function modelRadius(model) {
3769
4462
  }
3770
4463
  function enginePosition(model) {
3771
4464
  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]));
4465
+ return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
3773
4466
  }
3774
4467
  function modelBounds(model) {
3775
4468
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -4110,16 +4803,16 @@ function bindAppExport({
4110
4803
  $("save-error").textContent = "";
4111
4804
  };
4112
4805
  function formatChanged() {
4113
- const format = $("save-format").value;
4806
+ const format2 = $("save-format").value;
4114
4807
  if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
4115
4808
  suggestedName();
4116
4809
  }
4117
4810
  if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
4118
4811
  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);
4812
+ $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4813
+ $("save-cad-note").hidden = !["step", "iges"].includes(format2);
4121
4814
  $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
4122
- $("save-submit").textContent = format === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4815
+ $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4123
4816
  sync();
4124
4817
  }
4125
4818
  function suggestedName() {
@@ -4570,7 +5263,7 @@ var animationControls = [
4570
5263
  "animation-status",
4571
5264
  "animation-error"
4572
5265
  ];
4573
- var axes2 = ["x", "y", "z"];
5266
+ var axes4 = ["x", "y", "z"];
4574
5267
  function syncAnimatedRecord(record, model) {
4575
5268
  if (!record || !model?.geometry) return;
4576
5269
  record.bounds = Object.fromEntries(
@@ -4582,11 +5275,11 @@ function syncAnimatedRecord(record, model) {
4582
5275
  ])
4583
5276
  );
4584
5277
  record.rotation = Object.fromEntries(
4585
- axes2.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5278
+ axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
4586
5279
  );
4587
5280
  record.position = { x: 0, y: 0, z: 0 };
4588
5281
  const offset = enginePosition(record);
4589
- for (const a of axes2) record.position[a] = model[a] - offset[a];
5282
+ for (const a of axes4) record.position[a] = model[a] - offset[a];
4590
5283
  }
4591
5284
  function centerAnimationPivot(record, model) {
4592
5285
  syncAnimatedRecord(record, model);
@@ -4599,10 +5292,10 @@ function centerAnimationPivot(record, model) {
4599
5292
  const rotation2 = rotationMatrix(record.rotation);
4600
5293
  const original = record.originalPosition || { x: 0, y: 0, z: 0 };
4601
5294
  record.originalPosition = Object.fromEntries(
4602
- axes2.map((a, i) => [a, original[a] + delta[i]])
5295
+ axes4.map((a, i) => [a, original[a] + delta[i]])
4603
5296
  );
4604
5297
  for (let i = 0; i < 3; i++)
4605
- model[axes2[i]] += delta.reduce(
5298
+ model[axes4[i]] += delta.reduce(
4606
5299
  (sum, v, j) => sum + rotation2[i * 3 + j] * v,
4607
5300
  0
4608
5301
  );
@@ -4611,7 +5304,7 @@ function centerAnimationPivot(record, model) {
4611
5304
  syncAnimatedRecord(record, model);
4612
5305
  }
4613
5306
  function spinDefinition(axis, seconds, direction) {
4614
- if (!axes2.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5307
+ if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
4615
5308
  throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
4616
5309
  return {
4617
5310
  delta: {
@@ -4868,6 +5561,8 @@ function mountViewstlApp(root, options = {}) {
4868
5561
  missing.push(...printControls.filter((id) => !$(id)));
4869
5562
  if (animationControls.some((id) => $(id)))
4870
5563
  missing.push(...animationControls.filter((id) => !$(id)));
5564
+ if (toolsControls.some((id) => $(id)))
5565
+ missing.push(...toolsControls.filter((id) => !$(id)));
4871
5566
  if (missing.length)
4872
5567
  throw new Error("Missing ViewSTL controls: " + missing.join(", "));
4873
5568
  const abort = new window2.AbortController();
@@ -4896,6 +5591,7 @@ function mountViewstlApp(root, options = {}) {
4896
5591
  let exportUI;
4897
5592
  let printUI;
4898
5593
  let animationUI;
5594
+ let toolsUI;
4899
5595
  let sceneImport;
4900
5596
  let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, transformTool = "move", modelMover, dragArrangement, background = "transparent";
4901
5597
  const viewNames = {
@@ -4944,6 +5640,8 @@ function mountViewstlApp(root, options = {}) {
4944
5640
  $(name + "-panel").hidden = !open;
4945
5641
  $(name + "-button").setAttribute("aria-expanded", String(open));
4946
5642
  syncSelection();
5643
+ if (open && name === "info") populateInfo();
5644
+ if (open && (name === "scene" || name === "view")) toolsUI?.sync();
4947
5645
  }
4948
5646
  function fileSize(size) {
4949
5647
  return size == null ? "Sample model" : size < 1024 ** 2 ? `${formatNumber.format(size / 1024)} KiB` : size < 1024 ** 3 ? `${formatNumber.format(size / 1024 ** 2)} MiB` : `${formatNumber.format(size / 1024 ** 3)} GiB`;
@@ -4970,12 +5668,13 @@ function mountViewstlApp(root, options = {}) {
4970
5668
  }
4971
5669
  function syncControls() {
4972
5670
  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";
5671
+ const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
4974
5672
  for (const input of modeInputs) {
4975
5673
  input.checked = input.value === mode;
4976
5674
  input.disabled = busy;
4977
5675
  }
4978
- $("view-label").textContent = viewNames[mode];
5676
+ $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5677
+ $("view-button").title = "View \xB7 " + viewNames[mode];
4979
5678
  $("smooth-shading").checked = info?.smooth_shading || false;
4980
5679
  $("smooth-shading").disabled = busy || mode === "wireframe";
4981
5680
  $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
@@ -4999,14 +5698,17 @@ function mountViewstlApp(root, options = {}) {
4999
5698
  exportUI?.sync();
5000
5699
  printUI?.sync();
5001
5700
  animationUI?.sync();
5701
+ toolsUI?.sync();
5002
5702
  }
5003
5703
  function populateInfo() {
5004
5704
  if (modelID === null) return;
5005
- const info = viewer.get_model_info(modelID), record = records.get(modelID), units = record.units === "mm" ? " mm" : "";
5705
+ const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
5006
5706
  $("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;
5707
+ $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
5008
5708
  $("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.";
5709
+ const size = viewer.get_model_size(modelID);
5710
+ $("unit-note").textContent = size.editable ? "File coordinates interpreted as " + (size.file_units === "inch" ? "inches." : "millimetres (assumed).") + " Change file units in Models \u2192 Size & units." : "Explicit file units were converted to millimetres on import.";
5711
+ toolsUI?.syncInfo();
5010
5712
  }
5011
5713
  function renderList() {
5012
5714
  $("model-list").replaceChildren(
@@ -5108,7 +5810,7 @@ function mountViewstlApp(root, options = {}) {
5108
5810
  input.removeAttribute("aria-invalid");
5109
5811
  }
5110
5812
  $("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.";
5813
+ $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
5112
5814
  $("move-model-button").disabled = !record || busy;
5113
5815
  $("reset-position").disabled = !record;
5114
5816
  syncRotation(preserveFocus);
@@ -5191,11 +5893,11 @@ function mountViewstlApp(root, options = {}) {
5191
5893
  $("model-tool").value = transformTool;
5192
5894
  $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
5193
5895
  }
5194
- function setMoveMode(enabled, tool = transformTool) {
5896
+ function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
5195
5897
  modelMover?.setEnabled(false);
5196
5898
  transformTool = tool;
5197
5899
  moveMode = !!enabled && modelID !== null && !busy;
5198
- if (moveMode) prepareManual(modelID);
5900
+ if (moveMode && !retainPaused) prepareManual(modelID);
5199
5901
  modelMover?.setEnabled(moveMode);
5200
5902
  syncTool();
5201
5903
  $("move-mode").hidden = !moveMode;
@@ -5372,6 +6074,7 @@ function mountViewstlApp(root, options = {}) {
5372
6074
  function refreshAnimatedPose() {
5373
6075
  if (disposed || modelID === null) return;
5374
6076
  syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6077
+ if (!$("info-panel").hidden) populateInfo();
5375
6078
  if (!$("scene-panel").hidden && $("position-details").open)
5376
6079
  syncPosition(true);
5377
6080
  }
@@ -5496,7 +6199,7 @@ function mountViewstlApp(root, options = {}) {
5496
6199
  if (queue.length && !draining) drainPromise = drainQueue();
5497
6200
  return drainPromise || Promise.resolve();
5498
6201
  }
5499
- function openURL(value, format = "") {
6202
+ function openURL(value, format2 = "") {
5500
6203
  assertActive();
5501
6204
  if (!options.url_import_endpoint)
5502
6205
  throw new Error("URL imports are not configured.");
@@ -5505,7 +6208,7 @@ function mountViewstlApp(root, options = {}) {
5505
6208
  throw new Error(
5506
6209
  "Use a public HTTP or HTTPS file URL without login credentials."
5507
6210
  );
5508
- if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
6211
+ if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
5509
6212
  throw new Error("Choose a supported 3D format.");
5510
6213
  url.hash = "";
5511
6214
  const name = decodeURIComponent(
@@ -5514,9 +6217,9 @@ function mountViewstlApp(root, options = {}) {
5514
6217
  queue.push({
5515
6218
  name,
5516
6219
  remote: url.href,
5517
- format,
6220
+ format: format2,
5518
6221
  size: 0,
5519
- scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6222
+ scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
5520
6223
  });
5521
6224
  if (!draining) drainPromise = drainQueue();
5522
6225
  return drainPromise || Promise.resolve();
@@ -5585,7 +6288,7 @@ function mountViewstlApp(root, options = {}) {
5585
6288
  await engine.when_idle();
5586
6289
  if (token !== generation) continue;
5587
6290
  loadingID = null;
5588
- const loaded = lastLoadedID === id ? engine.get_model_info(id) : null;
6291
+ const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
5589
6292
  if (!loaded) {
5590
6293
  announce(source.name + " could not be opened.");
5591
6294
  continue;
@@ -5677,7 +6380,7 @@ function mountViewstlApp(root, options = {}) {
5677
6380
  );
5678
6381
  const record = {
5679
6382
  id,
5680
- name: String(m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6383
+ name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
5681
6384
  size: g.file_bytes ?? m.local_file?.size ?? null,
5682
6385
  units: g.units || m.units,
5683
6386
  bounds,
@@ -6006,6 +6709,44 @@ function mountViewstlApp(root, options = {}) {
6006
6709
  refreshPose: refreshAnimatedPose,
6007
6710
  announce
6008
6711
  });
6712
+ if ($("size-details"))
6713
+ toolsUI = bindAppTools({
6714
+ $,
6715
+ listen,
6716
+ window: window2,
6717
+ getViewer: () => viewer,
6718
+ getState: () => ({ selectedId: modelID, busy }),
6719
+ refresh: renderScene,
6720
+ announce,
6721
+ changeSize(operation) {
6722
+ setMoveMode(false);
6723
+ const record = records.get(modelID);
6724
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6725
+ operation();
6726
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6727
+ $("scene-arrangement").value = "custom";
6728
+ renderScene();
6729
+ },
6730
+ duplicate() {
6731
+ const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6732
+ const record = {
6733
+ ...original,
6734
+ id,
6735
+ name: m.display_name,
6736
+ position: { ...original.position },
6737
+ rotation: { ...original.rotation }
6738
+ };
6739
+ syncAnimatedRecord(record, m);
6740
+ record.originalPosition = { ...record.position };
6741
+ record.originalRotation = { ...record.rotation };
6742
+ records.set(id, record);
6743
+ nextID = Math.max(nextID, id + 1);
6744
+ $("scene-arrangement").value = "custom";
6745
+ selectModel(id);
6746
+ setMoveMode(true, "move", true);
6747
+ announce("Copy selected at the same position. Drag to move it.");
6748
+ }
6749
+ });
6009
6750
  updateGrid();
6010
6751
  setBusy(false);
6011
6752
  setMoveMode(false);
@@ -6054,6 +6795,7 @@ function mountViewstlApp(root, options = {}) {
6054
6795
  exportUI?.dispose();
6055
6796
  printUI?.dispose();
6056
6797
  animationUI?.dispose();
6798
+ toolsUI?.dispose();
6057
6799
  cancelLoading();
6058
6800
  setMoveMode(false);
6059
6801
  appearanceRequest++;