@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.js CHANGED
@@ -1,51 +1,3 @@
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
- }
48
-
49
1
  // src/math.js
50
2
  var add = (a, b) => a.map((v, i) => v + b[i]);
51
3
  var sub = (a, b) => a.map((v, i) => v - b[i]);
@@ -178,41 +130,625 @@ function rayBox(origin, direction, min, max) {
178
130
  if (origin[a] < min[a] || origin[a] > max[a]) return false;
179
131
  continue;
180
132
  }
181
- let x = (min[a] - origin[a]) / direction[a], y = (max[a] - origin[a]) / direction[a];
182
- if (x > y) [x, y] = [y, x];
183
- near = Math.max(near, x);
184
- far = Math.min(far, y);
185
- if (far < near) return false;
133
+ let x = (min[a] - origin[a]) / direction[a], y = (max[a] - origin[a]) / direction[a];
134
+ if (x > y) [x, y] = [y, x];
135
+ near = Math.max(near, x);
136
+ far = Math.min(far, y);
137
+ if (far < near) return false;
138
+ }
139
+ return true;
140
+ }
141
+ function rayTriangle(o, d, p, i) {
142
+ 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);
143
+ const h = cross(d, c), det = dot(b, h);
144
+ if (Math.abs(det) < 1e-12) return Infinity;
145
+ const s = sub(o, a), u = dot(s, h) / det;
146
+ if (u < 0 || u > 1) return Infinity;
147
+ const q = cross(s, b), v = dot(d, q) / det;
148
+ if (v < 0 || u + v > 1) return Infinity;
149
+ const t = dot(c, q) / det;
150
+ return t >= 0 ? t : Infinity;
151
+ }
152
+
153
+ // src/animation.js
154
+ var animationKeys = [
155
+ "x",
156
+ "y",
157
+ "z",
158
+ "rotationx",
159
+ "rotationy",
160
+ "rotationz",
161
+ "scalex",
162
+ "scaley",
163
+ "scalez"
164
+ ];
165
+ var isScale = (key) => key.startsWith("scale");
166
+ var number = (value, name, positive2 = false) => {
167
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER || positive2 && value <= 0)
168
+ throw new Error(`Invalid animation ${name}`);
169
+ return value;
170
+ };
171
+ var object = (value, name) => {
172
+ if (!value || typeof value !== "object" || Array.isArray(value))
173
+ throw new Error(`Invalid animation ${name}`);
174
+ return value;
175
+ };
176
+ function animationPose(model) {
177
+ return Object.fromEntries(
178
+ animationKeys.map((key) => [
179
+ key,
180
+ number(
181
+ model[key] ?? (isScale(key) ? model.scale ?? 1 : 0),
182
+ key,
183
+ isScale(key)
184
+ )
185
+ ])
186
+ );
187
+ }
188
+ function normalizeAnimation(input) {
189
+ object(input, "definition");
190
+ const result = {}, claimed = /* @__PURE__ */ new Set();
191
+ for (const mode of ["delta", "exact"]) {
192
+ if (input[mode] == null) continue;
193
+ const source = object(input[mode], mode), values = {};
194
+ const msec = number(source.msec ?? 300, "duration", true);
195
+ if (source.loop !== void 0 && typeof source.loop !== "boolean")
196
+ throw new Error("Animation loop must be a boolean");
197
+ const loop = mode === "delta" && !!source.loop;
198
+ for (const key of animationKeys) {
199
+ const value = source[key] ?? (isScale(key) ? source.scale : void 0);
200
+ if (value === void 0) continue;
201
+ if (claimed.has(key))
202
+ throw new Error(`Animation delta/exact conflict: ${key}`);
203
+ number(value, key, mode === "exact" && isScale(key));
204
+ if (loop && isScale(key) && value < 0)
205
+ throw new Error("Looping scale deltas cannot be negative");
206
+ values[key] = value;
207
+ claimed.add(key);
208
+ }
209
+ if (!Object.keys(values).length)
210
+ throw new Error("Animation track needs a transform");
211
+ result[mode] = { ...values, msec, ...loop ? { loop: true } : {} };
212
+ }
213
+ if (!Object.keys(result).length)
214
+ throw new Error("Animation needs delta or exact");
215
+ return result;
216
+ }
217
+ function normalizePlaybackState(input) {
218
+ object(input, "playback state");
219
+ if (input.version !== 1 || typeof input.paused !== "boolean")
220
+ throw new Error("Invalid animation playback state version or paused flag");
221
+ const elapsed_ms = number(input.elapsed_ms, "elapsed time");
222
+ if (elapsed_ms < 0) throw new Error("Invalid animation elapsed time");
223
+ object(input.origin, "origin");
224
+ for (const key of animationKeys) number(input.origin[key], key, isScale(key));
225
+ if (!Array.isArray(input.center) || input.center.length !== 3)
226
+ throw new Error("Invalid animation pivot");
227
+ return {
228
+ version: 1,
229
+ elapsed_ms,
230
+ paused: input.paused,
231
+ origin: animationPose(input.origin),
232
+ center: input.center.map((v) => number(v, "pivot"))
233
+ };
234
+ }
235
+ function createPlayback(definition, model, saved) {
236
+ const animation = normalizeAnimation(definition);
237
+ const state = saved == null ? null : normalizePlaybackState(saved);
238
+ const origin = state?.origin ?? animationPose(model);
239
+ const tracks = Object.entries(animation).map(([mode, a]) => {
240
+ const values = Object.entries(a).filter(
241
+ ([key]) => animationKeys.includes(key)
242
+ );
243
+ if (mode === "delta")
244
+ for (const [key, value] of values)
245
+ number(origin[key] + value, `${key} endpoint`, isScale(key));
246
+ return { mode, values, duration: a.msec, loop: !!a.loop, done: false };
247
+ });
248
+ const playback = {
249
+ animation,
250
+ origin,
251
+ tracks,
252
+ elapsed_ms: state?.elapsed_ms ?? 0,
253
+ paused: state?.paused ?? false,
254
+ last_time: null
255
+ };
256
+ evaluatePlayback({ ...playback, tracks: tracks.map((t) => ({ ...t })) }, {});
257
+ return playback;
258
+ }
259
+ function evaluatePlayback(playback, model) {
260
+ const updates = [];
261
+ for (const track of playback.tracks) {
262
+ if (track.done) continue;
263
+ const fraction = playback.elapsed_ms / track.duration;
264
+ const p = track.loop ? fraction : Math.min(fraction, 1);
265
+ for (const [key, value] of track.values) {
266
+ const from = playback.origin[key];
267
+ const to = track.mode === "delta" ? from + value * p : from * (1 - p) + value * p;
268
+ updates.push([key, number(to, `${key} result`, isScale(key))]);
269
+ }
270
+ }
271
+ let scaleChanged = false;
272
+ for (const [key, value] of updates) {
273
+ scaleChanged ||= isScale(key) && model[key] !== value;
274
+ model[key] = value;
275
+ }
276
+ for (const track of playback.tracks)
277
+ if (!track.loop && playback.elapsed_ms >= track.duration) track.done = true;
278
+ if (scaleChanged) model._stats = null;
279
+ return playback.tracks.some((track) => !track.done);
280
+ }
281
+ function advancePlayback(playback, model, time) {
282
+ if (playback.paused) return true;
283
+ if (playback.last_time !== null)
284
+ playback.elapsed_ms = number(
285
+ playback.elapsed_ms + Math.max(0, time - playback.last_time),
286
+ "elapsed time"
287
+ );
288
+ playback.last_time = time;
289
+ return evaluatePlayback(playback, model);
290
+ }
291
+ function playbackState(playback, suspended = false) {
292
+ return {
293
+ status: playback.paused ? "paused" : "running",
294
+ elapsed_ms: playback.elapsed_ms,
295
+ suspended,
296
+ tracks: playback.tracks.filter((t) => !t.done).map((t) => ({
297
+ mode: t.mode,
298
+ msec: t.duration,
299
+ loop: t.loop,
300
+ progress: t.loop ? playback.elapsed_ms % t.duration / t.duration : Math.min(1, playback.elapsed_ms / t.duration)
301
+ }))
302
+ };
303
+ }
304
+ function savePlayback(model) {
305
+ const p = model._animation;
306
+ if (!p) return null;
307
+ const factor = model.unitScale ?? 1;
308
+ const animation = structuredClone(p.animation), origin = { ...p.origin };
309
+ for (const key of animationKeys.filter(isScale)) {
310
+ origin[key] *= factor;
311
+ for (const track of Object.values(animation))
312
+ if (track[key] !== void 0) track[key] *= factor;
313
+ }
314
+ const animation_state = normalizePlaybackState({
315
+ version: 1,
316
+ origin,
317
+ elapsed_ms: p.elapsed_ms,
318
+ paused: p.paused,
319
+ center: [...model.center]
320
+ });
321
+ return { animation: normalizeAnimation(animation), animation_state };
322
+ }
323
+
324
+ // src/memory.js
325
+ var MEMORY_OVERHEAD = 64 * 1024 * 1024;
326
+ var TRIANGLE_MEMORY = 224;
327
+ function geometryMemory(g) {
328
+ if (!g) return 0;
329
+ return g.triangles * TRIANGLE_MEMORY + (g.smoothNormals?.byteLength || 0) * 4 + ((g.edges?.byteLength || 0) + (g.wire?.byteLength || 0)) * 2;
330
+ }
331
+ function assertMemory(limits, bytes, operation = "model") {
332
+ if (bytes > (limits.max_memory_bytes ?? Number.MAX_SAFE_INTEGER))
333
+ throw new Error(
334
+ `Estimated memory for this ${operation} exceeds the device safety budget. Use a smaller model or a device with more memory.`
335
+ );
336
+ }
337
+ function derivedMemory(triangles, features) {
338
+ return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
339
+ }
340
+
341
+ // src/print-options.js
342
+ function printOptions(input = {}) {
343
+ const level = input.level ?? "basic";
344
+ if (!["basic", "detailed"].includes(level))
345
+ throw new Error("Choose basic or detailed print checks");
346
+ const number2 = (v, name, max = 1e9) => {
347
+ if (!Number.isFinite(v) || v <= 0 || v > max)
348
+ throw new Error(`Invalid ${name}`);
349
+ return v;
350
+ };
351
+ let profile = null;
352
+ if (input.profile != null) {
353
+ if (input.profile.technology !== "fdm")
354
+ throw new Error("Only FDM profiles are supported");
355
+ profile = { technology: "fdm" };
356
+ for (const key of ["min_wall_mm", "line_width_mm"])
357
+ if (input.profile[key] != null)
358
+ profile[key] = number2(input.profile[key], key);
359
+ if (input.profile.overhang_degrees != null)
360
+ profile.overhang_degrees = number2(
361
+ input.profile.overhang_degrees,
362
+ "overhang angle",
363
+ 89.9
364
+ );
365
+ if (input.profile.build_volume_mm != null) {
366
+ if (!Array.isArray(input.profile.build_volume_mm) || input.profile.build_volume_mm.length !== 3)
367
+ throw new Error("Build volume requires X, Y and Z");
368
+ profile.build_volume_mm = input.profile.build_volume_mm.map(
369
+ (v) => number2(v, "build volume")
370
+ );
371
+ }
372
+ }
373
+ const time = number2(input.max_time_ms ?? 3e4, "check time limit", 12e4);
374
+ return {
375
+ level,
376
+ profile,
377
+ apply_transforms: input.apply_transforms !== false,
378
+ units_confirmed: input.units_confirmed === true,
379
+ max_time_ms: time
380
+ };
381
+ }
382
+ var printMemory = (triangles) => 64 * 1024 ** 2 + triangles * 2400;
383
+ var printSignature = (model) => JSON.stringify([
384
+ model.x,
385
+ model.y,
386
+ model.z,
387
+ model.rotationx,
388
+ model.rotationy,
389
+ model.rotationz,
390
+ model.scaleVector,
391
+ model.center,
392
+ model.units,
393
+ model.unitScale
394
+ ]);
395
+
396
+ // src/model-tools.js
397
+ var axes = ["x", "y", "z"];
398
+ function validateUnits(units) {
399
+ if (!["mm", "inch"].includes(units)) throw new Error("Choose mm or inch");
400
+ return units;
401
+ }
402
+ function validateSizeState(value) {
403
+ if (!value || value.version !== 1 || !Array.isArray(value.baseline_mm) || value.baseline_mm.length !== 3 || value.baseline_mm.some((v) => !Number.isFinite(v) || v < 0 || v > 1e12) || typeof value.editable !== "boolean" || value.unit_factor !== void 0 && (!Number.isFinite(value.unit_factor) || value.unit_factor <= 0 || value.unit_factor > 1e12))
404
+ throw new Error("Invalid saved model size");
405
+ return {
406
+ version: 1,
407
+ baseline_mm: [...value.baseline_mm],
408
+ file_units: validateUnits(value.file_units),
409
+ editable: value.editable,
410
+ unit_factor: value.unit_factor ?? 1
411
+ };
412
+ }
413
+ function sizeState(model) {
414
+ return model.size_state ??= {
415
+ version: 1,
416
+ baseline_mm: model.geometry.bounds.max.map(
417
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.unitScale
418
+ ),
419
+ file_units: "mm",
420
+ editable: !model.geometry.units,
421
+ unit_factor: 1
422
+ };
423
+ }
424
+ function modelSize(model) {
425
+ if (!model.geometry) throw new Error("Wait for the model to finish opening");
426
+ const state = sizeState(model);
427
+ return {
428
+ ...structuredClone(state),
429
+ dimensions_mm: model.geometry.bounds.max.map(
430
+ (v, i) => (v - model.geometry.bounds.min[i]) * model.scaleVector[i]
431
+ )
432
+ };
433
+ }
434
+ function resizeModel(id, factors) {
435
+ const m = this._model(id);
436
+ modelSize(m);
437
+ if (!Array.isArray(factors) || factors.length !== 3 || factors.some((v) => !Number.isFinite(v) || v <= 0))
438
+ throw new Error("Scale must be a positive finite number");
439
+ const next = axes.map((a, i) => m["scale" + a] * factors[i]);
440
+ if (next.some((v) => !Number.isFinite(v) || v < 1e-12 || v > 1e12))
441
+ throw new Error("Scale is outside the supported range");
442
+ const center = m.geometry.bounds.min.map(
443
+ (v, i) => (v + m.geometry.bounds.max[i]) / 2
444
+ );
445
+ const before = transform(modelMatrix(m), center);
446
+ const cached = m._measurements;
447
+ const oldKey = m.scaleVector.join(",");
448
+ this._stopAnimation(m, "manual");
449
+ axes.forEach((a, i) => m["scale" + a] = next[i]);
450
+ const after = transform(modelMatrix(m), center);
451
+ axes.forEach((a, i) => m[a] += before[i] - after[i]);
452
+ m._stats = null;
453
+ if (cached?.key === oldKey && cached.positions === m.geometry.positions && factors.every((v) => v === factors[0])) {
454
+ m._measurements = {
455
+ ...cached,
456
+ key: m.scaleVector.join(","),
457
+ result: {
458
+ ...cached.result,
459
+ area_mm2: cached.result.area_mm2 * factors[0] ** 2,
460
+ volume_mm3: cached.result.volume_mm3 == null ? null : cached.result.volume_mm3 * factors[0] ** 3
461
+ }
462
+ };
463
+ }
464
+ this._invalidate();
465
+ return modelSize(m);
466
+ }
467
+ function setFileUnits(id, units) {
468
+ validateUnits(units);
469
+ const m = this._model(id), state = sizeState(m);
470
+ if (!state.editable)
471
+ throw new Error(
472
+ "This model has explicit file units, already converted to millimetres"
473
+ );
474
+ if (state.file_units === units) return modelSize(m);
475
+ const factor = units === "inch" ? 25.4 : 1 / 25.4;
476
+ const result = resizeModel.call(this, id, [factor, factor, factor]);
477
+ state.baseline_mm = state.baseline_mm.map((v) => v * factor);
478
+ state.file_units = units;
479
+ state.unit_factor = (state.unit_factor ?? 1) * factor;
480
+ return { ...result, ...structuredClone(state) };
481
+ }
482
+ function resetModelSize(id) {
483
+ const size = modelSize(this._model(id));
484
+ return resizeModel.call(
485
+ this,
486
+ id,
487
+ size.dimensions_mm.map((v, i) => v ? size.baseline_mm[i] / v : 1)
488
+ );
489
+ }
490
+ function duplicateModel(id) {
491
+ const source = this._model(id);
492
+ modelSize(source);
493
+ if (source._sceneImportPending)
494
+ throw new Error("Wait for the scene to finish opening");
495
+ if (this._models.size + 1 > this.limits.max_archive_entries || [...this._models.values()].reduce(
496
+ (n, m) => n + (m.geometry?.triangles || 0),
497
+ source.geometry.triangles
498
+ ) > this.limits.max_triangles)
499
+ throw new Error("Scene model or triangle limit exceeded");
500
+ assertMemory(
501
+ this.limits,
502
+ this._occupiedMemory() + geometryMemory(source.geometry),
503
+ "duplicate"
504
+ );
505
+ const copy = {};
506
+ for (const key of [
507
+ "filename",
508
+ "orig_filename",
509
+ "local_file",
510
+ "x",
511
+ "y",
512
+ "z",
513
+ "rotationx",
514
+ "rotationy",
515
+ "rotationz",
516
+ "opacity",
517
+ "units",
518
+ "display",
519
+ "view_edges",
520
+ "smooth_shading",
521
+ "color"
522
+ ])
523
+ copy[key] = source[key];
524
+ copy.id = this.max_model_id + 1;
525
+ if (!Number.isSafeInteger(copy.id))
526
+ throw new Error("Model ID limit exceeded");
527
+ copy.display_name = (source.display_name || source.orig_filename || source.filename || "Model").split(/[\\/]/).pop() + " \u2014 copy";
528
+ copy.center = [...source.center];
529
+ copy.unitScale = 1;
530
+ axes.forEach((a, i) => copy["scale" + a] = source.scaleVector[i]);
531
+ Object.defineProperty(copy, "scaleVector", {
532
+ get: () => axes.map((a) => copy["scale" + a] * copy.unitScale)
533
+ });
534
+ copy.geometry = { ...source.geometry };
535
+ copy.section = source.section ? { ...source.section } : null;
536
+ copy.size_state = structuredClone(sizeState(source));
537
+ copy.size_state.unit_factor = (copy.size_state.unit_factor ?? 1) * source.unitScale;
538
+ const saved = savePlayback(source);
539
+ this.max_model_id = copy.id;
540
+ this._models.set(copy.id, copy);
541
+ this.models_ref[copy.id] = this.models.length;
542
+ this.models.push(copy);
543
+ this.models_count = this._models.size;
544
+ if (saved)
545
+ this._startAnimation(copy, saved.animation, {
546
+ ...saved.animation_state,
547
+ paused: true
548
+ });
549
+ this._invalidate();
550
+ return copy.id;
551
+ }
552
+ var cameraDirections = {
553
+ front: [
554
+ [0, -1, 0],
555
+ [0, 0, 1]
556
+ ],
557
+ back: [
558
+ [0, 1, 0],
559
+ [0, 0, 1]
560
+ ],
561
+ left: [
562
+ [-1, 0, 0],
563
+ [0, 0, 1]
564
+ ],
565
+ right: [
566
+ [1, 0, 0],
567
+ [0, 0, 1]
568
+ ],
569
+ top: [
570
+ [0, 0, 1],
571
+ [0, 1, 0]
572
+ ],
573
+ bottom: [
574
+ [0, 0, -1],
575
+ [0, -1, 0]
576
+ ],
577
+ isometric: [
578
+ [1, -1, 1],
579
+ [0, 0, 1]
580
+ ]
581
+ };
582
+ function cameraView(name) {
583
+ const view = cameraDirections[name];
584
+ if (!view) throw new Error("Unknown camera view");
585
+ this.camera.position = this.camera.target.map((v, i) => v + view[0][i]);
586
+ this.camera.up = [...view[1]];
587
+ this.set_auto_zoom();
588
+ }
589
+ function validateBookmarks(bookmarks) {
590
+ if (!Array.isArray(bookmarks) || bookmarks.length > 32)
591
+ throw new Error("Save up to 32 camera views");
592
+ const names = /* @__PURE__ */ new Set();
593
+ return bookmarks.map((b) => {
594
+ const name = typeof b?.name === "string" ? b.name.trim() : "";
595
+ if (!name || name.length > 80 || /[\x00-\x1f]/.test(name) || names.has(name))
596
+ throw new Error("Use a unique view name of 1\u201380 characters");
597
+ names.add(name);
598
+ const state = {};
599
+ for (const key of ["position", "target", "up"]) {
600
+ if (!b.camera?.[key] || axes.some(
601
+ (a) => !Number.isFinite(b.camera[key][a]) || Math.abs(b.camera[key][a]) > 1e15
602
+ ))
603
+ throw new Error("Invalid saved camera");
604
+ state[key] = Object.fromEntries(axes.map((a) => [a, b.camera[key][a]]));
605
+ }
606
+ const d = axes.map((a) => state.position[a] - state.target[a]), u = axes.map((a) => state.up[a]);
607
+ if (Math.hypot(
608
+ d[1] * u[2] - d[2] * u[1],
609
+ d[2] * u[0] - d[0] * u[2],
610
+ d[0] * u[1] - d[1] * u[0]
611
+ ) < 1e-12)
612
+ throw new Error("Degenerate saved camera");
613
+ return { name, camera: state };
614
+ });
615
+ }
616
+ function mergeBookmarks(existing, incoming) {
617
+ const result = validateBookmarks(existing), names = new Set(result.map((b) => b.name));
618
+ for (const b of validateBookmarks(incoming)) {
619
+ let name = b.name, n = 2;
620
+ while (names.has(name)) name = b.name.slice(0, 70) + ` (${n++})`;
621
+ names.add(name);
622
+ result.push({ ...b, name });
623
+ }
624
+ return validateBookmarks(result);
625
+ }
626
+ async function measureModel(id, options = {}) {
627
+ await this.when_ready;
628
+ const m = this._model(id), g = m.geometry;
629
+ if (!g || m._sceneImportPending)
630
+ throw new Error("Wait for the model to finish opening");
631
+ const scale = m.scaleVector, key = scale.join(","), cached = m._measurements;
632
+ if (options.signal?.aborted)
633
+ throw new DOMException("Measurement cancelled", "AbortError");
634
+ if (cached?.positions === g.positions && cached.key === key)
635
+ return { ...cached.result };
636
+ const working = printMemory(g.triangles), occupied = this._occupiedMemory();
637
+ assertMemory(this.limits, occupied + working, "measurements");
638
+ const release = this._reserveMemory(
639
+ m,
640
+ working + geometryMemory(g),
641
+ "measurements"
642
+ );
643
+ const controller = new AbortController(), cancel = () => controller.abort();
644
+ this._aborters.add(controller);
645
+ (m._measureJobs ??= /* @__PURE__ */ new Set()).add(controller);
646
+ options.signal?.addEventListener("abort", cancel, { once: true });
647
+ let job, callbackError;
648
+ const check = () => {
649
+ if (controller.signal.aborted || this.killsign)
650
+ throw new DOMException("Measurement cancelled", "AbortError");
651
+ if (this._models.get(id) !== m || m.geometry.positions !== g.positions || m.scaleVector.join(",") !== key)
652
+ throw new Error("Model size changed. Calculate again.");
653
+ };
654
+ try {
655
+ const positions = new Float32Array(g.positions.length);
656
+ for (let start = 0; start < positions.length; start += 262144) {
657
+ check();
658
+ positions.set(g.positions.subarray(start, start + 262144), start);
659
+ if (start + 262144 < positions.length)
660
+ await new Promise((r) => setTimeout(r, 0));
661
+ }
662
+ job = this._pool.run(
663
+ {
664
+ kind: "measure",
665
+ positions,
666
+ scale,
667
+ area: g.area,
668
+ max_time_ms: options.max_time_ms ?? 3e4,
669
+ limits: {
670
+ ...this.limits,
671
+ max_memory_bytes: this.limits.max_memory_bytes - occupied
672
+ }
673
+ },
674
+ (progress) => {
675
+ try {
676
+ options.on_progress?.(progress);
677
+ } catch (error) {
678
+ callbackError = error;
679
+ controller.abort();
680
+ }
681
+ },
682
+ [positions.buffer]
683
+ );
684
+ controller.signal.addEventListener("abort", () => job.cancel(), {
685
+ once: true
686
+ });
687
+ check();
688
+ let result;
689
+ try {
690
+ result = await job;
691
+ } catch (error) {
692
+ throw callbackError || error;
693
+ }
694
+ check();
695
+ m._measurements = { positions: g.positions, key, result };
696
+ return { ...result };
697
+ } finally {
698
+ job?.cancel?.();
699
+ release();
700
+ this._aborters.delete(controller);
701
+ m._measureJobs.delete(controller);
702
+ options.signal?.removeEventListener("abort", cancel);
186
703
  }
187
- return true;
188
- }
189
- function rayTriangle(o, d, p, i) {
190
- 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);
191
- const h = cross(d, c), det = dot(b, h);
192
- if (Math.abs(det) < 1e-12) return Infinity;
193
- const s = sub(o, a), u = dot(s, h) / det;
194
- if (u < 0 || u > 1) return Infinity;
195
- const q = cross(s, b), v = dot(d, q) / det;
196
- if (v < 0 || u + v > 1) return Infinity;
197
- const t = dot(c, q) / det;
198
- return t >= 0 ? t : Infinity;
199
704
  }
