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

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.
package/build/viewstl.cjs CHANGED
@@ -1,4 +1,4 @@
1
- //=========== Stl Viewer v2.0.0-rc.7, by Omri Rips, Viewstl.com, November 2026 ; admin@viewstl.com ===========
1
+ //=========== Stl Viewer v2.0.0-rc.9, by Omri Rips, Viewstl.com, November 2026 ; admin@viewstl.com ===========
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -21,8 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var commonjs_exports = {};
22
22
  __export(commonjs_exports, {
23
23
  StlViewer: () => StlViewer,
24
- default: () => viewer_default,
25
- mountViewstlApp: () => mountViewstlApp
24
+ ViewerSession: () => ViewerSession,
25
+ default: () => viewer_default
26
26
  });
27
27
  module.exports = __toCommonJS(commonjs_exports);
28
28
 
@@ -1733,8 +1733,8 @@ function importScene(source, options = {}) {
1733
1733
  await this.when_ready;
1734
1734
  check();
1735
1735
  oldCamera = this.get_camera_state();
1736
- const format2 = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1737
- if (!["vsb", "vsj"].includes(format2))
1736
+ const format = options.format || sceneFormat(typeof source === "string" ? source : source.name);
1737
+ if (!["vsb", "vsj"].includes(format))
1738
1738
  throw new Error("Choose VSB or VSJ for a scene");
1739
1739
  let remote, relay;
1740
1740
  if (typeof source === "string") {
@@ -1767,8 +1767,8 @@ function importScene(source, options = {}) {
1767
1767
  source,
1768
1768
  remote,
1769
1769
  relay,
1770
- format: format2,
1771
- name: `scene.${format2}`,
1770
+ format,
1771
+ name: `scene.${format}`,
1772
1772
  limits
1773
1773
  },
1774
1774
  (p) => {
@@ -2129,8 +2129,8 @@ function exportFilename(filename, extension) {
2129
2129
  function exportOptions(options = {}) {
2130
2130
  const aliases = { stp: "step", igs: "iges" };
2131
2131
  const requested = String(options.format || "").toLowerCase();
2132
- const format2 = aliases[requested] || requested;
2133
- if (!["stl", "obj", "3mf", "step", "iges"].includes(format2))
2132
+ const format = aliases[requested] || requested;
2133
+ if (!["stl", "obj", "3mf", "step", "iges"].includes(format))
2134
2134
  throw new Error("Export format must be stl, obj, 3mf, step or iges");
2135
2135
  const units = options.units ?? "mm";
2136
2136
  if (!["mm", "inch"].includes(units))
@@ -2140,10 +2140,10 @@ function exportOptions(options = {}) {
2140
2140
  throw new Error(`${key} must be a boolean`);
2141
2141
  if (options.on_progress !== void 0 && typeof options.on_progress !== "function")
2142
2142
  throw new Error("on_progress must be a function");
2143
- const colors = options.colors !== false && ["obj", "3mf"].includes(format2);
2144
- const extension = format2 === "obj" && colors ? "zip" : format2;
2143
+ const colors = options.colors !== false && ["obj", "3mf"].includes(format);
2144
+ const extension = format === "obj" && colors ? "zip" : format;
2145
2145
  return {
2146
- format: format2,
2146
+ format,
2147
2147
  units,
2148
2148
  colors,
2149
2149
  apply_transforms: options.apply_transforms !== false,
@@ -2370,7 +2370,7 @@ function modelOptions(input) {
2370
2370
  return out;
2371
2371
  }
2372
2372
  var StlViewer = class _StlViewer {
2373
- static version = "2.0.0-rc.7";
2373
+ static version = "2.0.0-rc.9";
2374
2374
  static workerURL = null;
2375
2375
  constructor(parent, options = {}) {
2376
2376
  if (!parent?.appendChild)
@@ -3624,8 +3624,8 @@ var StlViewer = class _StlViewer {
3624
3624
  );
3625
3625
  }
3626
3626
  async export_scene(options = {}) {
3627
- const format2 = String(options.format || "").toLowerCase();
3628
- if (!["vsb", "vsj"].includes(format2))
3627
+ const format = String(options.format || "").toLowerCase();
3628
+ if (!["vsb", "vsj"].includes(format))
3629
3629
  throw new Error("Scene format must be vsb or vsj");
3630
3630
  if (options.force_basename !== void 0 && typeof options.force_basename !== "boolean")
3631
3631
  throw new Error("force_basename must be a boolean");
@@ -3648,7 +3648,7 @@ var StlViewer = class _StlViewer {
3648
3648
  if (!models.length) throw new Error("No models to export");
3649
3649
  if (models.some((m) => !m.geometry))
3650
3650
  throw new Error("A model has not finished loading");
3651
- if (models.length + (format2 === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3651
+ if (models.length + (format === "vsb" ? 1 : 0) > this.limits.max_archive_entries)
3652
3652
  throw new Error("Scene model count limit exceeded");
3653
3653
  const triangles = models.reduce((sum, m) => sum + m.geometry.triangles, 0);
3654
3654
  if (triangles > this.limits.max_triangles)
@@ -3656,19 +3656,19 @@ var StlViewer = class _StlViewer {
3656
3656
  const scene = this.get_vsj(
3657
3657
  true,
3658
3658
  !!options.force_basename,
3659
- format2 === "vsb"
3659
+ format === "vsb"
3660
3660
  );
3661
3661
  const json = JSON.stringify(scene), manifestBytes = new Blob([json]).size;
3662
3662
  if (manifestBytes > this.limits.max_file_bytes)
3663
3663
  throw new Error("Scene manifest size limit exceeded");
3664
- if (format2 === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3664
+ if (format === "vsb" && manifestBytes + models.length * 84 + triangles * 50 > this.limits.max_uncompressed_bytes)
3665
3665
  throw new Error("Scene archive limit exceeded");
3666
- const working = MEMORY_OVERHEAD + json.length * 8 + (format2 === "vsb" ? triangles * 512 : 0);
3666
+ const working = MEMORY_OVERHEAD + json.length * 8 + (format === "vsb" ? triangles * 512 : 0);
3667
3667
  assertMemory(this.limits, this._occupiedMemory() + working, "scene export");
3668
3668
  this._exportBytes += working;
3669
3669
  let job, cancel, callbackError;
3670
3670
  try {
3671
- const snapshot = format2 === "vsb" ? models.map((m, index) => ({
3671
+ const snapshot = format === "vsb" ? models.map((m, index) => ({
3672
3672
  id: m.id,
3673
3673
  triangles: m.geometry.triangles,
3674
3674
  positions: m.geometry.positions.slice(),
@@ -3683,7 +3683,7 @@ var StlViewer = class _StlViewer {
3683
3683
  });
3684
3684
  checkAbort();
3685
3685
  let blob;
3686
- if (format2 === "vsj") {
3686
+ if (format === "vsj") {
3687
3687
  blob = new Blob([json], { type: "application/json" });
3688
3688
  options.on_progress?.({
3689
3689
  loaded: models.length,
@@ -3723,8 +3723,8 @@ var StlViewer = class _StlViewer {
3723
3723
  checkAbort();
3724
3724
  return {
3725
3725
  blob,
3726
- format: format2,
3727
- filename: exportFilename(options.filename, format2),
3726
+ format,
3727
+ filename: exportFilename(options.filename, format),
3728
3728
  model_ids: models.map((m) => m.id),
3729
3729
  triangles,
3730
3730
  representation: "scene"
@@ -4094,299 +4094,6 @@ var StlViewer = class _StlViewer {
4094
4094
  };
4095
4095
  var viewer_default = StlViewer;
4096
4096
 
4097
- // src/app-tools.js
4098
- var toolsControls = [
4099
- "size-details",
4100
- "size-fields",
4101
- "size-x",
4102
- "size-y",
4103
- "size-z",
4104
- "size-lock",
4105
- "size-percent",
4106
- "size-apply",
4107
- "size-cancel",
4108
- "size-reset",
4109
- "size-error",
4110
- "file-units",
4111
- "file-units-note",
4112
- "display-units",
4113
- "size-unit-label",
4114
- "duplicate-model",
4115
- "camera-preset",
4116
- "bookmark-name",
4117
- "bookmark-list",
4118
- "bookmark-save",
4119
- "bookmark-restore",
4120
- "bookmark-rename",
4121
- "bookmark-delete",
4122
- "camera-error",
4123
- "info-area",
4124
- "info-volume",
4125
- "measure-model",
4126
- "measure-cancel",
4127
- "measure-status"
4128
- ];
4129
- var axes2 = ["x", "y", "z"];
4130
- var format = new Intl.NumberFormat(void 0, { maximumSignificantDigits: 7 });
4131
- function bindAppTools({
4132
- $,
4133
- listen,
4134
- window: window2,
4135
- getViewer,
4136
- getState,
4137
- changeSize,
4138
- duplicate,
4139
- refresh,
4140
- announce
4141
- }) {
4142
- let draftKind = null, lastAxis = 0, base = null, selected = null, measurement = null, disposed = false;
4143
- const error = (id, message = "") => {
4144
- $(id).textContent = message;
4145
- $(id).hidden = !message;
4146
- };
4147
- const factor = () => getViewer()?.display_units === "inch" ? 25.4 : 1;
4148
- const value = (v) => Number(v.toPrecision(10));
4149
- function syncInfo() {
4150
- const v = getViewer(), { selectedId, busy } = getState(), m = v?._models.get(selectedId);
4151
- const s = m?.scaleVector, cached = m?._measurements;
4152
- const valid = cached && cached.positions === m.geometry.positions && cached.key === s.join(",");
4153
- const unit = factor() === 1 ? "mm" : "in";
4154
- const area = valid ? cached.result.area_mm2 : s && s[0] === s[1] && s[1] === s[2] ? m.geometry.area * s[0] ** 2 : null;
4155
- $("info-area").textContent = area == null ? "Calculate" : format.format(area / factor() ** 2) + " " + unit + "\xB2";
4156
- $("info-volume").textContent = valid ? cached.result.volume_valid ? format.format(cached.result.volume_mm3 / factor() ** 3) + " " + unit + "\xB3" : "Unavailable" : "Not checked";
4157
- if (!measurement)
4158
- $("measure-status").textContent = valid ? cached.result.volume_reason : "Calculate to check enclosed volume. Runs on your device.";
4159
- $("measure-model").disabled = busy || !m || !!measurement;
4160
- $("measure-cancel").hidden = !measurement;
4161
- }
4162
- function syncBookmarks() {
4163
- const v = getViewer(), previous = $("bookmark-list").value, bookmarks = v?.get_camera_bookmarks() || [];
4164
- $("bookmark-list").replaceChildren(
4165
- ...bookmarks.map((b) => {
4166
- const option = window2.document.createElement("option");
4167
- option.value = b.name;
4168
- option.textContent = b.name;
4169
- return option;
4170
- })
4171
- );
4172
- if (bookmarks.some((b) => b.name === previous))
4173
- $("bookmark-list").value = previous;
4174
- for (const id of [
4175
- "bookmark-restore",
4176
- "bookmark-rename",
4177
- "bookmark-delete",
4178
- "bookmark-list"
4179
- ])
4180
- $(id).disabled = !bookmarks.length;
4181
- $("bookmark-save").disabled = !v || bookmarks.length >= 32;
4182
- }
4183
- function sync() {
4184
- if (disposed) return;
4185
- const { selectedId, busy } = getState(), v = getViewer(), m = v?._models.get(selectedId);
4186
- if (selectedId !== selected) {
4187
- measurement?.abort();
4188
- selected = selectedId;
4189
- }
4190
- draftKind = null;
4191
- base = m?.geometry ? v.get_model_size(selectedId) : null;
4192
- $("size-fields").disabled = busy || !base;
4193
- $("duplicate-model").disabled = busy || !base;
4194
- $("display-units").value = v?.display_units || "mm";
4195
- $("size-unit-label").textContent = factor() === 1 ? "Dimensions in mm" : "Dimensions in inches";
4196
- $("file-units").value = base?.file_units || "mm";
4197
- $("file-units").disabled = busy || !base?.editable;
4198
- $("file-units-note").textContent = base?.editable ? "STL/OBJ have no units. Millimetres are assumed; choose inches to correct the physical size." : "Explicit file units were converted to millimetres on import.";
4199
- axes2.forEach((a, i) => {
4200
- $("size-" + a).value = base ? value(base.dimensions_mm[i] / factor()) : "";
4201
- $("size-" + a).disabled = !base || base.dimensions_mm[i] === 0;
4202
- });
4203
- const ratios = base?.baseline_mm.map((v2, i) => v2 ? base.dimensions_mm[i] / v2 : null).filter((v2) => v2 != null) || [];
4204
- $("size-percent").value = ratios.length && ratios.every((v2) => Math.abs(v2 / ratios[0] - 1) < 1e-8) ? value(ratios[0] * 100) : "";
4205
- error("size-error");
4206
- syncInfo();
4207
- syncBookmarks();
4208
- }
4209
- const attempt = (id, action) => {
4210
- try {
4211
- action();
4212
- error(id);
4213
- } catch (e) {
4214
- error(id, e.message);
4215
- }
4216
- };
4217
- axes2.forEach(
4218
- (a, i) => listen($("size-" + a), "input", () => {
4219
- if (!base) return;
4220
- draftKind = "axis";
4221
- lastAxis = i;
4222
- const n = $("size-" + a).valueAsNumber;
4223
- if (!Number.isFinite(n) || n <= 0) return;
4224
- if ($("size-lock").checked)
4225
- axes2.forEach((axis, j) => {
4226
- if (i !== j)
4227
- $("size-" + axis).value = value(
4228
- n * base.dimensions_mm[j] / base.dimensions_mm[i]
4229
- );
4230
- });
4231
- $("size-percent").value = "";
4232
- })
4233
- );
4234
- listen($("size-percent"), "input", () => {
4235
- draftKind = "percent";
4236
- const n = $("size-percent").valueAsNumber;
4237
- if (base && Number.isFinite(n) && n > 0)
4238
- axes2.forEach(
4239
- (a, i) => $("size-" + a).value = value(
4240
- base.baseline_mm[i] * n / 100 / factor()
4241
- )
4242
- );
4243
- });
4244
- listen(
4245
- $("size-apply"),
4246
- "click",
4247
- () => attempt("size-error", () => {
4248
- if (!base) return;
4249
- if (!draftKind) return;
4250
- const current = getViewer().get_model_size(selected).dimensions_mm;
4251
- let desired = axes2.map((a) => $("size-" + a).valueAsNumber * factor());
4252
- if (draftKind === "percent") {
4253
- const percent = $("size-percent").valueAsNumber;
4254
- if (!Number.isFinite(percent) || percent <= 0)
4255
- throw new Error("Enter a positive scale percentage.");
4256
- desired = base.baseline_mm.map((v) => v * percent / 100);
4257
- } else if ($("size-lock").checked) {
4258
- const ratio = desired[lastAxis] / base.dimensions_mm[lastAxis];
4259
- desired = base.dimensions_mm.map((v) => v * ratio);
4260
- }
4261
- let ratios = desired.map((n, i) => {
4262
- if (!Number.isFinite(n) || n < 0 || current[i] > 0 && n === 0)
4263
- throw new Error("Enter positive dimensions for each non-flat axis.");
4264
- return current[i] ? n / current[i] : 1;
4265
- });
4266
- if (ratios.every((v) => Math.abs(v / ratios[0] - 1) < 1e-12))
4267
- ratios = ratios.map(() => ratios[0]);
4268
- changeSize(() => getViewer().resize_model(selected, ratios));
4269
- announce("Model size updated.");
4270
- })
4271
- );
4272
- listen($("size-cancel"), "click", sync);
4273
- listen(
4274
- $("size-reset"),
4275
- "click",
4276
- () => attempt("size-error", () => {
4277
- changeSize(() => getViewer().reset_model_size(selected));
4278
- announce("Original size restored; file-unit correction retained.");
4279
- })
4280
- );
4281
- listen(
4282
- $("file-units"),
4283
- "change",
4284
- () => attempt("size-error", () => {
4285
- changeSize(
4286
- () => getViewer().set_file_units(selected, $("file-units").value)
4287
- );
4288
- announce("File units corrected.");
4289
- })
4290
- );
4291
- listen($("display-units"), "change", () => {
4292
- getViewer()?.set_display_units($("display-units").value);
4293
- refresh();
4294
- });
4295
- listen($("duplicate-model"), "click", () => attempt("size-error", duplicate));
4296
- listen(
4297
- $("camera-preset"),
4298
- "change",
4299
- () => attempt("camera-error", () => {
4300
- if ($("camera-preset").value)
4301
- getViewer().set_camera_view($("camera-preset").value);
4302
- announce("Camera view updated.");
4303
- $("camera-preset").value = "";
4304
- })
4305
- );
4306
- const editBookmarks = (action) => attempt("camera-error", () => {
4307
- const v = getViewer(), bookmarks = v.get_camera_bookmarks();
4308
- action(bookmarks, v);
4309
- v.set_camera_bookmarks(bookmarks);
4310
- syncBookmarks();
4311
- });
4312
- listen(
4313
- $("bookmark-save"),
4314
- "click",
4315
- () => editBookmarks((b, v) => {
4316
- b.push({ name: $("bookmark-name").value, camera: v.get_camera_state() });
4317
- })
4318
- );
4319
- listen(
4320
- $("bookmark-rename"),
4321
- "click",
4322
- () => editBookmarks((b) => {
4323
- b.find((b2) => b2.name === $("bookmark-list").value).name = $("bookmark-name").value;
4324
- })
4325
- );
4326
- listen(
4327
- $("bookmark-delete"),
4328
- "click",
4329
- () => editBookmarks((b) => {
4330
- b.splice(
4331
- b.findIndex((b2) => b2.name === $("bookmark-list").value),
4332
- 1
4333
- );
4334
- })
4335
- );
4336
- listen(
4337
- $("bookmark-restore"),
4338
- "click",
4339
- () => attempt("camera-error", () => {
4340
- const b = getViewer().get_camera_bookmarks().find((b2) => b2.name === $("bookmark-list").value);
4341
- if (b) {
4342
- getViewer().set_camera_state(b.camera);
4343
- announce("Saved view restored.");
4344
- }
4345
- })
4346
- );
4347
- listen(
4348
- $("bookmark-list"),
4349
- "change",
4350
- () => $("bookmark-name").value = $("bookmark-list").value
4351
- );
4352
- listen($("measure-cancel"), "click", () => measurement?.abort());
4353
- listen($("measure-model"), "click", async () => {
4354
- const id = selected, v = getViewer(), controller = new window2.AbortController();
4355
- measurement = controller;
4356
- syncInfo();
4357
- $("measure-status").textContent = "Calculating area and checking volume\u2026";
4358
- try {
4359
- await v.measure_model(id, {
4360
- signal: controller.signal,
4361
- on_progress: (p) => {
4362
- if (selected === id && !disposed)
4363
- $("measure-status").textContent = p.phase + "\u2026";
4364
- }
4365
- });
4366
- if (!disposed && selected === id) announce("Measurements ready.");
4367
- } catch (e) {
4368
- if (!disposed && selected === id)
4369
- $("measure-status").textContent = e.name === "AbortError" ? "Calculation cancelled." : e.message;
4370
- } finally {
4371
- const message = $("measure-status").textContent;
4372
- if (measurement === controller) measurement = null;
4373
- if (!disposed) {
4374
- syncInfo();
4375
- if (!v._models.get(id)?._measurements && selected === id)
4376
- $("measure-status").textContent = message;
4377
- }
4378
- }
4379
- });
4380
- return {
4381
- sync,
4382
- syncInfo,
4383
- dispose() {
4384
- disposed = true;
4385
- measurement?.abort();
4386
- }
4387
- };
4388
- }
4389
-
4390
4097
  // src/memory-policy.js
4391
4098
  var GiB = 1024 ** 3;
4392
4099
  var MiB = 1024 ** 2;
@@ -4418,9 +4125,9 @@ async function estimateFile(file) {
4418
4125
  }
4419
4126
 
4420
4127
  // src/model-transform.js
4421
- var axes3 = ["x", "y", "z"];
4422
- var vector2 = (p) => axes3.map((a) => p[a]);
4423
- var object2 = (p) => Object.fromEntries(axes3.map((a, i) => [a, p[i]]));
4128
+ var axes2 = ["x", "y", "z"];
4129
+ var vector2 = (p) => axes2.map((a) => p[a]);
4130
+ var object2 = (p) => Object.fromEntries(axes2.map((a, i) => [a, p[i]]));
4424
4131
  var dot2 = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
4425
4132
  var norm2 = (a) => {
4426
4133
  const n = Math.hypot(...a) || 1;
@@ -4452,7 +4159,7 @@ function rotationMatrix(degrees = { x: 0, y: 0, z: 0 }) {
4452
4159
  var centerOf = (bounds) => bounds.min.map((v, i) => (v + bounds.max[i]) / 2);
4453
4160
  function modelCenter(model) {
4454
4161
  return object2(
4455
- centerOf(model.bounds).map((v, i) => v + model.position[axes3[i]])
4162
+ centerOf(model.bounds).map((v, i) => v + model.position[axes2[i]])
4456
4163
  );
4457
4164
  }
4458
4165
  function modelRadius(model) {
@@ -4463,7 +4170,7 @@ function modelRadius(model) {
4463
4170
  }
4464
4171
  function enginePosition(model) {
4465
4172
  const c = centerOf(model.bounds), rotated = transform2(rotationMatrix(model.rotation), c);
4466
- return object2(c.map((v, i) => model.position[axes3[i]] + v - rotated[i]));
4173
+ return object2(c.map((v, i) => model.position[axes2[i]] + v - rotated[i]));
4467
4174
  }
4468
4175
  function modelBounds(model) {
4469
4176
  if (!model.rotation || !Object.values(model.rotation).some((v) => v % 360))
@@ -4559,6 +4266,48 @@ function sceneFloor(models, placement) {
4559
4266
  ) : 0;
4560
4267
  }
4561
4268
 
4269
+ // src/model-state.js
4270
+ var axes3 = ["x", "y", "z"];
4271
+ function syncAnimatedRecord(record, model) {
4272
+ if (!record || !model?.geometry) return;
4273
+ record.bounds = Object.fromEntries(
4274
+ ["min", "max"].map((key) => [
4275
+ key,
4276
+ model.geometry.bounds[key].map(
4277
+ (v, i) => (v - model.center[i]) * model.scaleVector[i]
4278
+ )
4279
+ ])
4280
+ );
4281
+ record.rotation = Object.fromEntries(
4282
+ axes3.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
4283
+ );
4284
+ record.position = { x: 0, y: 0, z: 0 };
4285
+ const offset = enginePosition(record);
4286
+ for (const a of axes3) record.position[a] = model[a] - offset[a];
4287
+ }
4288
+ function centerAnimationPivot(record, model) {
4289
+ syncAnimatedRecord(record, model);
4290
+ const center = model.geometry.bounds.min.map(
4291
+ (v, i) => (v + model.geometry.bounds.max[i]) / 2
4292
+ );
4293
+ const delta = center.map(
4294
+ (v, i) => (v - model.center[i]) * model.scaleVector[i]
4295
+ );
4296
+ const rotation2 = rotationMatrix(record.rotation);
4297
+ const original = record.originalPosition || { x: 0, y: 0, z: 0 };
4298
+ record.originalPosition = Object.fromEntries(
4299
+ axes3.map((a, i) => [a, original[a] + delta[i]])
4300
+ );
4301
+ for (let i = 0; i < 3; i++)
4302
+ model[axes3[i]] += delta.reduce(
4303
+ (sum, v, j) => sum + rotation2[i * 3 + j] * v,
4304
+ 0
4305
+ );
4306
+ model.center = center;
4307
+ record.animationPivot = true;
4308
+ syncAnimatedRecord(record, model);
4309
+ }
4310
+
4562
4311
  // src/model-move.js
4563
4312
  var vector3 = (p) => [p.x, p.y, p.z];
4564
4313
  var sub2 = (a, b) => a.map((v, i) => v - b[i]);
@@ -4617,7 +4366,6 @@ function bindModelMove(canvas, {
4617
4366
  if (current.tool === "rotate") onRotate?.(current.id, current.rotation);
4618
4367
  else onMove(current.id, current.position);
4619
4368
  }
4620
- canvas.classList.remove("moving-model");
4621
4369
  if (canvas.hasPointerCapture(current.pointer))
4622
4370
  canvas.releasePointerCapture(current.pointer);
4623
4371
  onEnd?.(current.id, cancel);
@@ -4629,7 +4377,7 @@ function bindModelMove(canvas, {
4629
4377
  blocked.add(e.pointerId);
4630
4378
  return;
4631
4379
  }
4632
- const model = getModel();
4380
+ const model = getModel(e);
4633
4381
  if (!model) return;
4634
4382
  drag = {
4635
4383
  tool: getTool(),
@@ -4654,7 +4402,6 @@ function bindModelMove(canvas, {
4654
4402
  };
4655
4403
  onStart?.(model.id);
4656
4404
  canvas.setPointerCapture(e.pointerId);
4657
- canvas.classList.add("moving-model");
4658
4405
  canvas.focus({ preventScroll: true });
4659
4406
  });
4660
4407
  on("pointermove", (e) => {
@@ -4716,7 +4463,6 @@ function bindModelMove(canvas, {
4716
4463
  end(true);
4717
4464
  blocked.clear();
4718
4465
  enabled = value;
4719
- canvas.classList.toggle("model-move-ready", value);
4720
4466
  },
4721
4467
  dispose() {
4722
4468
  end(true);
@@ -4725,1527 +4471,328 @@ function bindModelMove(canvas, {
4725
4471
  };
4726
4472
  }
4727
4473
 
4728
- // src/drop-source.js
4729
- function dropKind(transfer) {
4730
- const types = Array.from(transfer?.types || [], (type) => type.toLowerCase());
4731
- if (types.includes("files") || transfer?.files?.length) return "files";
4732
- if (types.includes("text/uri-list") || types.includes("text/x-moz-url"))
4733
- return "url";
4734
- if (types.includes("text/plain")) return "text";
4735
- return null;
4736
- }
4737
- function droppedURLs(transfer) {
4738
- if (dropKind(transfer) === "files") return [];
4739
- const uriList = transfer?.getData("text/uri-list")?.trim();
4740
- const mozURL = !uriList && transfer?.getData("text/x-moz-url")?.trim();
4741
- const plain = !uriList && !mozURL && transfer?.getData("text/plain")?.trim();
4742
- let lines;
4743
- if (uriList)
4744
- lines = uriList.split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith("#"));
4745
- else if (mozURL)
4746
- lines = [mozURL.split(/\r?\n/)[0]];
4747
- else if (plain) {
4748
- lines = plain.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
4749
- if (!lines.every((s) => /^https?:\/\/\S+$/i.test(s))) return [];
4750
- } else return [];
4751
- const urls = /* @__PURE__ */ new Set();
4752
- for (const line of lines) {
4753
- let url;
4754
- try {
4755
- url = new URL(line);
4756
- } catch {
4757
- }
4758
- if (!url || !["https:", "http:"].includes(url.protocol) || url.username || url.password)
4759
- throw new Error(
4760
- "Drop a public HTTP or HTTPS file URL without login credentials."
4761
- );
4762
- url.hash = "";
4763
- urls.add(url.href);
4764
- }
4765
- return [...urls];
4766
- }
4767
-
4768
- // src/app-export.js
4769
- var exportControls = [
4770
- "save-button",
4771
- "save-dialog",
4772
- "save-form",
4773
- "save-fields",
4774
- "save-name",
4775
- "save-format",
4776
- "save-scope",
4777
- "save-colors",
4778
- "save-colors-row",
4779
- "save-format-note",
4780
- "save-cad-note",
4781
- "save-progress",
4782
- "save-status",
4783
- "save-error",
4784
- "save-submit",
4785
- "save-cancel",
4786
- "save-close"
4787
- ];
4788
- function bindAppExport({
4789
- $,
4790
- listen,
4791
- window: window2,
4792
- getState,
4793
- getViewer,
4794
- onOpen,
4795
- announce
4796
- }) {
4797
- const dialog = $("save-dialog");
4798
- let job = null, disposed = false, lastSuggestedName = "";
4799
- const state = () => getState();
4800
- const available = () => !disposed && !state().busy && state().models.length > 0;
4801
- const isScene = () => ["vsb", "vsj"].includes($("save-format").value);
4802
- const clearError = () => {
4803
- $("save-error").hidden = true;
4804
- $("save-error").textContent = "";
4805
- };
4806
- function formatChanged() {
4807
- const format2 = $("save-format").value;
4808
- if (!$("save-name").value || $("save-name").value === lastSuggestedName) {
4809
- suggestedName();
4810
- }
4811
- if ($("save-mesh-note")) $("save-mesh-note").hidden = isScene();
4812
- if ($("save-scene-note")) $("save-scene-note").hidden = !isScene();
4813
- $("save-colors-row").hidden = !["obj", "3mf"].includes(format2);
4814
- $("save-cad-note").hidden = !["step", "iges"].includes(format2);
4815
- $("save-format-note").textContent = $("save-format").selectedOptions[0]?.dataset.note || "";
4816
- $("save-submit").textContent = format2 === "obj" && $("save-colors").checked ? "Download ZIP" : "Download file";
4817
- sync();
4818
- }
4819
- function suggestedName() {
4820
- const { models, selectedId } = state();
4821
- $("save-name").value = isScene() ? "viewstl-scene" : (models.find((m) => m.id === selectedId)?.name || "model").replace(
4822
- /\.[^.]+$/,
4823
- ""
4824
- );
4825
- lastSuggestedName = $("save-name").value;
4826
- }
4827
- function sync() {
4828
- $("save-button").disabled = !available() || !!job;
4829
- $("save-fields").disabled = !!job;
4830
- $("save-submit").disabled = !available() || !!job;
4831
- $("save-progress").hidden = !job;
4832
- $("save-cancel").textContent = job ? "Cancel saving" : "Cancel";
4833
- const { models, selectedId } = state();
4834
- const target = isScene() ? `Entire scene \xB7 ${models.length} ${models.length === 1 ? "model" : "models"}` : "Selected model: " + (models.find((m) => m.id === selectedId)?.name || "model");
4835
- if ($("save-scope").textContent !== target)
4836
- $("save-scope").textContent = target;
4837
- }
4838
- function cancel() {
4839
- if (job) {
4840
- const previous = job;
4841
- job = null;
4842
- previous.abort();
4843
- announce("Saving cancelled.");
4844
- }
4845
- $("save-status").textContent = "";
4846
- sync();
4847
- }
4848
- function close() {
4849
- cancel();
4850
- if (dialog.open) dialog.close();
4851
- }
4852
- listen($("save-button"), "click", () => {
4853
- if (!available() || job) return;
4854
- onOpen();
4855
- clearError();
4856
- $("save-status").textContent = "";
4857
- suggestedName();
4858
- formatChanged();
4859
- sync();
4860
- dialog.showModal();
4861
- });
4862
- listen($("save-format"), "change", () => {
4863
- clearError();
4864
- formatChanged();
4865
- });
4866
- listen($("save-colors"), "change", formatChanged);
4867
- listen($("save-cancel"), "click", close);
4868
- listen($("save-close"), "click", close);
4869
- listen(dialog, "cancel", (event) => {
4870
- event.preventDefault();
4871
- close();
4872
- });
4873
- listen(dialog, "close", () => {
4874
- if (!dialog.open) cancel();
4875
- });
4876
- listen($("save-form"), "submit", async (event) => {
4877
- event.preventDefault();
4878
- if (job || !available()) return;
4879
- clearError();
4880
- const { selectedId } = state();
4881
- const controller = new window2.AbortController();
4882
- job = controller;
4883
- $("save-progress").removeAttribute("value");
4884
- $("save-status").textContent = "Preparing your file\u2026";
4885
- sync();
4886
- try {
4887
- const settings = {
4888
- format: $("save-format").value,
4889
- filename: $("save-name").value.trim() || "model",
4890
- // App selection remains meaningful even when its editing frame is hidden.
4891
- model_ids: [selectedId],
4892
- units: "mm",
4893
- apply_transforms: true,
4894
- colors: $("save-colors").checked,
4895
- signal: controller.signal,
4896
- on_progress({ loaded, total, phase }) {
4897
- if (job !== controller || controller.signal.aborted) return;
4898
- const percent = total ? Math.min(100, Math.floor(100 * loaded / total)) : 0;
4899
- $("save-progress").value = percent;
4900
- $("save-status").textContent = phase === "packaging" ? "Packing your scene\u2026" : percent === 100 ? "Finishing your file\u2026" : `Saving\u2026 ${percent}%`;
4901
- }
4902
- };
4903
- const result = isScene() ? await getViewer().download_scene({
4904
- format: settings.format,
4905
- filename: settings.filename,
4906
- signal: settings.signal,
4907
- on_progress: settings.on_progress,
4908
- force_basename: true
4909
- }) : await getViewer().download_models(settings);
4910
- if (job !== controller || disposed) return;
4911
- job = null;
4912
- close();
4913
- announce(result.filename + " ready to download.");
4914
- } catch (error) {
4915
- if (job !== controller || disposed) return;
4916
- job = null;
4917
- $("save-status").textContent = "";
4918
- if (error?.name !== "AbortError") {
4919
- $("save-error").textContent = error?.message || "This file could not be saved. Try another format.";
4920
- $("save-error").hidden = false;
4921
- }
4922
- } finally {
4923
- if (!disposed && (!job || job === controller)) sync();
4924
- }
4925
- });
4926
- formatChanged();
4927
- sync();
4928
- return {
4929
- sync,
4930
- close,
4931
- dispose() {
4932
- close();
4933
- disposed = true;
4934
- }
4935
- };
4936
- }
4937
-
4938
- // src/app-print.js
4939
- var printControls = [
4940
- "print-button",
4941
- "print-panel",
4942
- "print-close",
4943
- "print-form",
4944
- "print-fields",
4945
- "print-scope",
4946
- "print-level",
4947
- "print-units",
4948
- "print-fdm",
4949
- "print-wall",
4950
- "print-angle",
4951
- "print-bed-x",
4952
- "print-bed-y",
4953
- "print-bed-z",
4954
- "print-run",
4955
- "print-cancel",
4956
- "print-status",
4957
- "print-results",
4958
- "print-clear",
4959
- "print-target"
4960
- ];
4961
- function bindAppPrint({
4962
- $,
4963
- listen,
4964
- window: window2,
4965
- getState,
4966
- getViewer,
4967
- onOpen,
4968
- announce
4969
- }) {
4970
- let job = null, report = null, timer = null, disposed = false, scopeKey = "", previousScope = "", unavailable = false;
4971
- const panel = $("print-panel"), document2 = panel.ownerDocument;
4972
- const pausedForCheck = /* @__PURE__ */ new Set();
4973
- const summary = {
4974
- issues_found: "Issues found",
4975
- incomplete: "Check incomplete",
4976
- no_issues_found: "No issues found in completed checks"
4977
- };
4978
- const text = (tag, value) => {
4979
- const el = document2.createElement(tag);
4980
- el.textContent = value;
4981
- return el;
4982
- };
4983
- const stateIds = () => $("print-scope").value === "all" ? getState().models.map((m) => m.id) : [getState().selectedId];
4984
- const key = () => JSON.stringify(stateIds());
4985
- const clear = () => {
4986
- getViewer()?.clear_printability();
4987
- $("print-clear").hidden = true;
4988
- };
4989
- function sync() {
4990
- if (disposed) return;
4991
- const s = getState();
4992
- $("print-button").disabled = s.busy || !s.models.length;
4993
- if (panel.hidden) return;
4994
- for (const id of pausedForCheck)
4995
- if (!s.models.some((m) => m.id === id) || getViewer()?.get_model_animation_state(id)?.status !== "paused")
4996
- pausedForCheck.delete(id);
4997
- if ($("print-resume")) {
4998
- $("print-resume").hidden = !pausedForCheck.size;
4999
- $("print-resume").disabled = !!job || s.busy;
5000
- }
5001
- if ($("print-animation-note"))
5002
- $("print-animation-note").hidden = !pausedForCheck.size;
5003
- const wasUnavailable = unavailable;
5004
- unavailable = s.busy || !s.models.length;
5005
- $("print-run").disabled = !!job || unavailable;
5006
- if (previousScope !== key()) {
5007
- $("print-units").checked = false;
5008
- previousScope = key();
5009
- }
5010
- $("print-target").textContent = $("print-scope").value === "all" ? `All ${s.models.length} models, checked separately` : s.models.find((m) => m.id === s.selectedId)?.name || "No model selected";
5011
- if (job && (unavailable || scopeKey !== key())) cancel();
5012
- if (report && (!getViewer()?.is_printability_current(report) || scopeKey !== key())) {
5013
- report = null;
5014
- $("print-units").checked = false;
5015
- clear();
5016
- $("print-results").replaceChildren();
5017
- $("print-status").textContent = "Model or selection changed. Run the check again.";
5018
- }
5019
- if (unavailable) {
5020
- if (!s.models.length) {
5021
- report = null;
5022
- clear();
5023
- $("print-results").replaceChildren();
5024
- }
5025
- $("print-status").textContent = s.busy ? "Opening models. Checks will be available when loading finishes." : "Open a model to run a print check.";
5026
- } else if (wasUnavailable && !job) {
5027
- $("print-status").textContent = report ? summary[report.summary] : "Ready to check. Choose Run check to analyze the current model selection.";
5028
- }
5029
- }
5030
- function render(value) {
5031
- const elements = [];
5032
- for (const model of value.models) {
5033
- const section = document2.createElement("section");
5034
- section.append(
5035
- text(
5036
- "h3",
5037
- getState().models.find((m) => m.id === model.model_id)?.name || model.name
5038
- )
5039
- );
5040
- section.append(
5041
- text(
5042
- "p",
5043
- `${model.dimensions_mm?.map((v) => Number(v.toPrecision(5))).join(" \xD7 ") || "Unknown size"} mm \xB7 ${(model.elapsed_ms / 1e3).toFixed(2)} s`
5044
- )
5045
- );
5046
- if (model.volume_valid)
5047
- section.append(
5048
- text(
5049
- "p",
5050
- `Enclosed volume at current mm scale: ${Number(model.volume_mm3.toPrecision(6))} mm\xB3`
5051
- )
5052
- );
5053
- for (const c of model.checks) {
5054
- const details = document2.createElement("details");
5055
- details.dataset.check = c.code;
5056
- details.dataset.status = c.status;
5057
- const status = {
5058
- pass: "Passed",
5059
- issue: c.severity === "info" ? "Information" : "Review",
5060
- inconclusive: "Inconclusive",
5061
- not_run: "Not run"
5062
- }[c.status];
5063
- details.append(
5064
- text(
5065
- "summary",
5066
- `${status} \xB7 ${c.label}${c.count ? ` (${c.count})` : ""}${c.estimated ? " \u2014 estimate" : ""}`
5067
- )
5068
- );
5069
- details.append(text("p", c.message));
5070
- if (c.area_mm2 != null)
5071
- details.append(
5072
- text("p", `Area: ${Number(c.area_mm2.toPrecision(5))} mm\xB2`)
5073
- );
5074
- if (c.minimum_sampled_mm != null)
5075
- details.append(
5076
- text(
5077
- "p",
5078
- `Smallest sampled distance: ${Number(c.minimum_sampled_mm.toPrecision(5))} mm`
5079
- )
5080
- );
5081
- if (c.triangle_ids.length || c.edge_vertices.length) {
5082
- const button = text("button", "Show locations");
5083
- button.type = "button";
5084
- button.dataset.issue = c.code;
5085
- button.dataset.model = model.model_id;
5086
- details.append(button);
5087
- details.append(
5088
- text(
5089
- "p",
5090
- "Highlights show a bounded sample, including edges hidden behind surfaces."
5091
- )
5092
- );
5093
- }
5094
- section.append(details);
5095
- }
5096
- elements.push(section);
5097
- }
5098
- $("print-results").replaceChildren(...elements);
5099
- }
5100
- function cancel() {
5101
- job?.abort();
5102
- }
5103
- function close(focus = true) {
5104
- cancel();
5105
- panel.hidden = true;
5106
- $("workspace")?.classList.remove("print-check-open");
5107
- clear();
5108
- window2.clearInterval(timer);
5109
- timer = null;
5110
- $("print-button").setAttribute("aria-expanded", "false");
5111
- if (focus && !disposed)
5112
- ($("print-button").offsetParent && !$("print-button").disabled ? $("print-button") : $("info-button").offsetParent ? $("info-button") : $("sample-button"))?.focus();
5113
- }
5114
- function changed() {
5115
- cancel();
5116
- report = null;
5117
- clear();
5118
- $("print-results").replaceChildren();
5119
- $("print-status").textContent = "Settings changed. Run the check again.";
5120
- sync();
5121
- }
5122
- listen($("print-button"), "click", () => {
5123
- if (!panel.hidden) {
5124
- onOpen();
5125
- sync();
5126
- panel.scrollTop = 0;
5127
- $("print-run").focus();
5128
- return;
5129
- }
5130
- onOpen();
5131
- panel.hidden = false;
5132
- $("workspace")?.classList.add("print-check-open");
5133
- $("print-button").setAttribute("aria-expanded", "true");
5134
- sync();
5135
- timer = window2.setInterval(sync, 400);
5136
- $("print-run").focus();
5137
- });
5138
- listen($("print-close"), "click", () => close());
5139
- listen(panel, "keydown", (e) => {
5140
- if (e.key === "Escape") {
5141
- e.stopPropagation();
5142
- e.preventDefault();
5143
- close();
5144
- }
5145
- });
5146
- listen($("print-fields"), "change", changed);
5147
- listen($("print-clear"), "click", clear);
5148
- listen($("print-cancel"), "click", cancel);
5149
- if ($("print-resume"))
5150
- listen($("print-resume"), "click", () => {
5151
- if (job || getState().busy) return;
5152
- for (const id of pausedForCheck)
5153
- if (getState().models.some((m) => m.id === id) && getViewer().get_model_animation_state(id)?.status === "paused")
5154
- getViewer().resume_model_animation(id);
5155
- pausedForCheck.clear();
5156
- report = null;
5157
- clear();
5158
- $("print-results").replaceChildren();
5159
- $("print-status").textContent = "Animation resumed. Pause and run the check again for the new pose.";
5160
- sync();
5161
- });
5162
- listen($("print-results"), "click", (e) => {
5163
- const b = e.target.closest("button[data-issue]");
5164
- if (!b || !report) return;
5165
- try {
5166
- getViewer().show_printability(report, {
5167
- issue: b.dataset.issue,
5168
- model_id: Number(b.dataset.model)
5169
- });
5170
- $("print-clear").hidden = false;
5171
- } catch (error) {
5172
- $("print-status").textContent = error.message;
5173
- }
5174
- });
5175
- listen($("print-form"), "submit", async (e) => {
5176
- e.preventDefault();
5177
- if (job || getState().busy || !getState().models.length) return;
5178
- const controller = new window2.AbortController();
5179
- job = controller;
5180
- report = null;
5181
- clear();
5182
- $("print-results").replaceChildren();
5183
- $("print-status").textContent = "Checking on your device\u2026";
5184
- $("print-fields").disabled = true;
5185
- $("print-run").disabled = true;
5186
- $("print-cancel").hidden = false;
5187
- try {
5188
- let profile = null;
5189
- if ($("print-fdm").checked) {
5190
- profile = { technology: "fdm" };
5191
- if ($("print-wall").value)
5192
- profile.min_wall_mm = Number($("print-wall").value);
5193
- if ($("print-angle").value)
5194
- profile.overhang_degrees = Number($("print-angle").value);
5195
- const bed = ["x", "y", "z"].map((a) => $("print-bed-" + a).value);
5196
- if (bed.some(Boolean)) {
5197
- if (!bed.every(Boolean))
5198
- throw new Error(
5199
- "Enter all three build-volume dimensions, or leave all three empty."
5200
- );
5201
- profile.build_volume_mm = bed.map(Number);
5202
- }
5203
- }
5204
- scopeKey = key();
5205
- for (const id of stateIds()) {
5206
- if (getViewer().get_model_animation_state(id)?.status === "running") {
5207
- getViewer().pause_model_animation(id);
5208
- pausedForCheck.add(id);
5209
- }
5210
- }
5211
- sync();
5212
- const result = await getViewer().check_printability({
5213
- model_ids: stateIds(),
5214
- level: $("print-level").value,
5215
- units_confirmed: $("print-units").checked,
5216
- profile,
5217
- signal: controller.signal,
5218
- on_progress: (p) => {
5219
- if (job === controller)
5220
- $("print-status").textContent = `${p.phase} \xB7 model ${(p.model_index ?? 0) + 1} of ${p.total_models}`;
5221
- }
5222
- });
5223
- if (disposed || controller.signal.aborted) return;
5224
- report = result;
5225
- render(report);
5226
- $("print-status").textContent = summary[report.summary];
5227
- announce(summary[report.summary]);
5228
- } catch (error) {
5229
- if (!disposed)
5230
- $("print-status").textContent = error.name === "AbortError" ? "Check cancelled. Your model is unchanged." : error.message;
5231
- } finally {
5232
- if (job === controller) job = null;
5233
- if (!disposed) {
5234
- $("print-fields").disabled = false;
5235
- $("print-run").disabled = false;
5236
- $("print-cancel").hidden = true;
5237
- sync();
5238
- }
5239
- }
5240
- });
5241
- sync();
5242
- return {
5243
- sync,
5244
- close,
5245
- dispose() {
5246
- disposed = true;
5247
- close(false);
5248
- report = null;
5249
- pausedForCheck.clear();
5250
- }
5251
- };
5252
- }
5253
-
5254
- // src/app-animation.js
5255
- var animationControls = [
5256
- "animation-details",
5257
- "animation-fields",
5258
- "animation-axis",
5259
- "animation-seconds",
5260
- "animation-direction",
5261
- "animation-play",
5262
- "animation-pause",
5263
- "animation-stop",
5264
- "animation-status",
5265
- "animation-error"
5266
- ];
4474
+ // src/session.js
5267
4475
  var axes4 = ["x", "y", "z"];
5268
- function syncAnimatedRecord(record, model) {
5269
- if (!record || !model?.geometry) return;
5270
- record.bounds = Object.fromEntries(
5271
- ["min", "max"].map((key) => [
5272
- key,
5273
- model.geometry.bounds[key].map(
5274
- (v, i) => (v - model.center[i]) * model.scaleVector[i]
5275
- )
5276
- ])
5277
- );
5278
- record.rotation = Object.fromEntries(
5279
- axes4.map((a) => [a, model["rotation" + a] * 180 / Math.PI % 360])
5280
- );
5281
- record.position = { x: 0, y: 0, z: 0 };
5282
- const offset = enginePosition(record);
5283
- for (const a of axes4) record.position[a] = model[a] - offset[a];
5284
- }
5285
- function centerAnimationPivot(record, model) {
5286
- syncAnimatedRecord(record, model);
5287
- const center = model.geometry.bounds.min.map(
5288
- (v, i) => (v + model.geometry.bounds.max[i]) / 2
5289
- );
5290
- const delta = center.map(
5291
- (v, i) => (v - model.center[i]) * model.scaleVector[i]
5292
- );
5293
- const rotation2 = rotationMatrix(record.rotation);
5294
- const original = record.originalPosition || { x: 0, y: 0, z: 0 };
5295
- record.originalPosition = Object.fromEntries(
5296
- axes4.map((a, i) => [a, original[a] + delta[i]])
5297
- );
5298
- for (let i = 0; i < 3; i++)
5299
- model[axes4[i]] += delta.reduce(
5300
- (sum, v, j) => sum + rotation2[i * 3 + j] * v,
5301
- 0
5302
- );
5303
- model.center = center;
5304
- record.animationPivot = true;
5305
- syncAnimatedRecord(record, model);
5306
- }
5307
- function spinDefinition(axis, seconds, direction) {
5308
- if (!axes4.includes(axis) || !Number.isFinite(seconds) || seconds < 0.1 || seconds > 3600 || !["1", "-1"].includes(direction))
5309
- throw new Error("Choose an axis and a duration from 0.1 to 3,600 seconds.");
5310
- return {
5311
- delta: {
5312
- ["rotation" + axis]: 2 * Math.PI * Number(direction),
5313
- msec: seconds * 1e3,
5314
- loop: true
5315
- }
5316
- };
5317
- }
5318
- function bindAppAnimation({
5319
- $,
5320
- listen,
5321
- window: window2,
5322
- getState,
5323
- getViewer,
5324
- beforePlay,
5325
- refreshPose,
5326
- announce
5327
- }) {
5328
- const settings = /* @__PURE__ */ new Map();
5329
- let selected = null, timer = null, disposed = false;
5330
- const state = () => {
5331
- const s = getState();
5332
- return {
5333
- ...s,
5334
- playback: s.selectedId == null ? null : getViewer()?.get_model_animation_state(s.selectedId)
5335
- };
5336
- };
5337
- function sync() {
5338
- if (disposed) return;
5339
- const s = state(), available = s.models.some((m) => m.id === s.selectedId);
5340
- for (const id of settings.keys())
5341
- if (!s.models.some((m) => m.id === id)) settings.delete(id);
5342
- $("animation-details").hidden = !available;
5343
- $("animation-fields").disabled = s.busy || !available;
5344
- if (selected !== s.selectedId) {
5345
- selected = s.selectedId;
5346
- let value = settings.get(selected);
5347
- if (!value) {
5348
- value = { axis: "z", seconds: 5, direction: "1" };
5349
- const a = getViewer()?._models.get(selected)?.animation;
5350
- const entries = Object.entries(a?.delta || {}).filter(
5351
- ([k, v]) => k.startsWith("rotation") && v !== 0
5352
- );
5353
- if (a?.delta?.loop && !a.exact && entries.length === 1) {
5354
- value = {
5355
- axis: entries[0][0].slice(-1),
5356
- seconds: a.delta.msec * 2 * Math.PI / Math.abs(entries[0][1]) / 1e3,
5357
- direction: entries[0][1] < 0 ? "-1" : "1"
5358
- };
5359
- }
5360
- settings.set(selected, value);
5361
- }
5362
- $("animation-axis").value = value.axis;
5363
- $("animation-seconds").value = value.seconds;
5364
- $("animation-direction").value = value.direction;
5365
- $("animation-error").hidden = true;
5366
- }
5367
- const status = s.playback?.status || "idle", active = ["running", "paused"].includes(status);
5368
- $("animation-play").textContent = active ? "Restart spin" : "Play";
5369
- $("animation-pause").textContent = status === "paused" ? "Resume" : "Pause";
5370
- $("animation-pause").disabled = !active;
5371
- $("animation-stop").disabled = !active;
5372
- const label = {
5373
- idle: "Ready",
5374
- running: "Playing",
5375
- paused: "Paused",
5376
- stopped: "Stopped",
5377
- completed: "Finished"
5378
- }[status];
5379
- const text = `${label}. ${active ? "Stop keeps the current pose." : "Spin the selected model around its center."}`;
5380
- if ($("animation-status").textContent !== text)
5381
- $("animation-status").textContent = text;
5382
- if (status === "running" && !timer)
5383
- timer = window2.setInterval(() => {
5384
- if (!disposed && !window2.document.hidden) refreshPose();
5385
- }, 250);
5386
- if (status !== "running" && timer) {
5387
- window2.clearInterval(timer);
5388
- timer = null;
5389
- }
4476
+ var formats = /\.(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/i;
4477
+ var zero = () => ({ x: 0, y: 0, z: 0 });
4478
+ var failure = (code, message) => Object.assign(new Error(message), { code });
4479
+ var ViewerSession = class {
4480
+ constructor(container, options = {}) {
4481
+ if (options.viewer_options?.models)
4482
+ throw new TypeError("Use session.openFiles/openURL for initial models");
4483
+ if (options.viewer_options?.allow_drag_and_drop)
4484
+ throw new TypeError("Pass dropped files to session.openFiles");
4485
+ this._container = container;
4486
+ this._options = options;
4487
+ this._policy = memoryPolicy(options.device_memory);
4488
+ for (const key of Object.keys(this._policy.limits))
4489
+ if (options.viewer_options?.[key] !== void 0)
4490
+ this._policy.limits[key] = options.viewer_options[key];
4491
+ this._policy.budget = this._policy.limits.max_memory_bytes;
4492
+ this._records = /* @__PURE__ */ new Map();
4493
+ this._queue = [];
4494
+ this._generation = 0;
4495
+ this._nextID = 0;
4496
+ this._loadingID = null;
4497
+ this._viewer = null;
4498
+ this._viewerPromise = null;
4499
+ this._drainPromise = null;
4500
+ this._busy = false;
4501
+ this._disposed = false;
4502
+ this._arrangement = "original";
4503
+ this._grid = { enabled: false, spacing: 1, height: 0 };
4504
+ this._bindings = /* @__PURE__ */ new Set();
5390
4505
  }
5391
- function play(keepPaused = false) {
5392
- const s = state();
5393
- if (s.busy || s.selectedId == null) return;
5394
- try {
5395
- const value = {
5396
- axis: $("animation-axis").value,
5397
- seconds: $("animation-seconds").valueAsNumber,
5398
- direction: $("animation-direction").value
5399
- };
5400
- const definition = spinDefinition(
5401
- value.axis,
5402
- value.seconds,
5403
- value.direction
5404
- );
5405
- beforePlay(s.selectedId);
5406
- getViewer().animate_model(s.selectedId, definition);
5407
- if (keepPaused && s.playback?.status === "paused")
5408
- getViewer().pause_model_animation(s.selectedId);
5409
- settings.set(s.selectedId, value);
5410
- $("animation-error").hidden = true;
5411
- announce(
5412
- keepPaused && s.playback?.status === "paused" ? "Spin updated. Still paused." : "Animation playing."
5413
- );
5414
- sync();
5415
- } catch (error) {
5416
- $("animation-error").textContent = error.message;
5417
- $("animation-error").hidden = false;
5418
- }
4506
+ get viewer() {
4507
+ return this._viewer;
5419
4508
  }
5420
- listen($("animation-play"), "click", () => play());
5421
- listen($("animation-pause"), "click", () => {
5422
- const s = state();
5423
- if (s.busy || s.selectedId == null) return;
5424
- getViewer()[s.playback?.status === "paused" ? "resume_model_animation" : "pause_model_animation"](s.selectedId);
5425
- sync();
5426
- });
5427
- listen($("animation-stop"), "click", () => {
5428
- const s = state();
5429
- if (!s.busy && s.selectedId != null)
5430
- getViewer().animate_model(s.selectedId, null);
5431
- sync();
5432
- });
5433
- for (const id of [
5434
- "animation-axis",
5435
- "animation-seconds",
5436
- "animation-direction"
5437
- ])
5438
- listen($(id), "change", () => {
5439
- if (["running", "paused"].includes(state().playback?.status)) play(true);
5440
- else
5441
- settings.set(selected, {
5442
- axis: $("animation-axis").value,
5443
- seconds: $("animation-seconds").valueAsNumber,
5444
- direction: $("animation-direction").value
5445
- });
5446
- });
5447
- sync();
5448
- return {
5449
- sync,
5450
- dispose() {
5451
- disposed = true;
5452
- window2.clearInterval(timer);
5453
- settings.clear();
5454
- }
5455
- };
5456
- }
5457
-
5458
- // src/app.js
5459
- var mountedRoots = /* @__PURE__ */ new WeakMap();
5460
- var requiredControls = [
5461
- "background-color",
5462
- "background-value",
5463
- "cancel-load",
5464
- "clear-scene",
5465
- "close-colors",
5466
- "close-info",
5467
- "close-model",
5468
- "close-scene",
5469
- "close-section",
5470
- "close-view",
5471
- "color-mode-note",
5472
- "colors-button",
5473
- "colors-panel",
5474
- "depth-controls",
5475
- "depth-model-button",
5476
- "dismiss-notice",
5477
- "drop-message",
5478
- "drop-overlay",
5479
- "empty-state",
5480
- "file-heading",
5481
- "file-input",
5482
- "filename",
5483
- "finish-move",
5484
- "fit-button",
5485
- "grid-button",
5486
- "image-button",
5487
- "info-button",
5488
- "info-dimensions",
5489
- "info-panel",
5490
- "info-size",
5491
- "info-triangles",
5492
- "live-status",
5493
- "loading-label",
5494
- "loading-state",
5495
- "memory-budget",
5496
- "model-color",
5497
- "model-color-value",
5498
- "model-controls",
5499
- "model-list",
5500
- "model-tool",
5501
- "move-controls",
5502
- "move-gesture-message",
5503
- "move-mode",
5504
- "move-mode-name",
5505
- "move-model-button",
5506
- "notice",
5507
- "notice-message",
5508
- "open-label",
5509
- "position-details",
5510
- "position-error",
5511
- "position-units",
5512
- "position-x",
5513
- "position-y",
5514
- "position-z",
5515
- "reset-position",
5516
- "reset-rotation",
5517
- "restore-color",
5518
- "rotate-controls",
5519
- "rotate-model-button",
5520
- "rotation-error",
5521
- "rotation-x",
5522
- "rotation-y",
5523
- "rotation-z",
5524
- "sample-button",
5525
- "scene-arrangement",
5526
- "scene-button",
5527
- "scene-count",
5528
- "scene-panel",
5529
- "section-axis",
5530
- "section-button",
5531
- "section-enabled",
5532
- "section-flip",
5533
- "section-panel",
5534
- "section-position",
5535
- "section-value",
5536
- "smooth-shading",
5537
- "unit-note",
5538
- "view-button",
5539
- "view-description",
5540
- "view-label",
5541
- "view-panel",
5542
- "view-status",
5543
- "viewer",
5544
- "workspace"
5545
- ];
5546
- function mountViewstlApp(root, options = {}) {
5547
- if (!root?.querySelectorAll)
5548
- throw new TypeError("A Document or HTMLElement root is required");
5549
- if (mountedRoots.has(root))
5550
- throw new Error("ViewSTL app is already mounted on this root");
5551
- const document2 = root.ownerDocument || root;
5552
- const window2 = document2.defaultView;
5553
- const URL2 = window2.URL;
5554
- const controls = new Map(
5555
- [...root.querySelectorAll("[id]")].map((el) => [el.id, el])
5556
- );
5557
- const $ = (id) => controls.get((options.id_prefix || "") + id);
5558
- const missing = requiredControls.filter((id) => !$(id));
5559
- if (exportControls.some((id) => $(id)))
5560
- missing.push(...exportControls.filter((id) => !$(id)));
5561
- if (printControls.some((id) => $(id)))
5562
- missing.push(...printControls.filter((id) => !$(id)));
5563
- if (animationControls.some((id) => $(id)))
5564
- missing.push(...animationControls.filter((id) => !$(id)));
5565
- if (toolsControls.some((id) => $(id)))
5566
- missing.push(...toolsControls.filter((id) => !$(id)));
5567
- if (missing.length)
5568
- throw new Error("Missing ViewSTL controls: " + missing.join(", "));
5569
- const abort = new window2.AbortController();
5570
- const dropTarget = root === document2 ? window2 : root;
5571
- let disposed = false, drainPromise = null, lastLoadedID = null;
5572
- const assertActive = () => {
5573
- if (disposed) throw new Error("ViewSTL app is disposed");
5574
- };
5575
- const listen = (target, type, handler, options2 = {}) => target.addEventListener(type, handler, {
5576
- ...options2,
5577
- signal: abort.signal
5578
- });
5579
- const policy = memoryPolicy(window2.navigator.deviceMemory);
5580
- const panelNames = ["view", "section", "info", "scene", "colors"];
5581
- const records = /* @__PURE__ */ new Map(), queue = [];
5582
- const palette = [
5583
- "#64a5b2",
5584
- "#dd766b",
5585
- "#edb14c",
5586
- "#7ca984",
5587
- "#927fbc",
5588
- "#5a8ec7",
5589
- "#89949d"
5590
- ];
5591
- let viewerPromise, viewer, generation = 0, appearanceRequest = 0, modelID = null, nextID = 0, loadingID = null;
5592
- let exportUI;
5593
- let printUI;
5594
- let animationUI;
5595
- let toolsUI;
5596
- let sceneImport;
5597
- let draining = false, busy = false, gridEnabled = false, gridHeight = 0, gridSpacing = 1, moveMode = false, selectionMode = false, selectionPointer = null, transformTool = "move", modelMover, dragArrangement, background = "transparent";
5598
- const viewNames = {
5599
- flat: "Solid",
5600
- edges: "Solid + edges",
5601
- wireframe: "Wireframe",
5602
- xray: "X-ray",
5603
- mesh: "Mesh overlay",
5604
- studio: "Studio",
5605
- orientation: "Face orientation"
5606
- };
5607
- const modeInputs = [...root.querySelectorAll('[name="view-mode"]')];
5608
- const shortcutButtons = [...root.querySelectorAll("[data-viewstl-shortcut]")];
5609
- if (options.id_prefix)
5610
- modeInputs.forEach(
5611
- (input) => input.name = options.id_prefix + "view-mode"
5612
- );
5613
- const sampleURL = options.sample_url;
5614
- const formatNumber = new Intl.NumberFormat(void 0, {
5615
- maximumFractionDigits: 2
5616
- });
5617
- function announce(text) {
5618
- if (disposed) return;
5619
- $("live-status").textContent = text;
5620
- }
5621
- function showNotice(text) {
5622
- if (disposed) return;
5623
- $("notice-message").textContent = text;
5624
- $("notice").hidden = false;
5625
- }
5626
- function syncSelection() {
5627
- viewer?.set_selection(
5628
- !busy && records.has(modelID) && (selectionMode || moveMode || !$("scene-panel").hidden) ? modelID : null
5629
- );
4509
+ get busy() {
4510
+ return this._busy;
5630
4511
  }
5631
- function syncShortcuts() {
5632
- for (const button of shortcutButtons) {
5633
- const action = button.dataset.viewstlShortcut;
5634
- button.disabled = busy || !records.size || action === "save" && !exportUI;
5635
- if (action === "select")
5636
- button.setAttribute("aria-pressed", String(selectionMode));
5637
- }
4512
+ get arrangement() {
4513
+ return this._arrangement;
5638
4514
  }
5639
- function setSelectionMode(enabled) {
5640
- if (enabled) setMoveMode(false);
5641
- selectionMode = !!enabled && !busy && records.size > 0;
5642
- selectionPointer = null;
5643
- if (viewer) {
5644
- viewer.controls.enabled = !selectionMode;
5645
- viewer.canvas.setAttribute(
5646
- "aria-label",
5647
- selectionMode ? "Select a model with a click. Escape returns to viewing." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5648
- );
5649
- }
5650
- $("workspace").classList.toggle("selecting-model-mode", selectionMode);
5651
- syncShortcuts();
5652
- syncSelection();
5653
- }
5654
- function editModelAt(event) {
5655
- const hit = viewer.pick_model(event.clientX, event.clientY);
5656
- if (!hit || !records.has(hit.id)) return;
5657
- event.preventDefault();
5658
- selectModel(hit.id);
5659
- closePanels();
5660
- $("scene-panel").hidden = false;
5661
- $("scene-button").setAttribute("aria-expanded", "true");
5662
- $("position-details").open = true;
5663
- syncSelection();
5664
- announce(
5665
- records.get(hit.id).name + " selected. Choose Move, Rotate or Depth."
5666
- );
4515
+ get grid() {
4516
+ return { ...this._grid };
5667
4517
  }
5668
- function closePanel(name) {
5669
- $(name + "-panel").hidden = true;
5670
- $(name + "-button").setAttribute("aria-expanded", "false");
5671
- syncSelection();
5672
- }
5673
- function closePanels() {
5674
- panelNames.forEach(closePanel);
5675
- }
5676
- function togglePanel(name) {
5677
- const open = $(name + "-panel").hidden;
5678
- closePanels();
5679
- $(name + "-panel").hidden = !open;
5680
- $(name + "-button").setAttribute("aria-expanded", String(open));
5681
- syncSelection();
5682
- if (open && name === "info") populateInfo();
5683
- if (open && (name === "scene" || name === "view")) toolsUI?.sync();
5684
- }
5685
- function fileSize(size) {
5686
- return size == null ? "Sample model" : size < 1024 ** 2 ? `${formatNumber.format(size / 1024)} KiB` : size < 1024 ** 3 ? `${formatNumber.format(size / 1024 ** 2)} MiB` : `${formatNumber.format(size / 1024 ** 3)} GiB`;
5687
- }
5688
- function syncColors() {
5689
- const record = records.get(modelID), color = /^#[a-f0-9]{6}$/i.test(record?.color) ? record.color : "#ffffff";
5690
- $("model-color").value = color;
5691
- $("model-color-value").textContent = record?.color === null ? "File colors" : color.toUpperCase();
5692
- $("restore-color").textContent = record?.embedded ? "Restore file colors" : "Restore original color";
5693
- root.querySelectorAll("[data-model-color]").forEach(
5694
- (b) => b.setAttribute(
5695
- "aria-pressed",
5696
- String(b.dataset.modelColor === record?.color)
5697
- )
5698
- );
5699
- root.querySelectorAll("[data-background]").forEach(
5700
- (b) => b.setAttribute(
5701
- "aria-pressed",
5702
- String(b.dataset.background === background)
5703
- )
5704
- );
5705
- $("background-color").value = background === "transparent" ? "#fafbfb" : background;
5706
- $("background-value").textContent = background === "transparent" ? "Default" : background.toUpperCase();
5707
- }
5708
- function syncControls() {
5709
- syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
5710
- const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
5711
- for (const input of modeInputs) {
5712
- input.checked = input.value === mode;
5713
- input.disabled = busy;
5714
- }
5715
- $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5716
- $("view-button").title = "View \xB7 " + viewNames[mode];
5717
- $("smooth-shading").checked = info?.smooth_shading || false;
5718
- $("smooth-shading").disabled = busy || mode === "wireframe";
5719
- $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
5720
- const text = mode === "orientation" ? "Blue: front faces \xB7 Red: back faces" : "";
5721
- $("view-description").textContent = text;
5722
- $("view-description").hidden = !text;
5723
- $("color-mode-note").hidden = mode !== "orientation";
5724
- const section = info?.section;
5725
- $("section-enabled").checked = !!section;
5726
- $("section-axis").value = section?.axis || "z";
5727
- $("section-position").value = (section?.position ?? 0.5) * 100;
5728
- $("section-value").textContent = $("section-position").value + "%";
5729
- $("section-flip").setAttribute(
5730
- "aria-pressed",
5731
- String(section?.flip || false)
5732
- );
5733
- $("section-button").setAttribute("aria-pressed", String(!!section));
5734
- root.querySelectorAll("[data-selected-name]").forEach((el) => el.textContent = records.get(modelID)?.name || "");
5735
- syncColors();
5736
- syncPosition();
5737
- exportUI?.sync();
5738
- printUI?.sync();
5739
- animationUI?.sync();
5740
- toolsUI?.sync();
5741
- syncShortcuts();
5742
- }
5743
- function populateInfo() {
5744
- if (modelID === null) return;
5745
- const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
5746
- $("info-triangles").textContent = info.triangles.toLocaleString();
5747
- $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
5748
- $("info-size").textContent = fileSize(record.size);
5749
- const size = viewer.get_model_size(modelID);
5750
- $("unit-note").textContent = size.editable ? "File coordinates interpreted as " + (size.file_units === "inch" ? "inches." : "millimetres (assumed).") + " Change file units in Models \u2192 Size & units." : "Explicit file units were converted to millimetres on import.";
5751
- toolsUI?.syncInfo();
5752
- }
5753
- function renderList() {
5754
- $("model-list").replaceChildren(
5755
- ...[...records.values()].map((record) => {
5756
- const row = document2.createElement("li"), color = document2.createElement("button"), pick = document2.createElement("button"), remove = document2.createElement("button");
5757
- row.className = "model-row";
5758
- row.classList.toggle("selected", record.id === modelID);
5759
- color.className = "model-swatch";
5760
- color.dataset.colorModel = record.id;
5761
- color.style.backgroundColor = record.color || "#b7c5c9";
5762
- color.setAttribute("aria-label", "Color for " + record.name);
5763
- color.title = "Change color";
5764
- pick.className = "model-pick";
5765
- pick.dataset.selectModel = record.id;
5766
- pick.textContent = record.name;
5767
- pick.title = record.name;
5768
- pick.setAttribute("aria-pressed", String(record.id === modelID));
5769
- remove.className = "icon-button";
5770
- remove.dataset.removeModel = record.id;
5771
- remove.textContent = "\xD7";
5772
- remove.setAttribute("aria-label", "Remove " + record.name);
5773
- row.append(color, pick, remove);
5774
- return row;
5775
- })
5776
- );
5777
- $("scene-count").textContent = records.size;
5778
- $("scene-arrangement").disabled = records.size === 0;
5779
- }
5780
- function renderScene() {
5781
- const has = records.size > 0;
5782
- if (!has && selectionMode) setSelectionMode(false);
5783
- $("workspace").classList.toggle("has-models", has);
5784
- $("viewer").hidden = !has && !busy;
5785
- $("empty-state").hidden = has || busy;
5786
- $("file-heading").hidden = !has;
5787
- $("model-controls").hidden = !has;
5788
- $("filename").textContent = records.get(modelID)?.name || "";
5789
- $("filename").title = $("filename").textContent;
5790
- $("open-label").textContent = has ? "Add models" : "Open files";
5791
- $("drop-message").textContent = has ? "Add to your scene." : "Drop it here.";
5792
- renderList();
5793
- syncControls();
5794
- populateInfo();
5795
- if (!has) closePanels();
5796
- syncSelection();
5797
- }
5798
- function setBusy(value) {
5799
- if (value) exportUI?.close();
5800
- if (disposed) return;
5801
- if (value && selectionMode) setSelectionMode(false);
5802
- if (value) setMoveMode(false);
5803
- busy = value;
5804
- $("loading-state").hidden = !value;
5805
- $("workspace").setAttribute("aria-busy", String(value));
5806
- renderScene();
5807
- }
5808
- function selectModel(id) {
5809
- exportUI?.close();
5810
- if (!records.has(id)) return;
5811
- setMoveMode(false);
5812
- appearanceRequest++;
5813
- modelID = id;
5814
- renderScene();
5815
- }
5816
- function placements() {
5817
- return scenePlacement([...records.values()], $("scene-arrangement").value);
5818
- }
5819
- function updateGrid() {
5820
- viewer?.set_infinite_grid(gridEnabled, gridSpacing, gridHeight);
5821
- $("grid-button").setAttribute("aria-pressed", String(gridEnabled));
5822
- }
5823
- function arrangeScene() {
5824
- setMoveMode(false);
5825
- for (const record of records.values())
5826
- syncAnimatedRecord(record, viewer?._models.get(record.id));
5827
- if ($("scene-arrangement").value === "custom") return;
5828
- for (const record of records.values()) prepareManual(record.id);
5829
- if ($("scene-arrangement").value === "original")
5830
- for (const record of records.values())
5831
- record.rotation = {
5832
- ...record.originalRotation || { x: 0, y: 0, z: 0 }
5833
- };
5834
- const positions = placements();
5835
- for (const p of positions) {
5836
- records.get(p.id).position = { x: p.x, y: p.y, z: p.z };
5837
- renderTransform(records.get(p.id));
5838
- }
5839
- if ($("scene-arrangement").value !== "custom" || !records.size)
5840
- gridHeight = sceneFloor([...records.values()], positions);
5841
- syncPosition();
5842
- updateGrid();
5843
- viewer?.set_auto_zoom();
5844
- }
5845
- function syncPosition(preserveFocus = false) {
5846
- const record = records.get(modelID);
5847
- for (const axis of ["x", "y", "z"]) {
5848
- const input = $("position-" + axis);
5849
- if (!preserveFocus || document2.activeElement !== input)
5850
- input.value = Number((record?.position?.[axis] || 0).toPrecision(12));
5851
- input.disabled = !record;
5852
- input.removeAttribute("aria-invalid");
5853
- }
5854
- $("position-error").hidden = true;
5855
- $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
5856
- $("move-model-button").disabled = !record || busy;
5857
- $("reset-position").disabled = !record;
5858
- syncRotation(preserveFocus);
5859
- for (const id of [
5860
- "rotate-model-button",
5861
- "depth-model-button",
5862
- "depth-nearer",
5863
- "depth-farther"
5864
- ])
5865
- $(id).disabled = !record || busy;
4518
+ get memoryPolicy() {
4519
+ return { ...this._policy, limits: { ...this._policy.limits } };
5866
4520
  }
5867
- function applyPosition(id, position, refreshInputs = true) {
5868
- if (!records.has(id) || !Object.values(position).every(
5869
- (v) => Number.isFinite(v) && Math.abs(v) <= 1e12
5870
- ))
5871
- return false;
5872
- records.get(id).position = { ...position };
5873
- renderTransform(records.get(id));
5874
- $("scene-arrangement").value = "custom";
5875
- if (id === modelID && refreshInputs) syncPosition();
5876
- return true;
5877
- }
5878
- function renderTransform(record) {
5879
- const p = enginePosition(record), r = record.rotation;
5880
- viewer.set_rotation(
5881
- record.id,
5882
- ...["x", "y", "z"].map((axis) => r[axis] % 360 * Math.PI / 180)
5883
- );
5884
- viewer.set_position(record.id, p.x, p.y, p.z);
4521
+ _assertActive() {
4522
+ if (this._disposed) throw new Error("ViewSTL session is disposed");
5885
4523
  }
5886
- function selectedTransform() {
5887
- prepareManual(modelID);
5888
- const record = records.get(modelID);
5889
- return record ? { ...record, center: modelCenter(record), radius: modelRadius(record) } : null;
4524
+ _emit(event) {
4525
+ if (!this._disposed) this._call(this._options.on_event, event);
5890
4526
  }
5891
- function syncRotation(preserveFocus = false) {
5892
- const record = records.get(modelID);
5893
- for (const axis of ["x", "y", "z"]) {
5894
- const input = $("rotation-" + axis);
5895
- if (!preserveFocus || document2.activeElement !== input)
5896
- input.value = Number((record?.rotation?.[axis] || 0).toPrecision(10));
5897
- input.disabled = !record;
5898
- input.removeAttribute("aria-invalid");
4527
+ _call(callback, ...args) {
4528
+ try {
4529
+ callback?.(...args);
4530
+ } catch (error) {
4531
+ console.error(error);
5899
4532
  }
5900
- $("rotation-error").hidden = true;
5901
- $("reset-rotation").disabled = !record;
5902
- }
5903
- function applyRotation(id, rotation2, refreshInputs = true) {
5904
- if (!records.has(id) || !Object.values(rotation2).every(
5905
- (v) => Number.isFinite(v) && Math.abs(v) <= 36e4
5906
- ))
5907
- return false;
5908
- records.get(id).rotation = { ...rotation2 };
5909
- renderTransform(records.get(id));
5910
- $("scene-arrangement").value = "custom";
5911
- if (refreshInputs && id === modelID) syncRotation();
5912
- return true;
5913
4533
  }
5914
- const toolLabels = { move: "Move", rotate: "Rotate", depth: "Depth" };
5915
- const toolHints = {
5916
- move: "Drag to move the selected model",
5917
- rotate: "Drag to rotate the selected model",
5918
- depth: "Drag up for nearer, down for farther"
5919
- };
5920
- function syncTool() {
5921
- if (viewer?.canvas) viewer.canvas.dataset.modelTool = transformTool;
5922
- root.querySelectorAll("[data-transform-tab]").forEach(
5923
- (b) => b.setAttribute(
5924
- "aria-pressed",
5925
- String(b.dataset.transformTab === transformTool)
5926
- )
5927
- );
5928
- for (const name of ["move", "rotate", "depth"]) {
5929
- $(name + "-controls").hidden = name !== transformTool;
5930
- $(name + "-model-button").setAttribute(
5931
- "aria-pressed",
5932
- String(moveMode && name === transformTool)
5933
- );
5934
- }
5935
- $("model-tool").value = transformTool;
5936
- $("move-gesture-message").textContent = toolHints[transformTool] + " \xB7 Scroll to adjust depth";
5937
- }
5938
- function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
5939
- if (enabled && selectionMode) setSelectionMode(false);
5940
- modelMover?.setEnabled(false);
5941
- transformTool = tool;
5942
- moveMode = !!enabled && modelID !== null && !busy;
5943
- if (moveMode && !retainPaused) prepareManual(modelID);
5944
- modelMover?.setEnabled(moveMode);
5945
- syncTool();
5946
- $("move-mode").hidden = !moveMode;
5947
- $("move-mode-name").textContent = records.get(modelID)?.name || "";
5948
- $("workspace").classList.toggle("moving-model-mode", moveMode);
5949
- if (viewer?.canvas)
5950
- viewer.canvas.setAttribute(
5951
- "aria-label",
5952
- moveMode ? toolHints[transformTool] + ". Scroll changes only its depth. Escape exits." : selectionMode ? "Select a model with a click. Escape returns to viewing." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5953
- );
5954
- if (moveMode) {
5955
- closePanels();
5956
- viewer.canvas.focus({ preventScroll: true });
5957
- announce(
5958
- toolLabels[transformTool] + " tool for " + records.get(modelID).name + ". Done or Escape returns to viewing."
5959
- );
5960
- }
5961
- syncSelection();
5962
- }
5963
- for (const axis of ["x", "y", "z"]) {
5964
- const input = $("rotation-" + axis);
5965
- listen(input, "input", () => {
5966
- if (modelID === null || !input.value.trim()) return;
5967
- prepareManual(modelID);
5968
- if (applyRotation(
5969
- modelID,
5970
- { ...records.get(modelID).rotation, [axis]: input.valueAsNumber },
5971
- false
5972
- )) {
5973
- input.removeAttribute("aria-invalid");
5974
- $("rotation-error").hidden = true;
5975
- }
5976
- });
5977
- listen(input, "change", () => {
5978
- if (modelID === null) return;
5979
- prepareManual(modelID);
5980
- if (!input.value.trim() || !applyRotation(modelID, {
5981
- ...records.get(modelID).rotation,
5982
- [axis]: input.valueAsNumber
5983
- })) {
5984
- input.setAttribute("aria-invalid", "true");
5985
- $("rotation-error").hidden = false;
5986
- } else announce("Model rotation updated.");
5987
- });
4534
+ _setBusy(value) {
4535
+ this._busy = value;
4536
+ this._emit({ type: "busy", busy: value });
5988
4537
  }
5989
- listen($("reset-rotation"), "click", () => {
5990
- setMoveMode(false);
5991
- if (modelID !== null) {
5992
- prepareManual(modelID);
5993
- applyRotation(modelID, {
5994
- ...records.get(modelID).originalRotation || { x: 0, y: 0, z: 0 }
5995
- });
5996
- announce("Selected model rotation reset.");
5997
- }
5998
- });
5999
- for (const name of ["move", "rotate", "depth"])
6000
- listen(
6001
- $(name + "-model-button"),
6002
- "click",
6003
- () => setMoveMode(!moveMode || transformTool !== name, name)
6004
- );
6005
- for (const [id, amount] of [
6006
- ["depth-nearer", -0.12],
6007
- ["depth-farther", 0.12]
6008
- ])
6009
- listen($(id), "click", () => {
6010
- const model = selectedTransform();
6011
- if (!model) return;
6012
- applyPosition(
6013
- model.id,
6014
- depthPosition(model, viewer.get_camera_state(), amount)
6015
- );
6016
- announce(
6017
- amount < 0 ? "Selected model moved nearer." : "Selected model moved farther."
6018
- );
6019
- });
6020
- root.querySelectorAll("[data-transform-tab]").forEach(
6021
- (b) => listen(b, "click", () => setMoveMode(false, b.dataset.transformTab))
6022
- );
6023
- listen(
6024
- $("model-tool"),
6025
- "change",
6026
- () => setMoveMode(true, $("model-tool").value)
6027
- );
6028
- for (const axis of ["x", "y", "z"]) {
6029
- const input = $("position-" + axis);
6030
- listen(input, "input", () => {
6031
- if (modelID === null || !input.value.trim()) return;
6032
- prepareManual(modelID);
6033
- if (applyPosition(
6034
- modelID,
6035
- { ...records.get(modelID).position, [axis]: input.valueAsNumber },
6036
- false
6037
- )) {
6038
- input.removeAttribute("aria-invalid");
6039
- $("position-error").hidden = true;
6040
- }
6041
- });
6042
- listen(input, "change", () => {
6043
- if (modelID === null) return;
6044
- prepareManual(modelID);
6045
- if (!input.value.trim() || !applyPosition(modelID, {
6046
- ...records.get(modelID).position,
6047
- [axis]: input.valueAsNumber
6048
- })) {
6049
- input.setAttribute("aria-invalid", "true");
6050
- $("position-error").hidden = false;
6051
- } else announce("Model position updated.");
4538
+ _error(error, source) {
4539
+ this._emit({
4540
+ type: "error",
4541
+ code: error.code || "load-failed",
4542
+ message: error.message,
4543
+ source: source?.name
6052
4544
  });
6053
4545
  }
6054
- listen($("finish-move"), "click", () => {
6055
- setMoveMode(false);
6056
- viewer?.canvas.focus({ preventScroll: true });
6057
- });
6058
- listen($("reset-position"), "click", () => {
6059
- setMoveMode(false);
6060
- if (modelID !== null) {
6061
- prepareManual(modelID);
6062
- applyPosition(modelID, {
6063
- ...records.get(modelID).originalPosition || { x: 0, y: 0, z: 0 }
6064
- });
6065
- announce("Selected model restored to its original position.");
6066
- }
6067
- });
6068
- listen(window2, "blur", () => {
6069
- if (moveMode) setMoveMode(false);
6070
- });
6071
- function cancelLoading() {
6072
- generation++;
6073
- queue.length = 0;
6074
- sceneImport?.abort();
6075
- if (loadingID !== null) {
6076
- viewer?.remove_model(loadingID);
6077
- loadingID = null;
6078
- }
6079
- setBusy(false);
6080
- announce("Opening cancelled.");
6081
- }
6082
- function clearScene() {
6083
- exportUI?.close();
6084
- setMoveMode(false);
6085
- cancelLoading();
6086
- appearanceRequest++;
6087
- viewer?.clean();
6088
- records.clear();
6089
- modelID = null;
6090
- gridEnabled = false;
6091
- gridHeight = 0;
6092
- gridSpacing = 1;
6093
- $("scene-arrangement").value = "original";
6094
- updateGrid();
6095
- $("notice").hidden = true;
6096
- renderScene();
6097
- announce("Scene cleared.");
6098
- }
6099
- function removeModel(id) {
6100
- if (!records.has(id)) return;
6101
- exportUI?.close();
6102
- const name = records.get(id).name;
6103
- setMoveMode(false);
6104
- viewer.remove_model(id);
6105
- records.delete(id);
6106
- appearanceRequest++;
6107
- if (modelID === id) modelID = [...records.keys()].at(-1) ?? null;
6108
- arrangeScene();
6109
- renderScene();
6110
- announce(name + " removed.");
6111
- }
6112
- function prepareManual(id) {
6113
- if (!records.has(id)) return;
6114
- syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6115
- viewer?.animate_model(id, null);
6116
- }
6117
- function refreshAnimatedPose() {
6118
- if (disposed || modelID === null) return;
6119
- syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6120
- if (!$("info-panel").hidden) populateInfo();
6121
- if (!$("scene-panel").hidden && $("position-details").open)
6122
- syncPosition(true);
6123
- }
6124
- function animationChanged(id, state, reason) {
6125
- if (disposed) return;
6126
- if (reason === "restored" && state.status === "running" && window2.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
6127
- viewer.pause_model_animation(id);
6128
- syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6129
- if (["started", "restored", "resumed"].includes(reason))
6130
- $("scene-arrangement").value = "custom";
6131
- if (id === modelID) refreshAnimatedPose();
6132
- animationUI?.sync();
6133
- printUI?.sync();
6134
- options.animation_state_callback?.(id, state, reason);
6135
- }
6136
- async function getViewer() {
6137
- if (!viewerPromise)
6138
- viewerPromise = Promise.resolve().then(async () => {
6139
- if (disposed) throw new Error("ViewSTL app is disposed");
6140
- viewer = new StlViewer($("viewer"), {
6141
- ...policy.limits,
4546
+ async ready() {
4547
+ this._assertActive();
4548
+ if (!this._viewerPromise) {
4549
+ this._viewerPromise = Promise.resolve().then(async () => {
4550
+ this._assertActive();
4551
+ const supplied = this._options.viewer_options || {};
4552
+ const viewer = this._viewer = new StlViewer(this._container, {
4553
+ ...this._policy.limits,
6142
4554
  worker_count: 1,
6143
- worker_timeout: options.worker_timeout ?? 6e5,
6144
- worker_url: options.worker_url,
6145
- worker_factory: options.worker_factory,
6146
- url_import_endpoint: options.url_import_endpoint,
4555
+ worker_timeout: 6e5,
6147
4556
  center_models: false,
6148
- bg_color: background,
6149
4557
  allow_drag_and_drop: false,
6150
- animation_state_callback: animationChanged,
6151
- animation_complete_callback: (...args) => {
6152
- if (!disposed) options.animation_complete_callback?.(...args);
6153
- },
6154
- camera_state: {
6155
- position: { x: 90, y: -130, z: 90 },
6156
- target: { x: 0, y: 0, z: 0 },
6157
- up: { x: 0, y: 0, z: 1 }
6158
- },
4558
+ ...supplied,
6159
4559
  loading_progress_callback: (status) => {
6160
- if (disposed || !busy || sceneImport) return;
6161
- const p = status[loadingID];
6162
- $("loading-label").textContent = p?.phase === "loading-cad" ? "Loading the CAD reader\u2026" : p?.phase === "parsing-cad" ? "Preparing CAD surfaces\u2026" : p?.loaded && !p.total ? `Downloading your file\u2026 ${fileSize(p.loaded)}` : p?.total && p.loaded < p.total ? `Reading your file\u2026 ${Math.min(99, Math.floor(100 * p.loaded / p.total))}%` : "Preparing your model\u2026";
4560
+ if (this._busy && !this._sceneImport)
4561
+ this._emit({
4562
+ type: "progress",
4563
+ scene: false,
4564
+ progress: status[this._loadingID] || {}
4565
+ });
4566
+ this._call(supplied.loading_progress_callback, status);
6163
4567
  },
6164
4568
  model_loaded_callback: (id) => {
6165
- lastLoadedID = id;
4569
+ this._lastLoadedID = id;
4570
+ this._call(supplied.model_loaded_callback, id);
6166
4571
  },
6167
4572
  load_error_callback: (message) => {
6168
- showNotice(
6169
- /limit exceeded|safety budget/i.test(message) ? `This scene or view mode exceeds the estimated memory budget for this device (${fileSize(policy.budget)}). Remove a model, use Solid view without smoothing, or try a device with more memory.` : `Couldn\u2019t display this model. ${message}`
6170
- );
6171
- }
6172
- });
6173
- await viewer.when_ready;
6174
- if (disposed) throw new Error("ViewSTL app is disposed");
6175
- modelMover = bindModelMove(viewer.canvas, {
6176
- getCamera: () => viewer.get_camera_state(),
6177
- getModel: selectedTransform,
6178
- getTool: () => transformTool,
6179
- onRotate: (id, rotation2) => applyRotation(id, rotation2),
6180
- onMove: (id, position) => applyPosition(id, position),
6181
- onStart: () => {
6182
- dragArrangement = $("scene-arrangement").value;
4573
+ this._error(new Error(message));
4574
+ this._call(supplied.load_error_callback, message);
6183
4575
  },
6184
- onEnd: (id, cancelled) => {
6185
- if (cancelled) $("scene-arrangement").value = dragArrangement;
6186
- announce(cancelled ? "Move cancelled." : "Model updated.");
4576
+ animation_state_callback: (id, state, reason) => {
4577
+ if (["started", "restored", "resumed"].includes(reason))
4578
+ this._arrangement = "custom";
4579
+ this._emit({ type: "animation", id, state, reason });
4580
+ this._call(supplied.animation_state_callback, id, state, reason);
4581
+ },
4582
+ animation_complete_callback: (...args) => {
4583
+ if (!this._disposed)
4584
+ this._call(supplied.animation_complete_callback, ...args);
6187
4585
  }
6188
4586
  });
6189
- listen(viewer.canvas, "dblclick", (event) => {
6190
- if (busy || selectionMode || event.button !== 0) return;
6191
- editModelAt(event);
6192
- });
6193
- listen(viewer.canvas, "pointerdown", (event) => {
6194
- if (!selectionMode || busy || event.button !== 0 || !event.isPrimary)
6195
- return;
6196
- selectionPointer = {
6197
- id: event.pointerId,
6198
- x: event.clientX,
6199
- y: event.clientY,
6200
- moved: false
6201
- };
6202
- viewer.canvas.focus({ preventScroll: true });
6203
- viewer.canvas.setPointerCapture(event.pointerId);
6204
- event.preventDefault();
6205
- });
6206
- listen(viewer.canvas, "pointermove", (event) => {
6207
- if (selectionPointer?.id === event.pointerId && Math.hypot(
6208
- event.clientX - selectionPointer.x,
6209
- event.clientY - selectionPointer.y
6210
- ) > 6)
6211
- selectionPointer.moved = true;
6212
- });
6213
- listen(viewer.canvas, "pointerup", (event) => {
6214
- const pointer = selectionPointer;
6215
- selectionPointer = null;
6216
- if (selectionMode && !busy && pointer?.id === event.pointerId && !pointer.moved)
6217
- editModelAt(event);
6218
- });
6219
- for (const event of ["pointercancel", "lostpointercapture"])
6220
- listen(viewer.canvas, event, () => {
6221
- selectionPointer = null;
6222
- });
4587
+ await viewer.when_ready;
4588
+ this._assertActive();
4589
+ this._emit({ type: "ready" });
6223
4590
  return viewer;
6224
4591
  }).catch((error) => {
6225
- modelMover?.dispose();
6226
- modelMover = null;
6227
- viewer?.dispose();
6228
- viewer = null;
6229
- viewerPromise = null;
4592
+ this._viewer?.dispose();
4593
+ this._viewer = null;
4594
+ this._viewerPromise = null;
6230
4595
  throw error;
6231
4596
  });
6232
- return viewerPromise;
4597
+ }
4598
+ return this._viewerPromise;
4599
+ }
4600
+ _record(id) {
4601
+ const record = this._records.get(id), model = this._viewer?._models.get(id);
4602
+ if (!record || !model?.geometry) return null;
4603
+ syncAnimatedRecord(record, model);
4604
+ record.color = model.color || (model.geometry.colors ? null : "#909090");
4605
+ return record;
4606
+ }
4607
+ getModel(id) {
4608
+ this._assertActive();
4609
+ const r = this._record(id);
4610
+ if (!r) return null;
4611
+ const { sourceFile, estimated, ...data } = r;
4612
+ return structuredClone({
4613
+ ...data,
4614
+ animation: this._viewer.get_model_animation_state(id),
4615
+ animationDefinition: this._viewer._models.get(id)?.animation || null
4616
+ });
4617
+ }
4618
+ getModels() {
4619
+ this._assertActive();
4620
+ return [...this._records.keys()].map((id) => this.getModel(id)).filter(Boolean);
4621
+ }
4622
+ _adopt(id, source = {}, imported = false) {
4623
+ const m = this._viewer._models.get(id), g = m.geometry;
4624
+ const record = {
4625
+ id,
4626
+ name: source.name || String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
4627
+ size: source.size ?? g.file_bytes ?? m.local_file?.size ?? null,
4628
+ units: g.units || m.units || null,
4629
+ embedded: !!g.colors,
4630
+ color: m.color || (g.colors ? null : "#909090"),
4631
+ estimated: g.triangles * 224,
4632
+ sourceFile: source.sourceFile || null
4633
+ };
4634
+ syncAnimatedRecord(record, m);
4635
+ record.originalPosition = imported ? { ...record.position } : zero();
4636
+ record.originalRotation = imported ? { ...record.rotation } : zero();
4637
+ record.originalColor = record.color;
4638
+ this._records.set(id, record);
4639
+ }
4640
+ prepareManual(id) {
4641
+ this._assertActive();
4642
+ if (!this._record(id)) return;
4643
+ this._viewer.animate_model(id, null);
4644
+ }
4645
+ _renderTransform(record) {
4646
+ const p = enginePosition(record), r = record.rotation;
4647
+ this._viewer.set_rotation(
4648
+ record.id,
4649
+ ...axes4.map((a) => r[a] % 360 * Math.PI / 180)
4650
+ );
4651
+ this._viewer.set_position(record.id, p.x, p.y, p.z);
4652
+ }
4653
+ _setTransform(id, kind, value, limit) {
4654
+ this._assertActive();
4655
+ if (!this._records.has(id) || !value || !axes4.every(
4656
+ (a) => Number.isFinite(value[a]) && Math.abs(value[a]) <= limit
4657
+ ))
4658
+ return false;
4659
+ this.prepareManual(id);
4660
+ const record = this._records.get(id);
4661
+ record[kind] = { ...value };
4662
+ this._renderTransform(record);
4663
+ this._arrangement = "custom";
4664
+ return true;
4665
+ }
4666
+ setPosition(id, position) {
4667
+ return this._setTransform(id, "position", position, 1e12);
6233
4668
  }
6234
- function addFiles(files) {
6235
- assertActive();
4669
+ setRotation(id, degrees) {
4670
+ return this._setTransform(id, "rotation", degrees, 36e4);
4671
+ }
4672
+ centerPivot(id) {
4673
+ this.prepareManual(id);
4674
+ const record = this._records.get(id);
4675
+ if (!record) return;
4676
+ centerAnimationPivot(record, this._viewer._models.get(id));
4677
+ this._arrangement = "custom";
4678
+ }
4679
+ _manipulationModel(id) {
4680
+ this.prepareManual(id);
4681
+ const record = this._record(id);
4682
+ return record ? { ...record, center: modelCenter(record), radius: modelRadius(record) } : null;
4683
+ }
4684
+ moveDepth(id, amount) {
4685
+ if (!Number.isFinite(amount))
4686
+ throw new TypeError("Depth amount must be finite");
4687
+ const model = this._manipulationModel(id);
4688
+ return model ? this.setPosition(
4689
+ id,
4690
+ depthPosition(model, this._viewer.get_camera_state(), amount)
4691
+ ) : false;
4692
+ }
4693
+ bindManipulation(options) {
4694
+ this._assertActive();
4695
+ if (!this._viewer?.ready)
4696
+ throw new Error("Await session.ready() before binding manipulation");
4697
+ let arrangement, disposed = false;
4698
+ const binding = bindModelMove(this._viewer.canvas, {
4699
+ getCamera: () => this._viewer.get_camera_state(),
4700
+ getModel: (event) => this._manipulationModel(options.getModelId(event)),
4701
+ getTool: options.getTool,
4702
+ onMove: (id, position) => {
4703
+ this.setPosition(id, position);
4704
+ options.onChange?.(id);
4705
+ },
4706
+ onRotate: (id, rotation2) => {
4707
+ this.setRotation(id, rotation2);
4708
+ options.onChange?.(id);
4709
+ },
4710
+ onStart: (id) => {
4711
+ arrangement = this._arrangement;
4712
+ options.onStart?.(id);
4713
+ },
4714
+ onEnd: (id, cancelled) => {
4715
+ if (cancelled) this._arrangement = arrangement;
4716
+ options.onEnd?.(id, cancelled);
4717
+ }
4718
+ });
4719
+ const handle = {
4720
+ setEnabled: (value) => {
4721
+ if (!disposed) binding.setEnabled(value);
4722
+ },
4723
+ dispose: () => {
4724
+ if (disposed) return;
4725
+ disposed = true;
4726
+ binding.dispose();
4727
+ this._bindings.delete(handle);
4728
+ }
4729
+ };
4730
+ this._bindings.add(handle);
4731
+ return handle;
4732
+ }
4733
+ setArrangement(mode) {
4734
+ this._assertActive();
4735
+ if (!["original", "side-by-side", "custom"].includes(mode))
4736
+ throw new TypeError("Unknown arrangement");
4737
+ this._arrangement = mode;
4738
+ for (const id of this._records.keys()) this._record(id);
4739
+ if (mode === "custom") return;
4740
+ for (const record of this._records.values()) {
4741
+ this.prepareManual(record.id);
4742
+ if (mode === "original") record.rotation = { ...record.originalRotation };
4743
+ }
4744
+ const records = [...this._records.values()], positions = scenePlacement(records, mode);
4745
+ for (const { id, x, y, z } of positions) {
4746
+ const r = this._records.get(id);
4747
+ r.position = { x, y, z };
4748
+ this._renderTransform(r);
4749
+ }
4750
+ this.setGrid(
4751
+ this._grid.enabled,
4752
+ this._grid.spacing,
4753
+ sceneFloor(records, positions)
4754
+ );
4755
+ this._viewer?.set_auto_zoom();
4756
+ }
4757
+ setGrid(enabled, spacing = this._grid.spacing, height = this._grid.height) {
4758
+ this._assertActive();
4759
+ if (!Number.isFinite(spacing) || spacing <= 0 || !Number.isFinite(height))
4760
+ throw new TypeError("Grid spacing must be positive and height finite");
4761
+ this._viewer?.set_infinite_grid(enabled, spacing, height);
4762
+ this._grid = { enabled: !!enabled, spacing, height };
4763
+ }
4764
+ duplicate(id) {
4765
+ this._assertActive();
4766
+ const original = this._record(id);
4767
+ if (!original) throw new Error("Unknown model");
4768
+ const copy = this._viewer.duplicate_model(id);
4769
+ this._adopt(copy, { sourceFile: original.sourceFile }, true);
4770
+ const record = this._records.get(copy);
4771
+ record.animationPivot = original.animationPivot;
4772
+ record.originalColor = original.originalColor;
4773
+ this._nextID = Math.max(this._nextID, copy + 1);
4774
+ this._arrangement = "custom";
4775
+ return copy;
4776
+ }
4777
+ remove(id) {
4778
+ this._assertActive();
4779
+ if (!this._records.has(id)) return;
4780
+ this._viewer.remove_model(id);
4781
+ this._records.delete(id);
4782
+ this.setArrangement(this._arrangement);
4783
+ }
4784
+ openFiles(files) {
4785
+ this._assertActive();
6236
4786
  const accepted = [];
6237
4787
  for (const source of files) {
6238
- if (!/\.(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/i.test(source.name)) {
6239
- showNotice(
6240
- "Choose STL, OBJ, 3MF, STEP/STP, IGES/IGS, VSB or VSJ files."
4788
+ if (!formats.test(source.name))
4789
+ this._error(
4790
+ failure("unsupported-format", "Unsupported model or scene format"),
4791
+ source
6241
4792
  );
6242
- continue;
6243
- }
6244
- if (source.size === 0) {
6245
- showNotice("This file is empty. Choose a file containing a 3D model.");
6246
- continue;
6247
- }
6248
- accepted.push(source);
4793
+ else if (source.size === 0)
4794
+ this._error(failure("empty-file", "The file is empty"), source);
4795
+ else accepted.push(source);
6249
4796
  }
6250
4797
  const scenes = accepted.filter((f) => sceneFormat(f.name));
6251
4798
  if (scenes.length) {
@@ -6255,161 +4802,130 @@ function mountViewstlApp(root, options = {}) {
6255
4802
  failed: false
6256
4803
  };
6257
4804
  for (const sceneFile of scenes)
6258
- queue.push({ name: sceneFile.name, sceneFile, batch });
6259
- queue.push({ remaining: batch });
6260
- } else queue.push(...accepted);
6261
- if (queue.length && !draining) drainPromise = drainQueue();
6262
- return drainPromise || Promise.resolve();
6263
- }
6264
- function openURL(value, format2 = "") {
6265
- assertActive();
6266
- if (!options.url_import_endpoint)
6267
- throw new Error("URL imports are not configured.");
6268
- const url = new URL2(value.trim());
4805
+ this._queue.push({ name: sceneFile.name, sceneFile, batch });
4806
+ this._queue.push({ remaining: batch });
4807
+ } else this._queue.push(...accepted);
4808
+ return this._drain();
4809
+ }
4810
+ openURL(value, format = "") {
4811
+ this._assertActive();
4812
+ if (!this._options.viewer_options?.url_import_endpoint)
4813
+ throw new Error("URL imports are not configured");
4814
+ const url = new URL(value.trim());
6269
4815
  if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
6270
4816
  throw new Error(
6271
4817
  "Use a public HTTP or HTTPS file URL without login credentials."
6272
4818
  );
6273
- if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
6274
- throw new Error("Choose a supported 3D format.");
4819
+ if (format && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format))
4820
+ throw new Error("Unsupported model or scene format");
6275
4821
  url.hash = "";
6276
4822
  const name = decodeURIComponent(
6277
4823
  url.pathname.split("/").at(-1) || "Remote model"
6278
4824
  ).slice(0, 200);
6279
- queue.push({
4825
+ this._queue.push({
6280
4826
  name,
6281
4827
  remote: url.href,
6282
- format: format2,
4828
+ format,
6283
4829
  size: 0,
6284
- scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
4830
+ scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6285
4831
  });
6286
- if (!draining) drainPromise = drainQueue();
6287
- return drainPromise || Promise.resolve();
4832
+ return this._drain();
6288
4833
  }
6289
- if ($("url-form") && $("url-dialog")) {
6290
- listen($("url-form"), "submit", (event) => {
6291
- event.preventDefault();
6292
- try {
6293
- const work = openURL($("url-input").value, $("url-format").value);
6294
- $("url-input").value = "";
6295
- $("url-error").hidden = true;
6296
- $("url-dialog").close();
6297
- work.catch((error) => showNotice(error.message));
6298
- } catch (error) {
6299
- $("url-error").textContent = error.message;
6300
- $("url-error").hidden = false;
6301
- }
6302
- });
4834
+ _drain() {
4835
+ if (!this._drainPromise && this._queue.length) {
4836
+ this._drainPromise = Promise.resolve().then(() => this._drainQueue());
4837
+ }
4838
+ return this._drainPromise || Promise.resolve();
6303
4839
  }
6304
- async function drainQueue() {
6305
- if (draining) return;
6306
- draining = true;
4840
+ async _drainQueue() {
6307
4841
  try {
6308
- while (queue.length) {
6309
- const source = queue.shift(), token = generation;
4842
+ while (true) {
4843
+ if (!this._queue.length) {
4844
+ this._setBusy(false);
4845
+ if (!this._queue.length) break;
4846
+ }
4847
+ const source = this._queue.shift(), token = this._generation;
6310
4848
  if (source.remaining) {
6311
4849
  if (!source.remaining.failed)
6312
- queue.unshift(
4850
+ this._queue.unshift(
6313
4851
  ...source.remaining.files.filter(
6314
4852
  (f) => !source.remaining.used.has(f)
6315
4853
  )
6316
4854
  );
6317
4855
  continue;
6318
4856
  }
6319
- setBusy(true);
6320
- $("notice").hidden = true;
6321
- $("loading-label").textContent = "Opening " + source.name + "\u2026";
6322
- announce("Opening " + source.name);
4857
+ this._setBusy(true);
4858
+ this._emit({ type: "opening", name: source.name });
6323
4859
  try {
6324
4860
  if (source.sceneFile || source.scene) {
6325
- await openSceneSource(source, token);
4861
+ await this._openScene(source, token);
6326
4862
  continue;
6327
4863
  }
6328
4864
  const estimated = await estimateFile(source);
6329
- if (token !== generation) continue;
6330
- const resident = [...records.values()].reduce(
4865
+ if (token !== this._generation) continue;
4866
+ const resident = [...this._records.values()].reduce(
6331
4867
  (sum, r) => sum + r.estimated,
6332
4868
  0
6333
4869
  );
6334
- if (estimated + resident > policy.budget || source.size > policy.limits.max_file_bytes)
6335
- throw Error(
6336
- `Adding this file would exceed the estimated ${fileSize(policy.budget)} scene memory budget. Remove a model or try a device with more memory.`
4870
+ if (estimated + resident > this._policy.budget || source.size > this._policy.limits.max_file_bytes)
4871
+ throw failure(
4872
+ "memory-budget",
4873
+ "Estimated scene memory budget exceeded"
6337
4874
  );
6338
- const engine = await getViewer();
6339
- if (token !== generation) continue;
6340
- engine.do_resize();
6341
- nextID = Math.max(nextID, engine.max_model_id + 1);
6342
- const id = nextID++;
6343
- loadingID = id;
6344
- lastLoadedID = null;
6345
- engine.add_model({
4875
+ const viewer = await this.ready();
4876
+ if (token !== this._generation) continue;
4877
+ viewer.do_resize();
4878
+ this._nextID = Math.max(this._nextID, viewer.max_model_id + 1);
4879
+ const id = this._nextID++;
4880
+ this._loadingID = id;
4881
+ this._lastLoadedID = null;
4882
+ viewer.add_model({
6346
4883
  id,
6347
4884
  display: "flat",
6348
4885
  ...source.remote ? { remote_url: source.remote, remote_format: source.format } : source.url ? { filename: source.url } : { local_file: source }
6349
4886
  });
6350
- await engine.when_idle();
6351
- if (token !== generation) continue;
6352
- loadingID = null;
6353
- const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
6354
- if (!loaded) {
6355
- announce(source.name + " could not be opened.");
4887
+ await viewer.when_idle();
4888
+ if (token !== this._generation) continue;
4889
+ this._loadingID = null;
4890
+ if (this._lastLoadedID !== id) {
4891
+ this._emit({ type: "load-failed", name: source.name });
6356
4892
  continue;
6357
4893
  }
6358
- if (source.remote) {
6359
- source.name = loaded.name;
6360
- source.size = loaded.file_bytes;
6361
- delete source.remote;
6362
- }
6363
- const embedded = loaded.has_colors, originalColor = embedded ? null : palette[records.size % palette.length];
6364
- if (originalColor) engine.set_color(id, originalColor);
6365
- records.set(id, {
6366
- id,
6367
- name: source.name,
6368
- size: source.size,
6369
- units: loaded.source_units || (/\.3mf$/i.test(source.name) ? "mm" : null),
6370
- bounds: loaded.bounds,
6371
- position: { x: 0, y: 0, z: 0 },
6372
- rotation: { x: 0, y: 0, z: 0 },
6373
- embedded,
6374
- originalColor,
6375
- color: originalColor,
6376
- estimated: loaded.triangles * 224,
6377
- sourceFile: source instanceof window2.File ? source : null
4894
+ const loaded = viewer.get_model_info(id, { measurements: false });
4895
+ const colors = this._options.colors || [];
4896
+ if (!loaded.has_colors && colors.length)
4897
+ viewer.set_color(id, colors[this._records.size % colors.length]);
4898
+ this._adopt(id, {
4899
+ name: source.remote ? loaded.name : source.name,
4900
+ size: source.remote ? loaded.file_bytes : source.size,
4901
+ sourceFile: typeof source.slice === "function" ? source : null
6378
4902
  });
6379
- modelID = id;
6380
- appearanceRequest++;
6381
- arrangeScene();
6382
- renderScene();
6383
- announce(source.name + " is ready.");
6384
- engine.canvas.focus({ preventScroll: true });
4903
+ this.setArrangement(this._arrangement);
4904
+ this._emit({ type: "loaded", id, name: this._records.get(id).name });
6385
4905
  } catch (error) {
6386
4906
  if (source.batch) source.batch.failed = true;
6387
- if (token === generation) {
6388
- if (loadingID !== null) {
6389
- viewer?.remove_model(loadingID);
6390
- loadingID = null;
6391
- }
6392
- showNotice(
6393
- /WebGL/i.test(error.message) ? "3D viewing needs WebGL 2. Try enabling hardware acceleration in your browser, or use another device." : error.message
6394
- );
4907
+ if (token === this._generation) {
4908
+ if (this._loadingID !== null)
4909
+ this._viewer?.remove_model(this._loadingID);
4910
+ this._loadingID = null;
4911
+ this._error(error, source);
6395
4912
  }
6396
4913
  }
6397
4914
  }
6398
4915
  } finally {
6399
- draining = false;
6400
- setBusy(false);
4916
+ this._drainPromise = null;
4917
+ if (this._busy) this._setBusy(false);
6401
4918
  }
6402
4919
  }
6403
- async function openSceneSource(source, token) {
6404
- const engine = await getViewer();
6405
- if (token !== generation) return;
6406
- const controller = new window2.AbortController();
6407
- sceneImport = controller;
4920
+ async _openScene(source, token) {
4921
+ const viewer = await this.ready();
4922
+ if (token !== this._generation) return;
4923
+ const controller = this._sceneImport = new AbortController();
6408
4924
  try {
6409
4925
  const chosen = source.batch?.files || [];
6410
4926
  const cached = [
6411
4927
  ...new Set(
6412
- [...records.values()].map((r) => r.sourceFile).filter(Boolean)
4928
+ [...this._records.values()].map((r) => r.sourceFile).filter(Boolean)
6413
4929
  )
6414
4930
  ];
6415
4931
  const companions = [
@@ -6418,498 +4934,69 @@ function mountViewstlApp(root, options = {}) {
6418
4934
  (f) => !chosen.some((s) => s.name === f.name) && cached.filter((s) => s.name === f.name).length === 1
6419
4935
  ) : []
6420
4936
  ];
6421
- const result = await engine.import_scene(
4937
+ const result = await viewer.import_scene(
6422
4938
  source.sceneFile || source.remote,
6423
4939
  {
6424
4940
  format: source.scene || sceneFormat(source.name),
6425
4941
  signal: controller.signal,
6426
4942
  files: companions,
6427
- on_progress(p) {
6428
- if (token !== generation || disposed) return;
6429
- $("loading-label").textContent = p.phase === "opening-model" ? `Opening scene model ${p.loaded + 1} of ${p.total}\u2026` : p.phase === "complete" ? "Restoring scene\u2026" : p.total ? `Reading scene\u2026 ${Math.min(99, Math.floor(p.loaded / p.total * 100))}%` : "Reading scene\u2026";
4943
+ on_progress: (progress) => {
4944
+ if (token === this._generation)
4945
+ this._emit({ type: "progress", scene: true, progress });
6430
4946
  }
6431
4947
  }
6432
4948
  );
6433
- if (token !== generation) return;
4949
+ if (token !== this._generation) return;
6434
4950
  for (const file of result.used_files) source.batch?.used.add(file);
6435
4951
  for (const id of result.model_ids) {
6436
- const m = engine._models.get(id), g = m.geometry;
6437
- const bounds = Object.fromEntries(
6438
- ["min", "max"].map((key) => [
6439
- key,
6440
- g.bounds[key].map((v, i) => (v - m.center[i]) * m.scaleVector[i])
6441
- ])
6442
- );
6443
- const record = {
4952
+ const m = viewer._models.get(id);
4953
+ this._adopt(
6444
4954
  id,
6445
- name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6446
- size: g.file_bytes ?? m.local_file?.size ?? null,
6447
- units: g.units || m.units,
6448
- bounds,
6449
- position: { x: 0, y: 0, z: 0 },
6450
- rotation: {
6451
- x: m.rotationx * 180 / Math.PI,
6452
- y: m.rotationy * 180 / Math.PI,
6453
- z: m.rotationz * 180 / Math.PI
4955
+ {
4956
+ sourceFile: result.used_files.includes(m.local_file) ? m.local_file : null
6454
4957
  },
6455
- embedded: !!g.colors,
6456
- color: m.color || (g.colors ? null : "#909090"),
6457
- estimated: g.triangles * 224,
6458
- sourceFile: result.used_files.includes(m.local_file) ? m.local_file : null
6459
- };
6460
- const offset = enginePosition(record);
6461
- for (const axis of ["x", "y", "z"])
6462
- record.position[axis] = m[axis] - offset[axis];
6463
- record.originalPosition = { ...record.position };
6464
- record.originalRotation = { ...record.rotation };
6465
- record.originalColor = record.color;
6466
- records.set(id, record);
6467
- }
6468
- nextID = Math.max(nextID, engine.max_model_id + 1);
6469
- modelID = result.model_ids.at(-1);
6470
- appearanceRequest++;
6471
- $("scene-arrangement").value = "custom";
6472
- gridEnabled = !!engine.grid;
6473
- gridHeight = engine.grid?.height || 0;
6474
- gridSpacing = engine.grid?.spacing || 1;
6475
- $("grid-button").setAttribute("aria-pressed", String(gridEnabled));
6476
- setBackground(engine.bg_color);
6477
- renderScene();
6478
- announce(
6479
- source.name + " opened. " + result.model_ids.length + " models added."
6480
- );
6481
- engine.canvas.focus({ preventScroll: true });
6482
- } finally {
6483
- if (sceneImport === controller) sceneImport = null;
6484
- }
6485
- }
6486
- root.querySelectorAll("[data-open-file]").forEach((b) => listen(b, "click", () => $("file-input").click()));
6487
- for (const button of shortcutButtons)
6488
- listen(button, "click", () => {
6489
- if (button.disabled || busy || !records.size) return;
6490
- const action = button.dataset.viewstlShortcut;
6491
- if (action === "save") $("save-button")?.click();
6492
- else if (action === "select") {
6493
- setSelectionMode(!selectionMode);
6494
- closePanels();
6495
- announce(
6496
- selectionMode ? "Selection mode. Click a model; Escape returns to viewing." : "Viewing mode. Drag to rotate the scene."
4958
+ true
6497
4959
  );
6498
- } else if ([
6499
- "front",
6500
- "back",
6501
- "left",
6502
- "right",
6503
- "top",
6504
- "bottom",
6505
- "isometric"
6506
- ].includes(action)) {
6507
- setMoveMode(false);
6508
- setSelectionMode(false);
6509
- viewer.set_camera_view(action);
6510
- toolsUI?.sync();
6511
- announce(button.title || action + " view");
6512
4960
  }
6513
- });
6514
- listen(window2, "resize", () => {
6515
- if (selectionMode && !shortcutButtons.some(
6516
- (button) => button.dataset.viewstlShortcut === "select" && button.getClientRects().length
6517
- ))
6518
- setSelectionMode(false);
6519
- });
6520
- listen($("file-input"), "change", () => {
6521
- const files = [...$("file-input").files];
6522
- $("file-input").value = "";
6523
- addFiles(files);
6524
- });
6525
- $("sample-button").hidden = !sampleURL;
6526
- listen($("sample-button"), "click", () => {
6527
- if (sampleURL)
6528
- void addFiles([
6529
- { name: options.sample_name || "Sample.stl", url: sampleURL }
6530
- ]);
6531
- });
6532
- listen($("close-model"), "click", () => removeModel(modelID));
6533
- listen($("cancel-load"), "click", cancelLoading);
6534
- listen($("clear-scene"), "click", clearScene);
6535
- listen($("dismiss-notice"), "click", () => {
6536
- $("notice").hidden = true;
6537
- });
6538
- listen($("fit-button"), "click", () => viewer?.set_auto_zoom());
6539
- listen($("scene-arrangement"), "change", arrangeScene);
6540
- listen($("model-list"), "click", (event) => {
6541
- const b = event.target.closest("button");
6542
- if (!b) return;
6543
- if (b.dataset.removeModel !== void 0)
6544
- removeModel(Number(b.dataset.removeModel));
6545
- else if (b.dataset.selectModel !== void 0)
6546
- selectModel(Number(b.dataset.selectModel));
6547
- else if (b.dataset.colorModel !== void 0) {
6548
- selectModel(Number(b.dataset.colorModel));
6549
- togglePanel("colors");
6550
- }
6551
- });
6552
- async function applyView() {
6553
- if (modelID === null || busy) return;
6554
- const request = ++appearanceRequest;
6555
- const display = modeInputs.find((input) => input.checked).value;
6556
- modeInputs.forEach((input) => {
6557
- input.disabled = true;
6558
- });
6559
- $("smooth-shading").disabled = true;
6560
- $("view-status").textContent = "Preparing view\u2026";
6561
- $("notice").hidden = true;
6562
- try {
6563
- viewer.set_view(modelID, {
6564
- display,
6565
- smooth_shading: $("smooth-shading").checked
4961
+ this._nextID = Math.max(this._nextID, viewer.max_model_id + 1);
4962
+ this._arrangement = "custom";
4963
+ this._grid = {
4964
+ enabled: !!viewer.grid,
4965
+ spacing: viewer.grid?.spacing || 1,
4966
+ height: viewer.grid?.height || 0
4967
+ };
4968
+ this._emit({
4969
+ type: "scene-loaded",
4970
+ name: source.name,
4971
+ ids: result.model_ids
6566
4972
  });
6567
- await viewer.when_idle();
6568
4973
  } finally {
6569
- if (request === appearanceRequest) {
6570
- syncControls();
6571
- announce($("view-label").textContent + " view ready.");
6572
- }
4974
+ if (this._sceneImport === controller) this._sceneImport = null;
6573
4975
  }
6574
4976
  }
6575
- modeInputs.forEach((input) => listen(input, "change", applyView));
6576
- listen($("smooth-shading"), "change", applyView);
6577
- for (const name of panelNames) {
6578
- listen($(name + "-button"), "click", () => {
6579
- if (name === "info") populateInfo();
6580
- togglePanel(name);
6581
- if (name === "section" && !$("section-panel").hidden && !$("section-enabled").checked) {
6582
- $("section-enabled").checked = true;
6583
- applySection();
6584
- }
6585
- });
6586
- listen($("close-" + name), "click", () => {
6587
- closePanel(name);
6588
- if (name === "colors") {
6589
- togglePanel("view");
6590
- }
6591
- $(name + "-button").focus();
6592
- });
4977
+ cancelLoading() {
4978
+ this._assertActive();
4979
+ this._generation++;
4980
+ this._queue.length = 0;
4981
+ this._sceneImport?.abort();
4982
+ if (this._loadingID !== null) this._viewer?.remove_model(this._loadingID);
4983
+ this._loadingID = null;
4984
+ this._setBusy(false);
6593
4985
  }
6594
- function applySection() {
6595
- if (modelID === null) return;
6596
- const enabled = $("section-enabled").checked;
6597
- viewer.set_section(
6598
- modelID,
6599
- enabled ? {
6600
- axis: $("section-axis").value,
6601
- position: Number($("section-position").value) / 100,
6602
- flip: $("section-flip").getAttribute("aria-pressed") === "true"
6603
- } : null
6604
- );
6605
- $("section-value").textContent = $("section-position").value + "%";
6606
- $("section-button").setAttribute("aria-pressed", String(enabled));
6607
- }
6608
- listen($("section-enabled"), "change", applySection);
6609
- listen($("section-axis"), "change", applySection);
6610
- listen($("section-position"), "input", applySection);
6611
- listen($("section-flip"), "click", () => {
6612
- $("section-flip").setAttribute(
6613
- "aria-pressed",
6614
- String($("section-flip").getAttribute("aria-pressed") !== "true")
6615
- );
6616
- applySection();
6617
- });
6618
- listen($("grid-button"), "click", () => {
6619
- gridEnabled = !gridEnabled;
6620
- updateGrid();
6621
- });
6622
- function setColor(color) {
6623
- if (modelID === null) return;
6624
- viewer.set_color(modelID, color);
6625
- records.get(modelID).color = color;
6626
- syncColors();
6627
- renderList();
6628
- }
6629
- listen($("model-color"), "input", () => setColor($("model-color").value));
6630
- listen(
6631
- $("restore-color"),
6632
- "click",
6633
- () => setColor(records.get(modelID)?.originalColor)
6634
- );
6635
- root.querySelectorAll("[data-model-color]").forEach((b) => listen(b, "click", () => setColor(b.dataset.modelColor)));
6636
- function setBackground(color) {
6637
- background = color;
6638
- const rgb = color === "transparent" ? [255, 255, 255] : [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16));
6639
- $("workspace").classList.toggle(
6640
- "dark-background",
6641
- rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722 < 115
6642
- );
6643
- viewer?.set_bg_color(color);
6644
- syncColors();
4986
+ clear() {
4987
+ this.cancelLoading();
4988
+ for (const binding of this._bindings) binding.setEnabled(false);
4989
+ this._viewer?.clean();
4990
+ this._records.clear();
4991
+ this._arrangement = "original";
4992
+ this.setGrid(false, 1, 0);
6645
4993
  }
6646
- listen(
6647
- $("background-color"),
6648
- "input",
6649
- () => setBackground($("background-color").value)
6650
- );
6651
- root.querySelectorAll("[data-background]").forEach(
6652
- (b) => listen(b, "click", () => setBackground(b.dataset.background))
6653
- );
6654
- listen($("image-button"), "click", async () => {
6655
- if (modelID === null) return;
6656
- const name = records.size > 1 ? "viewstl-scene.png" : records.get(modelID).name.replace(/\.[^.]+$/, "") + ".png";
6657
- try {
6658
- const blob = await viewer.get_image_blob("image/png");
6659
- if (disposed) return;
6660
- const url = URL2.createObjectURL(blob), link = document2.createElement("a");
6661
- link.href = url;
6662
- link.download = name;
6663
- link.click();
6664
- setTimeout(() => URL2.revokeObjectURL(url), 1e3);
6665
- announce("Image ready to download.");
6666
- } catch {
6667
- showNotice("The image could not be saved. Please try again.");
6668
- }
6669
- });
6670
- let dragDepth = 0;
6671
- const acceptedDrag = (event) => {
6672
- const kind = dropKind(event.dataTransfer);
6673
- if (kind === "files") return kind;
6674
- if (!options.url_import_endpoint || event.target?.isContentEditable || event.target?.closest?.("input, textarea"))
6675
- return null;
6676
- return kind;
6677
- };
6678
- const clearDrop = () => {
6679
- dragDepth = 0;
6680
- $("drop-overlay").hidden = true;
6681
- };
6682
- listen(dropTarget, "dragenter", (event) => {
6683
- if (acceptedDrag(event)) {
6684
- event.preventDefault();
6685
- dragDepth++;
6686
- $("drop-overlay").hidden = false;
6687
- }
6688
- });
6689
- listen(dropTarget, "dragover", (event) => {
6690
- if (acceptedDrag(event)) {
6691
- event.preventDefault();
6692
- event.dataTransfer.dropEffect = "copy";
6693
- }
6694
- });
6695
- listen(dropTarget, "dragleave", (event) => {
6696
- if (dragDepth > 0 && --dragDepth === 0) clearDrop();
6697
- });
6698
- listen(dropTarget, "drop", (event) => {
6699
- const kind = acceptedDrag(event);
6700
- clearDrop();
6701
- if (!kind) return;
6702
- event.preventDefault();
6703
- try {
6704
- if (kind === "files")
6705
- addFiles([...event.dataTransfer.files]).catch(
6706
- (error) => showNotice(error.message)
6707
- );
6708
- else
6709
- for (const url of droppedURLs(event.dataTransfer))
6710
- openURL(url).catch((error) => showNotice(error.message));
6711
- } catch (error) {
6712
- showNotice(error.message);
6713
- }
6714
- });
6715
- listen(dropTarget, "dragend", clearDrop);
6716
- listen(window2, "blur", clearDrop);
6717
- root.querySelectorAll("[data-dialog]").forEach((b) => listen(b, "click", () => $(b.dataset.dialog).showModal()));
6718
- root.querySelectorAll("dialog").forEach(
6719
- (dialog) => listen(dialog, "click", (event) => {
6720
- if (event.target === dialog) {
6721
- const r = dialog.getBoundingClientRect();
6722
- if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom)
6723
- if (dialog === $("save-dialog")) exportUI.close();
6724
- else dialog.close();
6725
- }
6726
- })
6727
- );
6728
- listen(root, "keydown", (event) => {
6729
- if (event.key !== "Escape") return;
6730
- if ($("save-dialog")?.open) return;
6731
- if (selectionMode) {
6732
- setSelectionMode(false);
6733
- closePanels();
6734
- announce("Viewing mode. Drag to rotate the scene.");
6735
- return;
6736
- }
6737
- if (moveMode) {
6738
- setMoveMode(false);
6739
- announce("Move tool closed.");
6740
- return;
6741
- }
6742
- const active = panelNames.find((name) => !$(name + "-panel").hidden);
6743
- if (active) {
6744
- closePanels();
6745
- if (active === "colors") togglePanel("view");
6746
- $(active + "-button").focus();
6747
- }
6748
- });
6749
- listen(root, "pointerdown", (event) => {
6750
- if (!event.target.closest(
6751
- ".view-panel,.info-panel,.toolbar,.scene-heading-button,.move-mode"
6752
- ))
6753
- closePanels();
6754
- });
6755
- $("memory-budget").textContent = `Scene memory budget: ${fileSize(policy.budget)}. Half of ${policy.reported ? "the memory estimate reported by your browser" : "a conservative 2 GiB estimate because this browser does not report device memory"} is kept in reserve. This is an estimate, not a guarantee of available memory.`;
6756
- $("scene-arrangement").value = "original";
6757
- $("notice").hidden = true;
6758
- $("drop-overlay").hidden = true;
6759
- if ($("save-button"))
6760
- exportUI = bindAppExport({
6761
- $,
6762
- listen,
6763
- window: window2,
6764
- getState: () => ({
6765
- busy,
6766
- selectedId: modelID,
6767
- models: [...records.values()]
6768
- }),
6769
- getViewer: () => viewer,
6770
- onOpen: () => {
6771
- setMoveMode(false);
6772
- closePanels();
6773
- },
6774
- announce
6775
- });
6776
- if ($("print-button"))
6777
- printUI = bindAppPrint({
6778
- $,
6779
- listen,
6780
- window: window2,
6781
- getState: () => ({
6782
- busy,
6783
- selectedId: modelID,
6784
- models: [...records.values()]
6785
- }),
6786
- getViewer: () => viewer,
6787
- onOpen: () => {
6788
- setMoveMode(false);
6789
- closePanels();
6790
- },
6791
- announce
6792
- });
6793
- if ($("animation-details"))
6794
- animationUI = bindAppAnimation({
6795
- $,
6796
- listen,
6797
- window: window2,
6798
- getState: () => ({
6799
- busy,
6800
- selectedId: modelID,
6801
- models: [...records.values()]
6802
- }),
6803
- getViewer: () => viewer,
6804
- beforePlay(id) {
6805
- setMoveMode(false);
6806
- prepareManual(id);
6807
- centerAnimationPivot(records.get(id), viewer._models.get(id));
6808
- $("scene-arrangement").value = "custom";
6809
- },
6810
- refreshPose: refreshAnimatedPose,
6811
- announce
6812
- });
6813
- if ($("size-details"))
6814
- toolsUI = bindAppTools({
6815
- $,
6816
- listen,
6817
- window: window2,
6818
- getViewer: () => viewer,
6819
- getState: () => ({ selectedId: modelID, busy }),
6820
- refresh: renderScene,
6821
- announce,
6822
- changeSize(operation) {
6823
- setMoveMode(false);
6824
- const record = records.get(modelID);
6825
- syncAnimatedRecord(record, viewer._models.get(modelID));
6826
- operation();
6827
- syncAnimatedRecord(record, viewer._models.get(modelID));
6828
- $("scene-arrangement").value = "custom";
6829
- renderScene();
6830
- },
6831
- duplicate() {
6832
- const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6833
- const record = {
6834
- ...original,
6835
- id,
6836
- name: m.display_name,
6837
- position: { ...original.position },
6838
- rotation: { ...original.rotation }
6839
- };
6840
- syncAnimatedRecord(record, m);
6841
- record.originalPosition = { ...record.position };
6842
- record.originalRotation = { ...record.rotation };
6843
- records.set(id, record);
6844
- nextID = Math.max(nextID, id + 1);
6845
- $("scene-arrangement").value = "custom";
6846
- selectModel(id);
6847
- setMoveMode(true, "move", true);
6848
- announce("Copy selected at the same position. Drag to move it.");
6849
- }
6850
- });
6851
- updateGrid();
6852
- setBusy(false);
6853
- setMoveMode(false);
6854
- const api = {
6855
- openFiles: addFiles,
6856
- openURL,
6857
- cancelLoading() {
6858
- assertActive();
6859
- cancelLoading();
6860
- },
6861
- clear() {
6862
- assertActive();
6863
- clearScene();
6864
- },
6865
- select(id) {
6866
- assertActive();
6867
- selectModel(id);
6868
- },
6869
- remove(id) {
6870
- assertActive();
6871
- removeModel(id);
6872
- },
6873
- getState() {
6874
- assertActive();
6875
- for (const record of records.values())
6876
- syncAnimatedRecord(record, viewer?._models.get(record.id));
6877
- return {
6878
- selectedId: modelID,
6879
- busy,
6880
- arrangement: $("scene-arrangement").value,
6881
- memoryBudget: policy.budget,
6882
- models: [...records.values()].map((record) => ({
6883
- id: record.id,
6884
- name: record.name,
6885
- size: record.size,
6886
- units: record.units,
6887
- position: { ...record.position },
6888
- rotation: { ...record.rotation },
6889
- color: record.color,
6890
- animation: viewer.get_model_animation_state(record.id)
6891
- }))
6892
- };
6893
- },
6894
- dispose() {
6895
- if (disposed) return;
6896
- exportUI?.dispose();
6897
- printUI?.dispose();
6898
- animationUI?.dispose();
6899
- toolsUI?.dispose();
6900
- cancelLoading();
6901
- setMoveMode(false);
6902
- appearanceRequest++;
6903
- disposed = true;
6904
- abort.abort();
6905
- modelMover?.dispose();
6906
- viewer?.dispose();
6907
- records.clear();
6908
- if (options.id_prefix)
6909
- modeInputs.forEach((input) => input.name = "view-mode");
6910
- mountedRoots.delete(root);
6911
- }
6912
- };
6913
- mountedRoots.set(root, api);
6914
- return api;
6915
- }
4994
+ dispose() {
4995
+ if (this._disposed) return;
4996
+ this.cancelLoading();
4997
+ for (const binding of this._bindings) binding.dispose();
4998
+ this._disposed = true;
4999
+ this._viewer?.dispose();
5000
+ this._records.clear();
5001
+ }
5002
+ };