200
705
 
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;
706
+ // src/appearance.js
707
+ var DISPLAYS = [
708
+ "flat",
709
+ "smooth",
710
+ "wireframe",
711
+ "edges",
712
+ "mesh",
713
+ "xray",
714
+ "studio",
715
+ "orientation"
716
+ ];
717
+ function validateView(display, smooth) {
718
+ if (!DISPLAYS.includes(display)) throw new Error("Invalid display mode");
719
+ if (typeof smooth !== "boolean")
720
+ throw new Error("smooth_shading must be a boolean");
721
+ return { display, smooth_shading: display === "smooth" || smooth };
207
722
  }
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
- );
723
+ function viewFeatures(view) {
724
+ return {
725
+ smooth: (view.smooth_shading || view.display === "smooth") && view.display !== "wireframe",
726
+ edges: view.view_edges || view.display === "edges",
727
+ wire: view.display === "wireframe",
728
+ edgeAngle: view.display === "edges" ? 30 : 1
729
+ };
213
730
  }
214
- function derivedMemory(triangles, features) {
215
- return MEMORY_OVERHEAD + triangles * (TRIANGLE_MEMORY + (features.smooth ? 1536 : 0) + (features.edges || features.wire ? 2048 : 0));
731
+ function validateSection(section) {
732
+ if (section == null) return null;
733
+ if (!["x", "y", "z"].includes(section.axis))
734
+ throw new Error("Section axis must be x, y or z");
735
+ if (!Number.isFinite(section.position) || section.position < 0 || section.position > 1)
736
+ throw new Error("Section position must be between 0 and 1");
737
+ if (section.flip !== void 0 && typeof section.flip !== "boolean")
738
+ throw new Error("Section flip must be a boolean");
739
+ return {
740
+ axis: section.axis,
741
+ position: section.position,
742
+ flip: section.flip ?? false
743
+ };
744
+ }
745
+ function sectionPlane(bounds, section) {
746
+ if (!section) return [0, 0, 0, 0];
747
+ const axis = ["x", "y", "z"].indexOf(section.axis), sign = section.flip ? -1 : 1;
748
+ const plane = [0, 0, 0, 0];
749
+ plane[axis] = sign;
750
+ plane[3] = -sign * (bounds.min[axis] + (bounds.max[axis] - bounds.min[axis]) * section.position);
751
+ return plane;
216
752
  }
217
753
 
218
754
  // src/geometry.js
@@ -509,6 +1045,7 @@ var Renderer = class {
509
1045
  this.canvas = canvas;
510
1046
  this.domElement = canvas;
511
1047
  this.resources = /* @__PURE__ */ new Map();
1048
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
512
1049
  this.frames = 0;
513
1050
  this.lost = false;
514
1051
  this.gl = canvas.getContext("webgl2", {
@@ -525,6 +1062,7 @@ var Renderer = class {
525
1062
  e.preventDefault();
526
1063
  this.lost = true;
527
1064
  this.resources.clear();
1065
+ this.sharedResources = /* @__PURE__ */ new WeakMap();
528
1066
  this.gridResource = null;
529
1067
  this.infiniteGrid = null;
530
1068
  this.selectionResource = null;
@@ -632,7 +1170,12 @@ var Renderer = class {
632
1170
  if (this.diagnostic?.model === model) this.setDiagnostic(null);
633
1171
  const resources = this.resources.get(model);
634
1172
  if (resources)
635
- for (const resource of Object.values(resources)) this.free(resource);
1173
+ for (const resource of Object.values(resources)) {
1174
+ if (--resource.users === 0) {
1175
+ this.sharedResources.get(resource.source)?.delete(resource);
1176
+ this.free(resource);
1177
+ }
1178
+ }
636
1179
  this.resources.delete(model);
637
1180
  }
638
1181
  clear() {
@@ -646,8 +1189,10 @@ var Renderer = class {
646
1189
  }
647
1190
  get liveBuffers() {
648
1191
  let n = (this.gridResource?.buffers.length || 0) + (this.selectionResource?.buffers.length || 0) + (this.diagnosticResource?.buffers.length || 0);
1192
+ const unique = /* @__PURE__ */ new Set();
649
1193
  for (const r of this.resources.values())
650
- for (const b of Object.values(r)) n += b.buffers.length;
1194
+ for (const b of Object.values(r)) unique.add(b);
1195
+ for (const b of unique) n += b.buffers.length;
651
1196
  return n;
652
1197
  }
653
1198
  setDiagnostic(value) {
@@ -713,12 +1258,23 @@ var Renderer = class {
713
1258
  this.resources.set(model, r);
714
1259
  }
715
1260
  if (!r[mode]) {
716
- const g = model.geometry;
717
- r[mode] = mode === "edges" || mode === "wire" ? this.upload(g[mode]) : this.upload(
718
- g.positions,
719
- mode === "smooth" ? g.smoothNormals : g.normals,
720
- g.colors
1261
+ const g = model.geometry, lines = mode === "edges" || mode === "wire", source = lines ? g[mode] : g.positions, normals = lines ? void 0 : mode === "smooth" ? g.smoothNormals : g.normals, colors = lines ? void 0 : g.colors;
1262
+ let entries = this.sharedResources.get(source);
1263
+ if (!entries) this.sharedResources.set(source, entries = /* @__PURE__ */ new Set());
1264
+ let resource = [...entries].find(
1265
+ (r2) => r2.normals === normals && r2.colors === colors
721
1266
  );
1267
+ if (!resource) {
1268
+ resource = Object.assign(this.upload(source, normals, colors), {
1269
+ source,
1270
+ normals,
1271
+ colors,
1272
+ users: 0
1273
+ });
1274
+ entries.add(resource);
1275
+ }
1276
+ resource.users++;
1277
+ r[mode] = resource;
722
1278
  }
723
1279
  return r[mode];
724
1280
  }
@@ -1061,205 +1617,34 @@ var WorkerPool = class {
1061
1617
  return;
1062
1618
  }
1063
1619
  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))]);
1620
+ };
1621
+ job.worker.onerror = (e) => {
1622
+ e.preventDefault();
1623
+ end(
1624
+ new Error(
1625
+ e.message || "Worker failed; check worker URL and Content Security Policy"
1626
+ )
1627
+ );
1628
+ };
1629
+ job.worker.onmessageerror = () => end(new Error("Invalid worker response"));
1630
+ job.timer = setTimeout(
1631
+ () => end(new Error("File processing timed out")),
1632
+ this.timeout
1633
+ );
1634
+ job.worker.postMessage({ id: job.id, task: job.task }, job.transfer);
1635
+ } catch (error) {
1636
+ end(error);
1637
+ }
1209
1638
  }
1210
1639
  }
1211
- let scaleChanged = false;
1212
- for (const [key, value] of updates) {
1213
- scaleChanged ||= isScale(key) && model[key] !== value;
1214
- model[key] = value;
1640
+ clear() {
1641
+ for (const job of [...this.queue, ...this.active]) this.cancel(job);
1215
1642
  }
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;
1643
+ dispose() {
1644
+ this.disposed = true;
1645
+ this.clear();
1253
1646
  }
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
- }
1647
+ };
1263
1648
 
1264
1649
  // src/scene-import.js
1265
1650
  var sceneFormat = (name) => String(name || "").match(/\.(vsb|vsj)(?:[?#]|$)/i)?.[1].toLowerCase() || "";
@@ -1320,8 +1705,8 @@ function importScene(source, options = {}) {
1320
1705
  await this.when_ready;
1321
1706
  check();
1322
1707
  oldCamera = this.get_camera_state();
1323
- const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1324
- if (!["vsb", "vsj"].includes(format))
1708
+ const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1709
+ if (!["vsb", "vsj"].includes(format2))
1325
1710
  throw new Error("Choose VSB or VSJ for a scene");
1326
1711
  let remote, relay;
1327
1712
  if (typeof source === "string") {
@@ -1354,8 +1739,8 @@ function importScene(source, options = {}) {
1354
1739
  source,
1355
1740
  remote,
1356
1741
  relay,
1357
- format,
1358
- name: `scene.${format}`,
1742
+ format: format2,
1743
+ name: `scene.${format2}`,
1359
1744
  limits
1360
1745
  },
1361
1746
  (p) => {
@@ -1376,6 +1761,10 @@ function importScene(source, options = {}) {
1376
1761
  job = null;
1377
1762
  check();
1378
1763
  const { scene, files, base } = documentData;
1764
+ const bookmarks = mergeBookmarks(
1765
+ this.get_camera_bookmarks(),
1766
+ scene.camera_bookmarks || []
1767
+ );
1379
1768
  this._sceneBytes -= held;
1380
1769
  held = Object.values(files || {}).reduce((n, b) => n + b.byteLength, 0) + JSON.stringify(scene).length * 2;
1381
1770
  this._sceneBytes += held;
@@ -1467,6 +1856,8 @@ function importScene(source, options = {}) {
1467
1856
  if (scene.auto_rotate !== void 0)
1468
1857
  this.set_auto_rotate(scene.auto_rotate);
1469
1858
  this._restoreCamera = scene.camera_state || (hadModels ? oldCamera : null);
1859
+ this.camera_bookmarks = bookmarks;
1860
+ if (scene.display_units) this.display_units = scene.display_units;
1470
1861
  committed = true;
1471
1862
  return { model_ids: ids, scene, used_files: [...usedFiles] };
1472
1863
  })().catch((error) => {
@@ -1487,61 +1878,6 @@ function importScene(source, options = {}) {
1487
1878
  return promise;
1488
1879
  }
1489
1880
 
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
1881
  // src/print-controller.js
1546
1882
  var reports = /* @__PURE__ */ new WeakMap();
1547
1883
  function reportCurrent(viewer, report) {
@@ -1639,7 +1975,9 @@ async function checkPrintability(options = {}) {
1639
1975
  rotationy: 0,
1640
1976
  rotationz: 0,
1641
1977
  center: [0, 0, 0],
1642
- scaleVector: [model.unitScale, model.unitScale, model.unitScale]
1978
+ scaleVector: [1, 1, 1].map(
1979
+ () => model.unitScale * (model.size_state?.unit_factor ?? 1)
1980
+ )
1643
1981
  };
1644
1982
  progress({
1645
1983
  phase: "Starting",
@@ -1763,8 +2101,8 @@ function exportFilename(filename, extension) {
1763
2101
  function exportOptions(options = {}) {
1764
2102
  const aliases = { stp: "step", igs: "iges" };
1765
2103
  const requested = String(options.format || "").toLowerCase();
1766
- const format = aliases[requested] || requested;
1767
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2104
+ const format2 = aliases[requested] || requested;
2105
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
1768
2106
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
1769
2107
  const units = options.units ?? "mm";
1770
2108
  if (!["mm", "inch"].includes(units))
@@ -1774,10 +2112,10 @@ function exportOptions(options = {}) {
1774
2112
  throw new Error(`${key} must be a boolean`);
1775
2113
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
1776
2114
  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;
2115
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2116
+ const extension = format2 === "obj" && colors ? "zip" : format2;
1779
2117
  return {
1780
- format,
2118
+ format: format2,
1781
2119
  units,
1782
2120
  colors,
1783
2121
  apply_transforms: options.apply_transforms !== false,
@@ -1970,7 +2308,9 @@ var modelKeys = [
1970
2308
  "section",
1971
2309
  "units",
1972
2310
  "animation",
1973
- "animation_state"
2311
+ "animation_state",
2312
+ "size_state",
2313
+ "display_name"
1974
2314
  ];
1975
2315
  var finite = (v, name) => {
1976
2316
  if (typeof v !== "number" || !Number.isFinite(v))
@@ -2002,7 +2342,7 @@ function modelOptions(input) {
2002
2342
  return out;
2003
2343
  }
2004
2344
  var StlViewer = class _StlViewer {
2005
- static version = "2.0.0-rc.5";
2345
+ static version = "2.0.0-rc.6";
2006
2346
  static workerURL = null;
2007
2347
  constructor(parent, options = {}) {
2008
2348
  if (!parent?.appendChild)
@@ -2031,6 +2371,8 @@ var StlViewer = class _StlViewer {
2031
2371
  this._sessionActive = false;
2032
2372
  this._frame = 0;
2033
2373
  this._lastTime = 0;
2374
+ this.camera_bookmarks = [];
2375
+ this.display_units = "mm";
2034
2376
  this.grid = null;
2035
2377
  this._gridAuto = false;
2036
2378
  this.camera = {
@@ -2367,6 +2709,13 @@ var StlViewer = class _StlViewer {
2367
2709
  finite(m[key], key);
2368
2710
  if (key.startsWith("scale")) positive(m[key], key);
2369
2711
  }
2712
+ if (supplied.size_state)
2713
+ m.size_state = validateSizeState(supplied.size_state);
2714
+ if (supplied.display_name != null) {
2715
+ if (typeof supplied.display_name !== "string" || supplied.display_name.length > 1024)
2716
+ throw new Error("Invalid model name");
2717
+ m.display_name = supplied.display_name;
2718
+ }
2370
2719
  m.opacity = supplied.opacity ?? supplied.mesh?.material?.opacity ?? 1;
2371
2720
  finite(m.opacity, "opacity");
2372
2721
  if (m.opacity < 0 || m.opacity > 1)
@@ -2518,7 +2867,7 @@ var StlViewer = class _StlViewer {
2518
2867
  format: supplied.remote_format,
2519
2868
  relay,
2520
2869
  source: typeof source === "string" ? new URL(source, document.baseURI).href : source,
2521
- name: m.filename,
2870
+ name: m.display_name || m.filename,
2522
2871
  limits,
2523
2872
  features: features.smooth || features.edges || features.wire ? features : null
2524
2873
  },
@@ -2568,6 +2917,7 @@ var StlViewer = class _StlViewer {
2568
2917
  }
2569
2918
  _deleteModel(m) {
2570
2919
  this._stopAnimation(m, "removed");
2920
+ for (const controller of m._measureJobs || []) controller.abort();
2571
2921
  for (const [controller, models] of this._printJobs || [])
2572
2922
  if (models.has(m)) controller.abort();
2573
2923
  this._printCache = null;
@@ -2595,6 +2945,8 @@ var StlViewer = class _StlViewer {
2595
2945
  if (this.killsign) return;
2596
2946
  this._generation++;
2597
2947
  this._printCache = null;
2948
+ this.camera_bookmarks = [];
2949
+ this.display_units = "mm";
2598
2950
  this._pool?.clear();
2599
2951
  for (const c of this._aborters) c.abort();
2600
2952
  this._aborters.clear();
@@ -3034,12 +3386,42 @@ var StlViewer = class _StlViewer {
3034
3386
  type
3035
3387
  );
3036
3388
  }
3037
- get_model_info(id) {
3389
+ get_model_size(id) {
3390
+ return modelSize(this._model(id));
3391
+ }
3392
+ resize_model(id, factors) {
3393
+ return resizeModel.call(this, id, factors);
3394
+ }
3395
+ set_file_units(id, units) {
3396
+ return setFileUnits.call(this, id, units);
3397
+ }
3398
+ reset_model_size(id) {
3399
+ return resetModelSize.call(this, id);
3400
+ }
3401
+ duplicate_model(id) {
3402
+ return duplicateModel.call(this, id);
3403
+ }
3404
+ set_camera_view(name) {
3405
+ return cameraView.call(this, name);
3406
+ }
3407
+ set_display_units(units) {
3408
+ this.display_units = validateUnits(units);
3409
+ }
3410
+ get_camera_bookmarks() {
3411
+ return structuredClone(this.camera_bookmarks || []);
3412
+ }
3413
+ set_camera_bookmarks(bookmarks) {
3414
+ this.camera_bookmarks = validateBookmarks(bookmarks);
3415
+ }
3416
+ measure_model(id, options) {
3417
+ return measureModel.call(this, id, options);
3418
+ }
3419
+ get_model_info(id, options = {}) {
3038
3420
  return this._guard(() => {
3039
3421
  const m = this._model(id), g = m.geometry;
3040
3422
  if (!g) return null;
3041
3423
  const s = m.scaleVector, key = s.join(",");
3042
- if (!m._stats || m._stats.key !== key)
3424
+ if (options.measurements !== false && (!m._stats || m._stats.key !== key))
3043
3425
  m._stats = { key, ...scaledStats(g, s) };
3044
3426
  const factor = m.units === "inch" ? 1 / 25.4 : 1;
3045
3427
  return {
@@ -3055,8 +3437,8 @@ var StlViewer = class _StlViewer {
3055
3437
  section: m.section ? { ...m.section } : null,
3056
3438
  color: m.color ?? (g.colors ? "#ffffff" : "#909090"),
3057
3439
  scale: { x: m.scalex, y: m.scaley, z: m.scalez },
3058
- volume: m._stats.volume * factor ** 3,
3059
- area: m._stats.area * factor ** 2,
3440
+ volume: options.measurements === false ? null : m._stats.volume * factor ** 3,
3441
+ area: options.measurements === false ? null : m._stats.area * factor ** 2,
3060
3442
  triangles: g.triangles,
3061
3443
  bounds: { min: [...g.bounds.min], max: [...g.bounds.max] },
3062
3444
  has_colors: !!g.colors,
@@ -3144,7 +3526,9 @@ var StlViewer = class _StlViewer {
3144
3526
  rotationx: 0,
3145
3527
  rotationy: 0,
3146
3528
  rotationz: 0,
3147
- scaleVector: [m.unitScale, m.unitScale, m.unitScale]
3529
+ scaleVector: [1, 1, 1].map(
3530
+ () => m.unitScale * (m.size_state?.unit_factor ?? 1)
3531
+ )
3148
3532
  };
3149
3533
  const matrix = modelMatrix(source), factor = settings.units === "inch" ? 1 / 25.4 : 1;
3150
3534
  for (let c = 0; c < 4; c++)
@@ -3212,8 +3596,8 @@ var StlViewer = class _StlViewer {
3212
3596
  );
3213
3597
  }
3214
3598
  async export_scene(options = {}) {
3215
- const format = String(options.format || "").toLowerCase();
3216
- if (!["vsb", "vsj"].includes(format))
3599
+ const format2 = String(options.format || "").toLowerCase();
3600
+ if (!["vsb", "vsj"].includes(format2))
3217
3601
  throw new Error("Scene format must be vsb or vsj");
3218
3602
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
3219
3603
  throw new Error("force_basename must be a boolean");
@@ -3236,7 +3620,7 @@ var StlViewer = class _StlViewer {
3236
3620
  if (!models.length) throw new Error("No models to export");
3237
3621
  if (models.some((m) => !m.geometry))
3238
3622
  throw new Error("A model has not finished loading");
3239
- if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3623
+ if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3240
3624
  throw new Error("Scene model count limit exceeded");
3241
3625
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3242
3626
  if (triangles > this.limits.max_triangles)
@@ -3244,19 +3628,19 @@ var StlViewer = class _StlViewer {
3244
3628
  const scene = this.get_vsj(
3245
3629
  true,
3246
3630
  !!options.force_basename,
3247
- format === "vsb"
3631
+ format2 === "vsb"
3248
3632
  );
3249
3633
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3250
3634
  if (manifestBytes > this.limits.max_file_bytes)
3251
3635
  throw new Error("Scene manifest size limit exceeded");
3252
- if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3636
+ if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3253
3637
  throw new Error("Scene archive limit exceeded");
3254
- const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3638
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3255
3639
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3256
3640
  this._exportBytes += working;
3257
3641
  let job, cancel, callbackError;
3258
3642
  try {
3259
- const snapshot = format === "vsb" ? models.map((m, index) => ({
3643
+ const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3260
3644
  id: m.id,
3261
3645
  triangles: m.geometry.triangles,
3262
3646
  positions: m.geometry.positions.slice(),
@@ -3271,7 +3655,7 @@ var StlViewer = class _StlViewer {
3271
3655
  });
3272
3656
  checkAbort();
3273
3657
  let blob;
3274
- if (format === "vsj") {
3658
+ if (format2 === "vsj") {
3275
3659
  blob = new Blob([json], { type: "application/json" });
3276
3660
  options.on_progress?.({
3277
3661
  loaded: models.length,
@@ -3311,8 +3695,8 @@ var StlViewer = class _StlViewer {
3311
3695
  checkAbort();
3312
3696
  return {
3313
3697
  blob,
3314
- format,
3315
- filename: exportFilename(options.filename, format),
3698
+ format: format2,
3699
+ filename: exportFilename(options.filename, format2),
3316
3700
  model_ids: models.map((m) => m.id),
3317
3701
  triangles,
3318
3702
  representation: "scene"
@@ -3334,6 +3718,8 @@ var StlViewer = class _StlViewer {
3334
3718
  canvas_height: this.canvas_height,
3335
3719
  bg_color: this.bg_color,
3336
3720
  camera_state: this.get_camera_state(),
3721
+ camera_bookmarks: this.get_camera_bookmarks(),
3722
+ display_units: this.display_units || "mm",
3337
3723
  auto_rotate: this.auto_rotate,
3338
3724
  mouse_zoom: this.mouse_zoom,
3339
3725
  auto_resize: this.auto_resize,
@@ -3354,6 +3740,11 @@ var StlViewer = class _StlViewer {
3354
3740
  id: for_vsb ? -1 : m.id,
3355
3741
  filename: for_vsb ? `${m.id}.stl` : force_basename ? basename(m.filename) : m.filename,
3356
3742
  units: m.units,
3743
+ size_state: {
3744
+ ...structuredClone(sizeState(m)),
3745
+ unit_factor: for_vsb && !m._animation ? 1 : m.unitScale * (sizeState(m).unit_factor ?? 1)
3746
+ },
3747
+ ...m.display_name ? { display_name: m.display_name } : {},
3357
3748
  display: m.display,
3358
3749
  opacity: m.opacity,
3359
3750
  view_edges: m.view_edges,
@@ -3476,6 +3867,13 @@ var StlViewer = class _StlViewer {
3476
3867
  );
3477
3868
  else if (scene.grid) this.set_grid(true);
3478
3869
  if (scene.camera_state) this._restoreCamera = scene.camera_state;
3870
+ const bookmarks = mergeBookmarks(
3871
+ this.get_camera_bookmarks(),
3872
+ scene.camera_bookmarks || []
3873
+ );
3874
+ const displayUnits = validateUnits(
3875
+ scene.display_units || this.display_units || "mm"
3876
+ );
3479
3877
  const incoming = [];
3480
3878
  try {
3481
3879
  for (const input of scene.models) {
@@ -3523,6 +3921,8 @@ var StlViewer = class _StlViewer {
3523
3921
  this._startAnimation(m, model.animation, model.animation_state);
3524
3922
  m._sceneImportPending = false;
3525
3923
  }
3924
+ this.camera_bookmarks = bookmarks;
3925
+ this.display_units = displayUnits;
3526
3926
  } catch (error) {
3527
3927
  for (const { m } of incoming)
3528
3928
  if (this._models.get(m.id) === m) this.remove_model(m.id);
@@ -3665,6 +4065,299 @@ var StlViewer = class _StlViewer {
3665
4065
  }
3666
4066
  };
3667
4067
 
4068
+ // src/app-tools.js
4069
+ var toolsControls = [
4070
+ "size-details",
4071
+ "size-fields",
4072
+ "size-x",
4073
+ "size-y",
4074
+ "size-z",
4075
+ "size-lock",
4076
+ "size-percent",
4077
+ "size-apply",
4078
+ "size-cancel",
4079
+ "size-reset",
4080
+ "size-error",
4081
+ "file-units",
4082
+ "file-units-note",
4083
+ "display-units",
4084
+ "size-unit-label",
4085
+ "duplicate-model",
4086
+ "camera-preset",
4087
+ "bookmark-name",
4088
+ "bookmark-list",
4089
+ "bookmark-save",
4090
+ "bookmark-restore",
4091
+ "bookmark-rename",
4092
+ "bookmark-delete",
4093
+ "camera-error",
4094
+ "info-area",
4095
+ "info-volume",
4096
+ "measure-model",
4097
+ "measure-cancel",
4098
+ "measure-status"
4099
+ ];
4100
+ var axes2 = ["x", "y", "z"];
4101
+ var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4102
+ function bindAppTools({
4103
+ $,
4104
+ listen,
4105
+ window: window2,
4106
+ getViewer,
4107
+ getState,
4108
+ changeSize,
4109
+ duplicate,
4110
+ refresh,
4111
+ announce
4112
+ }) {
4113
+ let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4114
+ const error = (id, message = "") => {
4115
+ $(id).textContent = message;
4116
+ $(id).hidden = !message;
4117
+ };
4118
+ const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4119
+ const value = (v) => Number(v.toPrecision(10));
4120
+ function syncInfo() {
4121
+ const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4122
+ const s = m?.scaleVector, cached = m?._measurements;
4123
+ const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4124
+ const unit = factor() === 1 ? "mm" : "in";
4125
+ const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4126
+ $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4127
+ $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4128
+ if (!measurement)
4129
+ $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4130
+ $("measure-model").disabled = busy || !m || !!measurement;
4131
+ $("measure-cancel").hidden = !measurement;
4132
+ }
4133
+ function syncBookmarks() {
4134
+ const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4135
+ $("bookmark-list").replaceChildren(
4136
+ ...bookmarks.map((b) => {
4137
+ const option = window2.document.createElement("option");
4138
+ option.value = b.name;
4139
+ option.textContent = b.name;
4140
+ return option;
4141
+ })
4142
+ );
4143
+ if (bookmarks.some((b) => b.name === previous))
4144
+ $("bookmark-list").value = previous;
4145
+ for (const id of [
4146
+ "bookmark-restore",
4147
+ "bookmark-rename",
4148
+ "bookmark-delete",
4149
+ "bookmark-list"
4150
+ ])
4151
+ $(id).disabled = !bookmarks.length;
4152
+ $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4153
+ }
4154
+ function sync() {
4155
+ if (disposed) return;
4156
+ const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4157
+ if (selectedId !== selected) {
4158
+ measurement?.abort();
4159
+ selected = selectedId;
4160
+ }
4161
+ draftKind = null;
4162
+ base = m?.geometry ? v.get_model_size(selectedId) : null;
4163
+ $("size-fields").disabled = busy || !base;
4164
+ $("duplicate-model").disabled = busy || !base;
4165
+ $("display-units").value = v?.display_units || "mm";
4166
+ $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4167
+ $("file-units").value = base?.file_units || "mm";
4168
+ $("file-units").disabled = busy || !base?.editable;
4169
+ $("file-units-note").textContent = base?.editable ? "STL/OBJ have no units. Millimetres are assumed; choose inches to correct the physical size." : "Explicit file units were converted to millimetres on import.";
4170
+ axes2.forEach((a, i) => {
4171
+ $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4172
+ $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4173
+ });
4174
+ const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4175
+ $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4176
+ error("size-error");
4177
+ syncInfo();
4178
+ syncBookmarks();
4179
+ }
4180
+ const attempt = (id, action) => {
4181
+ try {
4182
+ action();
4183
+ error(id);
4184
+ } catch (e) {
4185
+ error(id, e.message);
4186
+ }
4187
+ };
4188
+ axes2.forEach(
4189
+ (a, i) => listen($("size-" + a), "input", () => {
4190
+ if (!base) return;
4191
+ draftKind = "axis";
4192
+ lastAxis = i;
4193
+ const n = $("size-" + a).valueAsNumber;
4194
+ if (!Number.isFinite(n) || n <= 0) return;
4195
+ if ($("size-lock").checked)
4196
+ axes2.forEach((axis, j) => {
4197
+ if (i !== j)
4198
+ $("size-" + axis).value = value(
4199
+ n * base.dimensions_mm[j] / base.dimensions_mm[i]
4200
+ );
4201
+ });
4202
+ $("size-percent").value = "";
4203
+ })
4204
+ );
4205
+ listen($("size-percent"), "input", () => {
4206
+ draftKind = "percent";
4207
+ const n = $("size-percent").valueAsNumber;
4208
+ if (base && Number.isFinite(n) && n > 0)
4209
+ axes2.forEach(
4210
+ (a, i) => $("size-" + a).value = value(
4211
+ base.baseline_mm[i] * n / 100 / factor()
4212
+ )
4213
+ );
4214
+ });
4215
+ listen(
4216
+ $("size-apply"),
4217
+ "click",
4218
+ () => attempt("size-error", () => {
4219
+ if (!base) return;
4220
+ if (!draftKind) return;
4221
+ const current = getViewer().get_model_size(selected).dimensions_mm;
4222
+ let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4223
+ if (draftKind === "percent") {
4224
+ const percent = $("size-percent").valueAsNumber;
4225
+ if (!Number.isFinite(percent) || percent <= 0)
4226
+ throw new Error("Enter a positive scale percentage.");
4227
+ desired = base.baseline_mm.map((v) => v * percent / 100);
4228
+ } else if ($("size-lock").checked) {
4229
+ const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4230
+ desired = base.dimensions_mm.map((v) => v * ratio);
4231
+ }
4232
+ let ratios = desired.map((n, i) => {
4233
+ if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4234
+ throw new Error("Enter positive dimensions for each non-flat axis.");
4235
+ return current[i] ? n / current[i] : 1;
4236
+ });
4237
+ if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4238
+ ratios = ratios.map(() => ratios[0]);
4239
+ changeSize(() => getViewer().resize_model(selected, ratios));
4240
+ announce("Model size updated.");
4241
+ })
4242
+ );
4243
+ listen($("size-cancel"), "click", sync);
4244
+ listen(
4245
+ $("size-reset"),
4246
+ "click",
4247
+ () => attempt("size-error", () => {
4248
+ changeSize(() => getViewer().reset_model_size(selected));
4249
+ announce("Original size restored; file-unit correction retained.");
4250
+ })
4251
+ );
4252
+ listen(
4253
+ $("file-units"),
4254
+ "change",
4255
+ () => attempt("size-error", () => {
4256
+ changeSize(
4257
+ () => getViewer().set_file_units(selected, $("file-units").value)
4258
+ );
4259
+ announce("File units corrected.");
4260
+ })
4261
+ );
4262
+ listen($("display-units"), "change", () => {
4263
+ getViewer()?.set_display_units($("display-units").value);
4264
+ refresh();
4265
+ });
4266
+ listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4267
+ listen(
4268
+ $("camera-preset"),
4269
+ "change",
4270
+ () => attempt("camera-error", () => {
4271
+ if ($("camera-preset").value)
4272
+ getViewer().set_camera_view($("camera-preset").value);
4273
+ announce("Camera view updated.");
4274
+ $("camera-preset").value = "";
4275
+ })
4276
+ );
4277
+ const editBookmarks = (action) => attempt("camera-error", () => {
4278
+ const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4279
+ action(bookmarks, v);
4280
+ v.set_camera_bookmarks(bookmarks);
4281
+ syncBookmarks();
4282
+ });
4283
+ listen(
4284
+ $("bookmark-save"),
4285
+ "click",
4286
+ () => editBookmarks((b, v) => {
4287
+ b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4288
+ })
4289
+ );
4290
+ listen(
4291
+ $("bookmark-rename"),
4292
+ "click",
4293
+ () => editBookmarks((b) => {
4294
+ b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4295
+ })
4296
+ );
4297
+ listen(
4298
+ $("bookmark-delete"),
4299
+ "click",
4300
+ () => editBookmarks((b) => {
4301
+ b.splice(
4302
+ b.findIndex((b2) => b2.name === $("bookmark-list").value),
4303
+ 1
4304
+ );
4305
+ })
4306
+ );
4307
+ listen(
4308
+ $("bookmark-restore"),
4309
+ "click",
4310
+ () => attempt("camera-error", () => {
4311
+ const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4312
+ if (b) {
4313
+ getViewer().set_camera_state(b.camera);
4314
+ announce("Saved view restored.");
4315
+ }
4316
+ })
4317
+ );
4318
+ listen(
4319
+ $("bookmark-list"),
4320
+ "change",
4321
+ () => $("bookmark-name").value = $("bookmark-list").value
4322
+ );
4323
+ listen($("measure-cancel"), "click", () => measurement?.abort());
4324
+ listen($("measure-model"), "click", async () => {
4325
+ const id = selected, v = getViewer(), controller = new window2.AbortController();
4326
+ measurement = controller;
4327
+ syncInfo();
4328
+ $("measure-status").textContent = "Calculating area and checking volume\u2026";
4329
+ try {
4330
+ await v.measure_model(id, {
4331
+ signal: controller.signal,
4332
+ on_progress: (p) => {
4333
+ if (selected === id && !disposed)
4334
+ $("measure-status").textContent = p.phase + "\u2026";
4335
+ }
4336
+ });
4337
+ if (!disposed && selected === id) announce("Measurements ready.");
4338
+ } catch (e) {
4339
+ if (!disposed && selected === id)
4340
+ $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4341
+ } finally {
4342
+ const message = $("measure-status").textContent;
4343
+ if (measurement === controller) measurement = null;
4344
+ if (!disposed) {
4345
+ syncInfo();
4346
+ if (!v._models.get(id)?._measurements && selected === id)
4347
+ $("measure-status").textContent = message;
4348
+ }
4349
+ }
4350
+ });
4351
+ return {
4352
+ sync,
4353
+ syncInfo,
4354
+ dispose() {
4355
+ disposed = true;
4356
+ measurement?.abort();
4357
+ }
4358
+ };
4359
+ }
4360
+
3668
4361
  // src/memory-policy.js
3669
4362
  var GiB = 1024 ** 3;
3670
4363
  var MiB = 1024 ** 2;
@@ -3696,9 +4389,9 @@ async function estimateFile(file) {
3696
4389
  }
3697
4390
 
3698
4391
  // 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]]));
4392
+ var axes3 = ["x", "y", "z"];
4393
+ var vector2 = (p) => axes3.map((a) => p[a]);
4394
+ var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
3702
4395
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
3703
4396
  var norm2 = (a) => {
3704
4397
  const n = Math.hypot(...a) || 1;
@@ -3730,7 +4423,7 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
3730
4423
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
3731
4424
  function modelCenter(model) {
3732
4425
  return object2(
3733
- centerOf(model.bounds).map((v, i) => v + model.position[axes[i]])
4426
+ centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
3734
4427
  );
3735
4428
  }
3736
4429
  function modelRadius(model) {
@@ -3741,7 +4434,7 @@ function modelRadius(model) {
3741
4434
  }
3742
4435
  function enginePosition(model) {
3743
4436
  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]));
4437
+ return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
3745
4438
  }
3746
4439
  function modelBounds(model) {
3747
4440
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -4082,16 +4775,16 @@ function bindAppExport({
4082
4775
  $("save-error").textContent = "";
4083
4776
  };
4084
4777
  function formatChanged() {
4085
- const format = $("save-format").value;
4778
+ const format2 = $("save-format").value;
4086
4779
  if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
4087
4780
  suggestedName();
4088
4781
  }
4089
4782
  if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
4090
4783
  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);
4784
+ $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4785
+ $("save-cad-note").hidden = !["step", "iges"].includes(format2);
4093
4786
  $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
4094
- $("save-submit").textContent = format === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4787
+ $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4095
4788
  sync();
4096
4789
  }
4097
4790
  function suggestedName() {
@@ -4542,7 +5235,7 @@ var animationControls = [
4542
5235
  "animation-status",
4543
5236
  "animation-error"
4544
5237
  ];
4545
- var axes2 = ["x", "y", "z"];
5238
+ var axes4 = ["x", "y", "z"];
4546
5239
  function syncAnimatedRecord(record, model) {
4547
5240
  if (!record || !model?.geometry) return;
4548
5241
  record.bounds = Object.fromEntries(
@@ -4554,11 +5247,11 @@ function syncAnimatedRecord(record, model) {
4554
5247
  ])
4555
5248
  );
4556
5249
  record.rotation = Object.fromEntries(
4557
- axes2.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5250
+ axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
4558
5251
  );
4559
5252
  record.position = { x: 0, y: 0, z: 0 };
4560
5253
  const offset = enginePosition(record);
4561
- for (const a of axes2) record.position[a] = model[a] - offset[a];
5254
+ for (const a of axes4) record.position[a] = model[a] - offset[a];
4562
5255
  }
4563
5256
  function centerAnimationPivot(record, model) {
4564
5257
  syncAnimatedRecord(record, model);
@@ -4571,10 +5264,10 @@ function centerAnimationPivot(record, model) {
4571
5264
  const rotation2 = rotationMatrix(record.rotation);
4572
5265
  const original = record.originalPosition || { x: 0, y: 0, z: 0 };
4573
5266
  record.originalPosition = Object.fromEntries(
4574
- axes2.map((a, i) => [a, original[a] + delta[i]])
5267
+ axes4.map((a, i) => [a, original[a] + delta[i]])
4575
5268
  );
4576
5269
  for (let i = 0; i < 3; i++)
4577
- model[axes2[i]] += delta.reduce(
5270
+ model[axes4[i]] += delta.reduce(
4578
5271
  (sum, v, j) => sum + rotation2[i * 3 + j] * v,
4579
5272
  0
4580
5273
  );
@@ -4583,7 +5276,7 @@ function centerAnimationPivot(record, model) {
4583
5276
  syncAnimatedRecord(record, model);
4584
5277
  }
4585
5278
  function spinDefinition(axis, seconds, direction) {
4586
- if (!axes2.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5279
+ if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
4587
5280
  throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
4588
5281
  return {
4589
5282
  delta: {
@@ -4840,6 +5533,8 @@ function mountViewstlApp(root, options = {}) {
4840
5533
  missing.push(...printControls.filter((id) => !$(id)));
4841
5534
  if (animationControls.some((id) => $(id)))
4842
5535
  missing.push(...animationControls.filter((id) => !$(id)));
5536
+ if (toolsControls.some((id) => $(id)))
5537
+ missing.push(...toolsControls.filter((id) => !$(id)));
4843
5538
  if (missing.length)
4844
5539
  throw new Error("Missing ViewSTL controls: " + missing.join(", "));
4845
5540
  const abort = new window2.AbortController();
@@ -4868,6 +5563,7 @@ function mountViewstlApp(root, options = {}) {
4868
5563
  let exportUI;
4869
5564
  let printUI;
4870
5565
  let animationUI;
5566
+ let toolsUI;
4871
5567
  let sceneImport;
4872
5568
  let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, transformTool = "move", modelMover, dragArrangement, background = "transparent";
4873
5569
  const viewNames = {
@@ -4916,6 +5612,8 @@ function mountViewstlApp(root, options = {}) {
4916
5612
  $(name + "-panel").hidden = !open;
4917
5613
  $(name + "-button").setAttribute("aria-expanded", String(open));
4918
5614
  syncSelection();
5615
+ if (open && name === "info") populateInfo();
5616
+ if (open && (name === "scene" || name === "view")) toolsUI?.sync();
4919
5617
  }
4920
5618
  function fileSize(size) {
4921
5619
  return size == null ? "Sample model" : size < 1024 ** 2 ? `${formatNumber.format(size / 1024)} KiB` : size < 1024 ** 3 ? `${formatNumber.format(size / 1024 ** 2)} MiB` : `${formatNumber.format(size / 1024 ** 3)} GiB`;
@@ -4942,12 +5640,13 @@ function mountViewstlApp(root, options = {}) {
4942
5640
  }
4943
5641
  function syncControls() {
4944
5642
  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";
5643
+ const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
4946
5644
  for (const input of modeInputs) {
4947
5645
  input.checked = input.value === mode;
4948
5646
  input.disabled = busy;
4949
5647
  }
4950
- $("view-label").textContent = viewNames[mode];
5648
+ $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5649
+ $("view-button").title = "View \xB7 " + viewNames[mode];
4951
5650
  $("smooth-shading").checked = info?.smooth_shading || false;
4952
5651
  $("smooth-shading").disabled = busy || mode === "wireframe";
4953
5652
  $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
@@ -4971,14 +5670,17 @@ function mountViewstlApp(root, options = {}) {
4971
5670
  exportUI?.sync();
4972
5671
  printUI?.sync();
4973
5672
  animationUI?.sync();
5673
+ toolsUI?.sync();
4974
5674
  }
4975
5675
  function populateInfo() {
4976
5676
  if (modelID === null) return;
4977
- const info = viewer.get_model_info(modelID), record = records.get(modelID), units = record.units === "mm" ? " mm" : "";
5677
+ const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
4978
5678
  $("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;
5679
+ $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
4980
5680
  $("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.";
5681
+ const size = viewer.get_model_size(modelID);
5682
+ $("unit-note").textContent = size.editable ? "File coordinates interpreted as " + (size.file_units === "inch" ? "inches." : "millimetres (assumed).") + " Change file units in Models \u2192 Size & units." : "Explicit file units were converted to millimetres on import.";
5683
+ toolsUI?.syncInfo();
4982
5684
  }
4983
5685
  function renderList() {
4984
5686
  $("model-list").replaceChildren(
@@ -5080,7 +5782,7 @@ function mountViewstlApp(root, options = {}) {
5080
5782
  input.removeAttribute("aria-invalid");
5081
5783
  }
5082
5784
  $("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.";
5785
+ $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
5084
5786
  $("move-model-button").disabled = !record || busy;
5085
5787
  $("reset-position").disabled = !record;
5086
5788
  syncRotation(preserveFocus);
@@ -5163,11 +5865,11 @@ function mountViewstlApp(root, options = {}) {
5163
5865
  $("model-tool").value = transformTool;
5164
5866
  $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
5165
5867
  }
5166
- function setMoveMode(enabled, tool = transformTool) {
5868
+ function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
5167
5869
  modelMover?.setEnabled(false);
5168
5870
  transformTool = tool;
5169
5871
  moveMode = !!enabled && modelID !== null && !busy;
5170
- if (moveMode) prepareManual(modelID);
5872
+ if (moveMode && !retainPaused) prepareManual(modelID);
5171
5873
  modelMover?.setEnabled(moveMode);
5172
5874
  syncTool();
5173
5875
  $("move-mode").hidden = !moveMode;
@@ -5344,6 +6046,7 @@ function mountViewstlApp(root, options = {}) {
5344
6046
  function refreshAnimatedPose() {
5345
6047
  if (disposed || modelID === null) return;
5346
6048
  syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6049
+ if (!$("info-panel").hidden) populateInfo();
5347
6050
  if (!$("scene-panel").hidden && $("position-details").open)
5348
6051
  syncPosition(true);
5349
6052
  }
@@ -5468,7 +6171,7 @@ function mountViewstlApp(root, options = {}) {
5468
6171
  if (queue.length && !draining) drainPromise = drainQueue();
5469
6172
  return drainPromise || Promise.resolve();
5470
6173
  }
5471
- function openURL(value, format = "") {
6174
+ function openURL(value, format2 = "") {
5472
6175
  assertActive();
5473
6176
  if (!options.url_import_endpoint)
5474
6177
  throw new Error("URL imports are not configured.");
@@ -5477,7 +6180,7 @@ function mountViewstlApp(root, options = {}) {
5477
6180
  throw new Error(
5478
6181
  "Use a public HTTP or HTTPS file URL without login credentials."
5479
6182
  );
5480
- if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
6183
+ if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
5481
6184
  throw new Error("Choose a supported 3D format.");
5482
6185
  url.hash = "";
5483
6186
  const name = decodeURIComponent(
@@ -5486,9 +6189,9 @@ function mountViewstlApp(root, options = {}) {
5486
6189
  queue.push({
5487
6190
  name,
5488
6191
  remote: url.href,
5489
- format,
6192
+ format: format2,
5490
6193
  size: 0,
5491
- scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6194
+ scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
5492
6195
  });
5493
6196
  if (!draining) drainPromise = drainQueue();
5494
6197
  return drainPromise || Promise.resolve();
@@ -5557,7 +6260,7 @@ function mountViewstlApp(root, options = {}) {
5557
6260
  await engine.when_idle();
5558
6261
  if (token !== generation) continue;
5559
6262
  loadingID = null;
5560
- const loaded = lastLoadedID === id ? engine.get_model_info(id) : null;
6263
+ const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
5561
6264
  if (!loaded) {
5562
6265
  announce(source.name + " could not be opened.");
5563
6266
  continue;
@@ -5649,7 +6352,7 @@ function mountViewstlApp(root, options = {}) {
5649
6352
  );
5650
6353
  const record = {
5651
6354
  id,
5652
- name: String(m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6355
+ name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
5653
6356
  size: g.file_bytes ?? m.local_file?.size ?? null,
5654
6357
  units: g.units || m.units,
5655
6358
  bounds,
@@ -5978,6 +6681,44 @@ function mountViewstlApp(root, options = {}) {
5978
6681
  refreshPose: refreshAnimatedPose,
5979
6682
  announce
5980
6683
  });
6684
+ if ($("size-details"))
6685
+ toolsUI = bindAppTools({
6686
+ $,
6687
+ listen,
6688
+ window: window2,
6689
+ getViewer: () => viewer,
6690
+ getState: () => ({ selectedId: modelID, busy }),
6691
+ refresh: renderScene,
6692
+ announce,
6693
+ changeSize(operation) {
6694
+ setMoveMode(false);
6695
+ const record = records.get(modelID);
6696
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6697
+ operation();
6698
+ syncAnimatedRecord(record, viewer._models.get(modelID));
6699
+ $("scene-arrangement").value = "custom";
6700
+ renderScene();
6701
+ },
6702
+ duplicate() {
6703
+ const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6704
+ const record = {
6705
+ ...original,
6706
+ id,
6707
+ name: m.display_name,
6708
+ position: { ...original.position },
6709
+ rotation: { ...original.rotation }
6710
+ };
6711
+ syncAnimatedRecord(record, m);
6712
+ record.originalPosition = { ...record.position };
6713
+ record.originalRotation = { ...record.rotation };
6714
+ records.set(id, record);
6715
+ nextID = Math.max(nextID, id + 1);
6716
+ $("scene-arrangement").value = "custom";
6717
+ selectModel(id);
6718
+ setMoveMode(true, "move", true);
6719
+ announce("Copy selected at the same position. Drag to move it.");
6720
+ }
6721
+ });
5981
6722
  updateGrid();
5982
6723
  setBusy(false);
5983
6724
  setMoveMode(false);
@@ -6026,6 +6767,7 @@ function mountViewstlApp(root, options = {}) {
6026
6767
  exportUI?.dispose();
6027
6768
  printUI?.dispose();
6028
6769
  animationUI?.dispose();
6770
+ toolsUI?.dispose();
6029
6771
  cancelLoading();
6030
6772
  setMoveMode(false);
6031
6773
  appearanceRequest++;