@omrips/viewstl 2.0.0-rc.8 → 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.8, 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.8";
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);
@@ -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,1506 +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, 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
- selectionMode = !!enabled && !busy && records.size > 0;
5641
- $("workspace").classList.toggle("selecting-model-mode", selectionMode);
5642
- setMoveMode(selectionMode, "move", true);
5643
- syncShortcuts();
5644
- }
5645
- function editModelAt(event) {
5646
- const hit = viewer.pick_model(event.clientX, event.clientY);
5647
- if (!hit || !records.has(hit.id)) return;
5648
- event.preventDefault();
5649
- selectModel(hit.id);
5650
- closePanels();
5651
- $("scene-panel").hidden = false;
5652
- $("scene-button").setAttribute("aria-expanded", "true");
5653
- $("position-details").open = true;
5654
- syncSelection();
5655
- announce(
5656
- records.get(hit.id).name + " selected. Choose Move, Rotate or Depth."
5657
- );
4515
+ get grid() {
4516
+ return { ...this._grid };
5658
4517
  }
5659
- function closePanel(name) {
5660
- $(name + "-panel").hidden = true;
5661
- $(name + "-button").setAttribute("aria-expanded", "false");
5662
- syncSelection();
5663
- }
5664
- function closePanels() {
5665
- panelNames.forEach(closePanel);
5666
- }
5667
- function togglePanel(name) {
5668
- const open = $(name + "-panel").hidden;
5669
- closePanels();
5670
- $(name + "-panel").hidden = !open;
5671
- $(name + "-button").setAttribute("aria-expanded", String(open));
5672
- syncSelection();
5673
- if (open && name === "info") populateInfo();
5674
- if (open && (name === "scene" || name === "view")) toolsUI?.sync();
5675
- }
5676
- function fileSize(size) {
5677
- 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`;
5678
- }
5679
- function syncColors() {
5680
- const record = records.get(modelID), color = /^#[a-f0-9]{6}$/i.test(record?.color) ? record.color : "#ffffff";
5681
- $("model-color").value = color;
5682
- $("model-color-value").textContent = record?.color === null ? "File colors" : color.toUpperCase();
5683
- $("restore-color").textContent = record?.embedded ? "Restore file colors" : "Restore original color";
5684
- root.querySelectorAll("[data-model-color]").forEach(
5685
- (b) => b.setAttribute(
5686
- "aria-pressed",
5687
- String(b.dataset.modelColor === record?.color)
5688
- )
5689
- );
5690
- root.querySelectorAll("[data-background]").forEach(
5691
- (b) => b.setAttribute(
5692
- "aria-pressed",
5693
- String(b.dataset.background === background)
5694
- )
5695
- );
5696
- $("background-color").value = background === "transparent" ? "#fafbfb" : background;
5697
- $("background-value").textContent = background === "transparent" ? "Default" : background.toUpperCase();
5698
- }
5699
- function syncControls() {
5700
- syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
5701
- const info = modelID === null ? null : viewer.get_model_info(modelID, { measurements: false }), mode = info?.display === "smooth" ? "flat" : info?.display || "flat";
5702
- for (const input of modeInputs) {
5703
- input.checked = input.value === mode;
5704
- input.disabled = busy;
5705
- }
5706
- $("view-label").textContent = toolsUI ? "View" : viewNames[mode];
5707
- $("view-button").title = "View \xB7 " + viewNames[mode];
5708
- $("smooth-shading").checked = info?.smooth_shading || false;
5709
- $("smooth-shading").disabled = busy || mode === "wireframe";
5710
- $("view-status").textContent = mode === "wireframe" ? "Smooth shading is available in surface views." : "Shading changes the appearance, not the geometry.";
5711
- const text = mode === "orientation" ? "Blue: front faces \xB7 Red: back faces" : "";
5712
- $("view-description").textContent = text;
5713
- $("view-description").hidden = !text;
5714
- $("color-mode-note").hidden = mode !== "orientation";
5715
- const section = info?.section;
5716
- $("section-enabled").checked = !!section;
5717
- $("section-axis").value = section?.axis || "z";
5718
- $("section-position").value = (section?.position ?? 0.5) * 100;
5719
- $("section-value").textContent = $("section-position").value + "%";
5720
- $("section-flip").setAttribute(
5721
- "aria-pressed",
5722
- String(section?.flip || false)
5723
- );
5724
- $("section-button").setAttribute("aria-pressed", String(!!section));
5725
- root.querySelectorAll("[data-selected-name]").forEach((el) => el.textContent = records.get(modelID)?.name || "");
5726
- syncColors();
5727
- syncPosition();
5728
- exportUI?.sync();
5729
- printUI?.sync();
5730
- animationUI?.sync();
5731
- toolsUI?.sync();
5732
- syncShortcuts();
5733
- }
5734
- function populateInfo() {
5735
- if (modelID === null) return;
5736
- const info = viewer.get_model_info(modelID, { measurements: false }), record = records.get(modelID), factor = viewer.display_units === "inch" ? 25.4 : 1;
5737
- $("info-triangles").textContent = info.triangles.toLocaleString();
5738
- $("info-dimensions").textContent = [info.dims.x, info.dims.y, info.dims.z].map((x) => formatNumber.format(x / factor)).join(" \xD7 ") + (factor === 1 ? " mm" : " in");
5739
- $("info-size").textContent = fileSize(record.size);
5740
- const size = viewer.get_model_size(modelID);
5741
- $("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.";
5742
- toolsUI?.syncInfo();
5743
- }
5744
- function renderList() {
5745
- $("model-list").replaceChildren(
5746
- ...[...records.values()].map((record) => {
5747
- const row = document2.createElement("li"), color = document2.createElement("button"), pick = document2.createElement("button"), remove = document2.createElement("button");
5748
- row.className = "model-row";
5749
- row.classList.toggle("selected", record.id === modelID);
5750
- color.className = "model-swatch";
5751
- color.dataset.colorModel = record.id;
5752
- color.style.backgroundColor = record.color || "#b7c5c9";
5753
- color.setAttribute("aria-label", "Color for " + record.name);
5754
- color.title = "Change color";
5755
- pick.className = "model-pick";
5756
- pick.dataset.selectModel = record.id;
5757
- pick.textContent = record.name;
5758
- pick.title = record.name;
5759
- pick.setAttribute("aria-pressed", String(record.id === modelID));
5760
- remove.className = "icon-button";
5761
- remove.dataset.removeModel = record.id;
5762
- remove.textContent = "\xD7";
5763
- remove.setAttribute("aria-label", "Remove " + record.name);
5764
- row.append(color, pick, remove);
5765
- return row;
5766
- })
5767
- );
5768
- $("scene-count").textContent = records.size;
5769
- $("scene-arrangement").disabled = records.size === 0;
5770
- }
5771
- function renderScene() {
5772
- const has = records.size > 0;
5773
- if (!has && selectionMode) setSelectionMode(false);
5774
- $("workspace").classList.toggle("has-models", has);
5775
- $("viewer").hidden = !has && !busy;
5776
- $("empty-state").hidden = has || busy;
5777
- $("file-heading").hidden = !has;
5778
- $("model-controls").hidden = !has;
5779
- $("filename").textContent = records.get(modelID)?.name || "";
5780
- $("filename").title = $("filename").textContent;
5781
- $("open-label").textContent = has ? "Add models" : "Open files";
5782
- $("drop-message").textContent = has ? "Add to your scene." : "Drop it here.";
5783
- renderList();
5784
- syncControls();
5785
- populateInfo();
5786
- if (!has) closePanels();
5787
- syncSelection();
5788
- }
5789
- function setBusy(value) {
5790
- if (value) exportUI?.close();
5791
- if (disposed) return;
5792
- if (value && selectionMode) setSelectionMode(false);
5793
- if (value) setMoveMode(false);
5794
- busy = value;
5795
- $("loading-state").hidden = !value;
5796
- $("workspace").setAttribute("aria-busy", String(value));
5797
- renderScene();
5798
- }
5799
- function selectModel(id) {
5800
- exportUI?.close();
5801
- if (!records.has(id)) return;
5802
- if (selectionMode) modelMover?.setEnabled(false);
5803
- else setMoveMode(false);
5804
- appearanceRequest++;
5805
- modelID = id;
5806
- renderScene();
5807
- if (selectionMode) setMoveMode(true, transformTool, true);
5808
- }
5809
- function placements() {
5810
- return scenePlacement([...records.values()], $("scene-arrangement").value);
5811
- }
5812
- function updateGrid() {
5813
- viewer?.set_infinite_grid(gridEnabled, gridSpacing, gridHeight);
5814
- $("grid-button").setAttribute("aria-pressed", String(gridEnabled));
5815
- }
5816
- function arrangeScene() {
5817
- setMoveMode(false);
5818
- for (const record of records.values())
5819
- syncAnimatedRecord(record, viewer?._models.get(record.id));
5820
- if ($("scene-arrangement").value === "custom") return;
5821
- for (const record of records.values()) prepareManual(record.id);
5822
- if ($("scene-arrangement").value === "original")
5823
- for (const record of records.values())
5824
- record.rotation = {
5825
- ...record.originalRotation || { x: 0, y: 0, z: 0 }
5826
- };
5827
- const positions = placements();
5828
- for (const p of positions) {
5829
- records.get(p.id).position = { x: p.x, y: p.y, z: p.z };
5830
- renderTransform(records.get(p.id));
5831
- }
5832
- if ($("scene-arrangement").value !== "custom" || !records.size)
5833
- gridHeight = sceneFloor([...records.values()], positions);
5834
- syncPosition();
5835
- updateGrid();
5836
- viewer?.set_auto_zoom();
5837
- }
5838
- function syncPosition(preserveFocus = false) {
5839
- const record = records.get(modelID);
5840
- for (const axis of ["x", "y", "z"]) {
5841
- const input = $("position-" + axis);
5842
- if (!preserveFocus || document2.activeElement !== input)
5843
- input.value = Number((record?.position?.[axis] || 0).toPrecision(12));
5844
- input.disabled = !record;
5845
- input.removeAttribute("aria-invalid");
5846
- }
5847
- $("position-error").hidden = true;
5848
- $("position-units").textContent = record?.animationPivot ? "Position of the model center in the scene, in mm." : "Offset from file coordinates, in mm.";
5849
- $("move-model-button").disabled = !record || busy;
5850
- $("reset-position").disabled = !record;
5851
- syncRotation(preserveFocus);
5852
- for (const id of [
5853
- "rotate-model-button",
5854
- "depth-model-button",
5855
- "depth-nearer",
5856
- "depth-farther"
5857
- ])
5858
- $(id).disabled = !record || busy;
4518
+ get memoryPolicy() {
4519
+ return { ...this._policy, limits: { ...this._policy.limits } };
5859
4520
  }
5860
- function applyPosition(id, position, refreshInputs = true) {
5861
- if (!records.has(id) || !Object.values(position).every(
5862
- (v) => Number.isFinite(v) && Math.abs(v) <= 1e12
5863
- ))
5864
- return false;
5865
- records.get(id).position = { ...position };
5866
- renderTransform(records.get(id));
5867
- $("scene-arrangement").value = "custom";
5868
- if (id === modelID && refreshInputs) syncPosition();
5869
- return true;
5870
- }
5871
- function renderTransform(record) {
5872
- const p = enginePosition(record), r = record.rotation;
5873
- viewer.set_rotation(
5874
- record.id,
5875
- ...["x", "y", "z"].map((axis) => r[axis] % 360 * Math.PI / 180)
5876
- );
5877
- viewer.set_position(record.id, p.x, p.y, p.z);
4521
+ _assertActive() {
4522
+ if (this._disposed) throw new Error("ViewSTL session is disposed");
5878
4523
  }
5879
- function selectedTransform() {
5880
- prepareManual(modelID);
5881
- const record = records.get(modelID);
5882
- return record ? { ...record, center: modelCenter(record), radius: modelRadius(record) } : null;
4524
+ _emit(event) {
4525
+ if (!this._disposed) this._call(this._options.on_event, event);
5883
4526
  }
5884
- function syncRotation(preserveFocus = false) {
5885
- const record = records.get(modelID);
5886
- for (const axis of ["x", "y", "z"]) {
5887
- const input = $("rotation-" + axis);
5888
- if (!preserveFocus || document2.activeElement !== input)
5889
- input.value = Number((record?.rotation?.[axis] || 0).toPrecision(10));
5890
- input.disabled = !record;
5891
- input.removeAttribute("aria-invalid");
4527
+ _call(callback, ...args) {
4528
+ try {
4529
+ callback?.(...args);
4530
+ } catch (error) {
4531
+ console.error(error);
5892
4532
  }
5893
- $("rotation-error").hidden = true;
5894
- $("reset-rotation").disabled = !record;
5895
4533
  }
5896
- function applyRotation(id, rotation2, refreshInputs = true) {
5897
- if (!records.has(id) || !Object.values(rotation2).every(
5898
- (v) => Number.isFinite(v) && Math.abs(v) <= 36e4
5899
- ))
5900
- return false;
5901
- records.get(id).rotation = { ...rotation2 };
5902
- renderTransform(records.get(id));
5903
- $("scene-arrangement").value = "custom";
5904
- if (refreshInputs && id === modelID) syncRotation();
5905
- return true;
4534
+ _setBusy(value) {
4535
+ this._busy = value;
4536
+ this._emit({ type: "busy", busy: value });
5906
4537
  }
5907
- const toolLabels = { move: "Move", rotate: "Rotate", depth: "Depth" };
5908
- const toolHints = {
5909
- move: "Drag to move the selected model",
5910
- rotate: "Drag to rotate the selected model",
5911
- depth: "Drag up for nearer, down for farther"
5912
- };
5913
- function syncTool() {
5914
- if (viewer?.canvas) viewer.canvas.dataset.modelTool = transformTool;
5915
- root.querySelectorAll("[data-transform-tab]").forEach(
5916
- (b) => b.setAttribute(
5917
- "aria-pressed",
5918
- String(b.dataset.transformTab === transformTool)
5919
- )
5920
- );
5921
- for (const name of ["move", "rotate", "depth"]) {
5922
- $(name + "-controls").hidden = name !== transformTool;
5923
- $(name + "-model-button").setAttribute(
5924
- "aria-pressed",
5925
- String(moveMode && name === transformTool)
5926
- );
5927
- }
5928
- $("model-tool").value = transformTool;
5929
- $("move-gesture-message").textContent = (selectionMode ? "Click a model to select \xB7 " : "") + toolHints[transformTool] + " \xB7 Scroll to adjust depth";
5930
- }
5931
- function setMoveMode(enabled, tool = transformTool, retainPaused = false) {
5932
- modelMover?.setEnabled(false);
5933
- transformTool = tool;
5934
- moveMode = !!enabled && modelID !== null && !busy;
5935
- if (!moveMode && selectionMode) {
5936
- selectionMode = false;
5937
- $("workspace").classList.remove("selecting-model-mode");
5938
- }
5939
- if (viewer) viewer.controls.enabled = !selectionMode;
5940
- syncShortcuts();
5941
- if (moveMode && !retainPaused) prepareManual(modelID);
5942
- modelMover?.setEnabled(moveMode);
5943
- syncTool();
5944
- $("move-mode").hidden = !moveMode;
5945
- $("move-mode-name").textContent = records.get(modelID)?.name || "";
5946
- $("workspace").classList.toggle("moving-model-mode", moveMode);
5947
- if (viewer?.canvas)
5948
- viewer.canvas.setAttribute(
5949
- "aria-label",
5950
- moveMode ? (selectionMode ? "Click a model to select. " : "") + toolHints[transformTool] + ". Scroll changes only its depth. Escape exits." : "3D model viewer. Drag to rotate, shift-drag to pan, scroll to zoom."
5951
- );
5952
- if (moveMode) {
5953
- closePanels();
5954
- viewer.canvas.focus({ preventScroll: true });
5955
- announce(
5956
- toolLabels[transformTool] + " tool for " + records.get(modelID).name + ". Done or Escape returns to viewing."
5957
- );
5958
- }
5959
- syncSelection();
5960
- }
5961
- for (const axis of ["x", "y", "z"]) {
5962
- const input = $("rotation-" + axis);
5963
- listen(input, "input", () => {
5964
- if (modelID === null || !input.value.trim()) return;
5965
- prepareManual(modelID);
5966
- if (applyRotation(
5967
- modelID,
5968
- { ...records.get(modelID).rotation, [axis]: input.valueAsNumber },
5969
- false
5970
- )) {
5971
- input.removeAttribute("aria-invalid");
5972
- $("rotation-error").hidden = true;
5973
- }
5974
- });
5975
- listen(input, "change", () => {
5976
- if (modelID === null) return;
5977
- prepareManual(modelID);
5978
- if (!input.value.trim() || !applyRotation(modelID, {
5979
- ...records.get(modelID).rotation,
5980
- [axis]: input.valueAsNumber
5981
- })) {
5982
- input.setAttribute("aria-invalid", "true");
5983
- $("rotation-error").hidden = false;
5984
- } else announce("Model rotation 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
5985
4544
  });
5986
4545
  }
5987
- listen($("reset-rotation"), "click", () => {
5988
- setMoveMode(false);
5989
- if (modelID !== null) {
5990
- prepareManual(modelID);
5991
- applyRotation(modelID, {
5992
- ...records.get(modelID).originalRotation || { x: 0, y: 0, z: 0 }
5993
- });
5994
- announce("Selected model rotation reset.");
5995
- }
5996
- });
5997
- for (const name of ["move", "rotate", "depth"])
5998
- listen(
5999
- $(name + "-model-button"),
6000
- "click",
6001
- () => setMoveMode(!moveMode || transformTool !== name, name)
6002
- );
6003
- for (const [id, amount] of [
6004
- ["depth-nearer", -0.12],
6005
- ["depth-farther", 0.12]
6006
- ])
6007
- listen($(id), "click", () => {
6008
- const model = selectedTransform();
6009
- if (!model) return;
6010
- applyPosition(
6011
- model.id,
6012
- depthPosition(model, viewer.get_camera_state(), amount)
6013
- );
6014
- announce(
6015
- amount < 0 ? "Selected model moved nearer." : "Selected model moved farther."
6016
- );
6017
- });
6018
- root.querySelectorAll("[data-transform-tab]").forEach(
6019
- (b) => listen(
6020
- b,
6021
- "click",
6022
- () => setMoveMode(selectionMode, b.dataset.transformTab, true)
6023
- )
6024
- );
6025
- listen(
6026
- $("model-tool"),
6027
- "change",
6028
- () => setMoveMode(true, $("model-tool").value, selectionMode)
6029
- );
6030
- for (const axis of ["x", "y", "z"]) {
6031
- const input = $("position-" + axis);
6032
- listen(input, "input", () => {
6033
- if (modelID === null || !input.value.trim()) return;
6034
- prepareManual(modelID);
6035
- if (applyPosition(
6036
- modelID,
6037
- { ...records.get(modelID).position, [axis]: input.valueAsNumber },
6038
- false
6039
- )) {
6040
- input.removeAttribute("aria-invalid");
6041
- $("position-error").hidden = true;
6042
- }
6043
- });
6044
- listen(input, "change", () => {
6045
- if (modelID === null) return;
6046
- prepareManual(modelID);
6047
- if (!input.value.trim() || !applyPosition(modelID, {
6048
- ...records.get(modelID).position,
6049
- [axis]: input.valueAsNumber
6050
- })) {
6051
- input.setAttribute("aria-invalid", "true");
6052
- $("position-error").hidden = false;
6053
- } else announce("Model position updated.");
6054
- });
6055
- }
6056
- listen($("finish-move"), "click", () => {
6057
- setMoveMode(false);
6058
- viewer?.canvas.focus({ preventScroll: true });
6059
- });
6060
- listen($("reset-position"), "click", () => {
6061
- setMoveMode(false);
6062
- if (modelID !== null) {
6063
- prepareManual(modelID);
6064
- applyPosition(modelID, {
6065
- ...records.get(modelID).originalPosition || { x: 0, y: 0, z: 0 }
6066
- });
6067
- announce("Selected model restored to its original position.");
6068
- }
6069
- });
6070
- listen(window2, "blur", () => {
6071
- if (moveMode) setMoveMode(false);
6072
- });
6073
- function cancelLoading() {
6074
- generation++;
6075
- queue.length = 0;
6076
- sceneImport?.abort();
6077
- if (loadingID !== null) {
6078
- viewer?.remove_model(loadingID);
6079
- loadingID = null;
6080
- }
6081
- setBusy(false);
6082
- announce("Opening cancelled.");
6083
- }
6084
- function clearScene() {
6085
- exportUI?.close();
6086
- setMoveMode(false);
6087
- cancelLoading();
6088
- appearanceRequest++;
6089
- viewer?.clean();
6090
- records.clear();
6091
- modelID = null;
6092
- gridEnabled = false;
6093
- gridHeight = 0;
6094
- gridSpacing = 1;
6095
- $("scene-arrangement").value = "original";
6096
- updateGrid();
6097
- $("notice").hidden = true;
6098
- renderScene();
6099
- announce("Scene cleared.");
6100
- }
6101
- function removeModel(id) {
6102
- if (!records.has(id)) return;
6103
- exportUI?.close();
6104
- const name = records.get(id).name;
6105
- setMoveMode(false);
6106
- viewer.remove_model(id);
6107
- records.delete(id);
6108
- appearanceRequest++;
6109
- if (modelID === id) modelID = [...records.keys()].at(-1) ?? null;
6110
- arrangeScene();
6111
- renderScene();
6112
- announce(name + " removed.");
6113
- }
6114
- function prepareManual(id) {
6115
- if (!records.has(id)) return;
6116
- syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6117
- viewer?.animate_model(id, null);
6118
- }
6119
- function refreshAnimatedPose() {
6120
- if (disposed || modelID === null) return;
6121
- syncAnimatedRecord(records.get(modelID), viewer?._models.get(modelID));
6122
- if (!$("info-panel").hidden) populateInfo();
6123
- if (!$("scene-panel").hidden && $("position-details").open)
6124
- syncPosition(true);
6125
- }
6126
- function animationChanged(id, state, reason) {
6127
- if (disposed) return;
6128
- if (reason === "restored" && state.status === "running" && window2.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
6129
- viewer.pause_model_animation(id);
6130
- syncAnimatedRecord(records.get(id), viewer?._models.get(id));
6131
- if (["started", "restored", "resumed"].includes(reason))
6132
- $("scene-arrangement").value = "custom";
6133
- if (id === modelID) refreshAnimatedPose();
6134
- animationUI?.sync();
6135
- printUI?.sync();
6136
- options.animation_state_callback?.(id, state, reason);
6137
- }
6138
- async function getViewer() {
6139
- if (!viewerPromise)
6140
- viewerPromise = Promise.resolve().then(async () => {
6141
- if (disposed) throw new Error("ViewSTL app is disposed");
6142
- viewer = new StlViewer($("viewer"), {
6143
- ...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,
6144
4554
  worker_count: 1,
6145
- worker_timeout: options.worker_timeout ?? 6e5,
6146
- worker_url: options.worker_url,
6147
- worker_factory: options.worker_factory,
6148
- url_import_endpoint: options.url_import_endpoint,
4555
+ worker_timeout: 6e5,
6149
4556
  center_models: false,
6150
- bg_color: background,
6151
4557
  allow_drag_and_drop: false,
6152
- animation_state_callback: animationChanged,
6153
- animation_complete_callback: (...args) => {
6154
- if (!disposed) options.animation_complete_callback?.(...args);
6155
- },
6156
- camera_state: {
6157
- position: { x: 90, y: -130, z: 90 },
6158
- target: { x: 0, y: 0, z: 0 },
6159
- up: { x: 0, y: 0, z: 1 }
6160
- },
4558
+ ...supplied,
6161
4559
  loading_progress_callback: (status) => {
6162
- if (disposed || !busy || sceneImport) return;
6163
- const p = status[loadingID];
6164
- $("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);
6165
4567
  },
6166
4568
  model_loaded_callback: (id) => {
6167
- lastLoadedID = id;
4569
+ this._lastLoadedID = id;
4570
+ this._call(supplied.model_loaded_callback, id);
6168
4571
  },
6169
4572
  load_error_callback: (message) => {
6170
- showNotice(
6171
- /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}`
6172
- );
6173
- }
6174
- });
6175
- await viewer.when_ready;
6176
- if (disposed) throw new Error("ViewSTL app is disposed");
6177
- modelMover = bindModelMove(viewer.canvas, {
6178
- getCamera: () => viewer.get_camera_state(),
6179
- getModel: (event) => {
6180
- if (selectionMode && event) {
6181
- const hit = viewer.pick_model(event.clientX, event.clientY);
6182
- if (!hit || !records.has(hit.id)) return null;
6183
- if (hit.id !== modelID) selectModel(hit.id);
6184
- }
6185
- return selectedTransform();
4573
+ this._error(new Error(message));
4574
+ this._call(supplied.load_error_callback, message);
6186
4575
  },
6187
- getTool: () => transformTool,
6188
- onRotate: (id, rotation2) => applyRotation(id, rotation2),
6189
- onMove: (id, position) => applyPosition(id, position),
6190
- onStart: () => {
6191
- dragArrangement = $("scene-arrangement").value;
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);
6192
4581
  },
6193
- onEnd: (id, cancelled) => {
6194
- if (cancelled) $("scene-arrangement").value = dragArrangement;
6195
- announce(cancelled ? "Move cancelled." : "Model updated.");
4582
+ animation_complete_callback: (...args) => {
4583
+ if (!this._disposed)
4584
+ this._call(supplied.animation_complete_callback, ...args);
6196
4585
  }
6197
4586
  });
6198
- listen(viewer.canvas, "dblclick", (event) => {
6199
- if (busy || selectionMode || event.button !== 0) return;
6200
- editModelAt(event);
6201
- });
4587
+ await viewer.when_ready;
4588
+ this._assertActive();
4589
+ this._emit({ type: "ready" });
6202
4590
  return viewer;
6203
4591
  }).catch((error) => {
6204
- modelMover?.dispose();
6205
- modelMover = null;
6206
- viewer?.dispose();
6207
- viewer = null;
6208
- viewerPromise = null;
4592
+ this._viewer?.dispose();
4593
+ this._viewer = null;
4594
+ this._viewerPromise = null;
6209
4595
  throw error;
6210
4596
  });
6211
- 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);
4668
+ }
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";
6212
4678
  }
6213
- function addFiles(files) {
6214
- assertActive();
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();
6215
4786
  const accepted = [];
6216
4787
  for (const source of files) {
6217
- if (!/\.(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/i.test(source.name)) {
6218
- showNotice(
6219
- "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
6220
4792
  );
6221
- continue;
6222
- }
6223
- if (source.size === 0) {
6224
- showNotice("This file is empty. Choose a file containing a 3D model.");
6225
- continue;
6226
- }
6227
- 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);
6228
4796
  }
6229
4797
  const scenes = accepted.filter((f) => sceneFormat(f.name));
6230
4798
  if (scenes.length) {
@@ -6234,161 +4802,130 @@ function mountViewstlApp(root, options = {}) {
6234
4802
  failed: false
6235
4803
  };
6236
4804
  for (const sceneFile of scenes)
6237
- queue.push({ name: sceneFile.name, sceneFile, batch });
6238
- queue.push({ remaining: batch });
6239
- } else queue.push(...accepted);
6240
- if (queue.length && !draining) drainPromise = drainQueue();
6241
- return drainPromise || Promise.resolve();
6242
- }
6243
- function openURL(value, format2 = "") {
6244
- assertActive();
6245
- if (!options.url_import_endpoint)
6246
- throw new Error("URL imports are not configured.");
6247
- 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());
6248
4815
  if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
6249
4816
  throw new Error(
6250
4817
  "Use a public HTTP or HTTPS file URL without login credentials."
6251
4818
  );
6252
- if (format2 && !/^(stl|obj|3mf|step|stp|iges|igs|vsb|vsj)$/.test(format2))
6253
- 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");
6254
4821
  url.hash = "";
6255
4822
  const name = decodeURIComponent(
6256
4823
  url.pathname.split("/").at(-1) || "Remote model"
6257
4824
  ).slice(0, 200);
6258
- queue.push({
4825
+ this._queue.push({
6259
4826
  name,
6260
4827
  remote: url.href,
6261
- format: format2,
4828
+ format,
6262
4829
  size: 0,
6263
- scene: ["vsb", "vsj"].includes(format2) ? format2 : sceneFormat(name)
4830
+ scene: ["vsb", "vsj"].includes(format) ? format : sceneFormat(name)
6264
4831
  });
6265
- if (!draining) drainPromise = drainQueue();
6266
- return drainPromise || Promise.resolve();
4832
+ return this._drain();
6267
4833
  }
6268
- if ($("url-form") && $("url-dialog")) {
6269
- listen($("url-form"), "submit", (event) => {
6270
- event.preventDefault();
6271
- try {
6272
- const work = openURL($("url-input").value, $("url-format").value);
6273
- $("url-input").value = "";
6274
- $("url-error").hidden = true;
6275
- $("url-dialog").close();
6276
- work.catch((error) => showNotice(error.message));
6277
- } catch (error) {
6278
- $("url-error").textContent = error.message;
6279
- $("url-error").hidden = false;
6280
- }
6281
- });
4834
+ _drain() {
4835
+ if (!this._drainPromise && this._queue.length) {
4836
+ this._drainPromise = Promise.resolve().then(() => this._drainQueue());
4837
+ }
4838
+ return this._drainPromise || Promise.resolve();
6282
4839
  }
6283
- async function drainQueue() {
6284
- if (draining) return;
6285
- draining = true;
4840
+ async _drainQueue() {
6286
4841
  try {
6287
- while (queue.length) {
6288
- 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;
6289
4848
  if (source.remaining) {
6290
4849
  if (!source.remaining.failed)
6291
- queue.unshift(
4850
+ this._queue.unshift(
6292
4851
  ...source.remaining.files.filter(
6293
4852
  (f) => !source.remaining.used.has(f)
6294
4853
  )
6295
4854
  );
6296
4855
  continue;
6297
4856
  }
6298
- setBusy(true);
6299
- $("notice").hidden = true;
6300
- $("loading-label").textContent = "Opening " + source.name + "\u2026";
6301
- announce("Opening " + source.name);
4857
+ this._setBusy(true);
4858
+ this._emit({ type: "opening", name: source.name });
6302
4859
  try {
6303
4860
  if (source.sceneFile || source.scene) {
6304
- await openSceneSource(source, token);
4861
+ await this._openScene(source, token);
6305
4862
  continue;
6306
4863
  }
6307
4864
  const estimated = await estimateFile(source);
6308
- if (token !== generation) continue;
6309
- const resident = [...records.values()].reduce(
4865
+ if (token !== this._generation) continue;
4866
+ const resident = [...this._records.values()].reduce(
6310
4867
  (sum, r) => sum + r.estimated,
6311
4868
  0
6312
4869
  );
6313
- if (estimated + resident > policy.budget || source.size > policy.limits.max_file_bytes)
6314
- throw Error(
6315
- `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"
6316
4874
  );
6317
- const engine = await getViewer();
6318
- if (token !== generation) continue;
6319
- engine.do_resize();
6320
- nextID = Math.max(nextID, engine.max_model_id + 1);
6321
- const id = nextID++;
6322
- loadingID = id;
6323
- lastLoadedID = null;
6324
- 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({
6325
4883
  id,
6326
4884
  display: "flat",
6327
4885
  ...source.remote ? { remote_url: source.remote, remote_format: source.format } : source.url ? { filename: source.url } : { local_file: source }
6328
4886
  });
6329
- await engine.when_idle();
6330
- if (token !== generation) continue;
6331
- loadingID = null;
6332
- const loaded = lastLoadedID === id ? engine.get_model_info(id, { measurements: false }) : null;
6333
- if (!loaded) {
6334
- 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 });
6335
4892
  continue;
6336
4893
  }
6337
- if (source.remote) {
6338
- source.name = loaded.name;
6339
- source.size = loaded.file_bytes;
6340
- delete source.remote;
6341
- }
6342
- const embedded = loaded.has_colors, originalColor = embedded ? null : palette[records.size % palette.length];
6343
- if (originalColor) engine.set_color(id, originalColor);
6344
- records.set(id, {
6345
- id,
6346
- name: source.name,
6347
- size: source.size,
6348
- units: loaded.source_units || (/\.3mf$/i.test(source.name) ? "mm" : null),
6349
- bounds: loaded.bounds,
6350
- position: { x: 0, y: 0, z: 0 },
6351
- rotation: { x: 0, y: 0, z: 0 },
6352
- embedded,
6353
- originalColor,
6354
- color: originalColor,
6355
- estimated: loaded.triangles * 224,
6356
- 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
6357
4902
  });
6358
- modelID = id;
6359
- appearanceRequest++;
6360
- arrangeScene();
6361
- renderScene();
6362
- announce(source.name + " is ready.");
6363
- engine.canvas.focus({ preventScroll: true });
4903
+ this.setArrangement(this._arrangement);
4904
+ this._emit({ type: "loaded", id, name: this._records.get(id).name });
6364
4905
  } catch (error) {
6365
4906
  if (source.batch) source.batch.failed = true;
6366
- if (token === generation) {
6367
- if (loadingID !== null) {
6368
- viewer?.remove_model(loadingID);
6369
- loadingID = null;
6370
- }
6371
- showNotice(
6372
- /WebGL/i.test(error.message) ? "3D viewing needs WebGL 2. Try enabling hardware acceleration in your browser, or use another device." : error.message
6373
- );
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);
6374
4912
  }
6375
4913
  }
6376
4914
  }
6377
4915
  } finally {
6378
- draining = false;
6379
- setBusy(false);
4916
+ this._drainPromise = null;
4917
+ if (this._busy) this._setBusy(false);
6380
4918
  }
6381
4919
  }
6382
- async function openSceneSource(source, token) {
6383
- const engine = await getViewer();
6384
- if (token !== generation) return;
6385
- const controller = new window2.AbortController();
6386
- 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();
6387
4924
  try {
6388
4925
  const chosen = source.batch?.files || [];
6389
4926
  const cached = [
6390
4927
  ...new Set(
6391
- [...records.values()].map((r) => r.sourceFile).filter(Boolean)
4928
+ [...this._records.values()].map((r) => r.sourceFile).filter(Boolean)
6392
4929
  )
6393
4930
  ];
6394
4931
  const companions = [
@@ -6397,498 +4934,69 @@ function mountViewstlApp(root, options = {}) {
6397
4934
  (f) => !chosen.some((s) => s.name === f.name) && cached.filter((s) => s.name === f.name).length === 1
6398
4935
  ) : []
6399
4936
  ];
6400
- const result = await engine.import_scene(
4937
+ const result = await viewer.import_scene(
6401
4938
  source.sceneFile || source.remote,
6402
4939
  {
6403
4940
  format: source.scene || sceneFormat(source.name),
6404
4941
  signal: controller.signal,
6405
4942
  files: companions,
6406
- on_progress(p) {
6407
- if (token !== generation || disposed) return;
6408
- $("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 });
6409
4946
  }
6410
4947
  }
6411
4948
  );
6412
- if (token !== generation) return;
4949
+ if (token !== this._generation) return;
6413
4950
  for (const file of result.used_files) source.batch?.used.add(file);
6414
4951
  for (const id of result.model_ids) {
6415
- const m = engine._models.get(id), g = m.geometry;
6416
- const bounds = Object.fromEntries(
6417
- ["min", "max"].map((key) => [
6418
- key,
6419
- g.bounds[key].map((v, i) => (v - m.center[i]) * m.scaleVector[i])
6420
- ])
6421
- );
6422
- const record = {
4952
+ const m = viewer._models.get(id);
4953
+ this._adopt(
6423
4954
  id,
6424
- name: String(m.display_name || m.orig_filename || m.filename).split(/[\\/]/).pop().split(/[?#]/)[0],
6425
- size: g.file_bytes ?? m.local_file?.size ?? null,
6426
- units: g.units || m.units,
6427
- bounds,
6428
- position: { x: 0, y: 0, z: 0 },
6429
- rotation: {
6430
- x: m.rotationx * 180 / Math.PI,
6431
- y: m.rotationy * 180 / Math.PI,
6432
- z: m.rotationz * 180 / Math.PI
4955
+ {
4956
+ sourceFile: result.used_files.includes(m.local_file) ? m.local_file : null
6433
4957
  },
6434
- embedded: !!g.colors,
6435
- color: m.color || (g.colors ? null : "#909090"),
6436
- estimated: g.triangles * 224,
6437
- sourceFile: result.used_files.includes(m.local_file) ? m.local_file : null
6438
- };
6439
- const offset = enginePosition(record);
6440
- for (const axis of ["x", "y", "z"])
6441
- record.position[axis] = m[axis] - offset[axis];
6442
- record.originalPosition = { ...record.position };
6443
- record.originalRotation = { ...record.rotation };
6444
- record.originalColor = record.color;
6445
- records.set(id, record);
6446
- }
6447
- nextID = Math.max(nextID, engine.max_model_id + 1);
6448
- modelID = result.model_ids.at(-1);
6449
- appearanceRequest++;
6450
- $("scene-arrangement").value = "custom";
6451
- gridEnabled = !!engine.grid;
6452
- gridHeight = engine.grid?.height || 0;
6453
- gridSpacing = engine.grid?.spacing || 1;
6454
- $("grid-button").setAttribute("aria-pressed", String(gridEnabled));
6455
- setBackground(engine.bg_color);
6456
- renderScene();
6457
- announce(
6458
- source.name + " opened. " + result.model_ids.length + " models added."
6459
- );
6460
- engine.canvas.focus({ preventScroll: true });
6461
- } finally {
6462
- if (sceneImport === controller) sceneImport = null;
6463
- }
6464
- }
6465
- root.querySelectorAll("[data-open-file]").forEach((b) => listen(b, "click", () => $("file-input").click()));
6466
- for (const button of shortcutButtons)
6467
- listen(button, "click", () => {
6468
- if (button.disabled || busy || !records.size) return;
6469
- const action = button.dataset.viewstlShortcut;
6470
- if (action === "save") $("save-button")?.click();
6471
- else if (action === "select") {
6472
- setSelectionMode(!selectionMode);
6473
- closePanels();
6474
- announce(
6475
- selectionMode ? "Selection mode. Click a model to select; drag to move. Done or Escape returns to viewing." : "Viewing mode. Drag to rotate the scene."
4958
+ true
6476
4959
  );
6477
- } else if ([
6478
- "front",
6479
- "back",
6480
- "left",
6481
- "right",
6482
- "top",
6483
- "bottom",
6484
- "isometric"
6485
- ].includes(action)) {
6486
- setMoveMode(false);
6487
- setSelectionMode(false);
6488
- viewer.set_camera_view(action);
6489
- toolsUI?.sync();
6490
- announce(button.title || action + " view");
6491
4960
  }
6492
- });
6493
- listen(window2, "resize", () => {
6494
- if (selectionMode && !shortcutButtons.some(
6495
- (button) => button.dataset.viewstlShortcut === "select" && button.getClientRects().length
6496
- ))
6497
- setSelectionMode(false);
6498
- });
6499
- listen($("file-input"), "change", () => {
6500
- const files = [...$("file-input").files];
6501
- $("file-input").value = "";
6502
- addFiles(files);
6503
- });
6504
- $("sample-button").hidden = !sampleURL;
6505
- listen($("sample-button"), "click", () => {
6506
- if (sampleURL)
6507
- void addFiles([
6508
- { name: options.sample_name || "Sample.stl", url: sampleURL }
6509
- ]);
6510
- });
6511
- listen($("close-model"), "click", () => removeModel(modelID));
6512
- listen($("cancel-load"), "click", cancelLoading);
6513
- listen($("clear-scene"), "click", clearScene);
6514
- listen($("dismiss-notice"), "click", () => {
6515
- $("notice").hidden = true;
6516
- });
6517
- listen($("fit-button"), "click", () => viewer?.set_auto_zoom());
6518
- listen($("scene-arrangement"), "change", arrangeScene);
6519
- listen($("model-list"), "click", (event) => {
6520
- const b = event.target.closest("button");
6521
- if (!b) return;
6522
- if (b.dataset.removeModel !== void 0)
6523
- removeModel(Number(b.dataset.removeModel));
6524
- else if (b.dataset.selectModel !== void 0)
6525
- selectModel(Number(b.dataset.selectModel));
6526
- else if (b.dataset.colorModel !== void 0) {
6527
- selectModel(Number(b.dataset.colorModel));
6528
- togglePanel("colors");
6529
- }
6530
- });
6531
- async function applyView() {
6532
- if (modelID === null || busy) return;
6533
- const request = ++appearanceRequest;
6534
- const display = modeInputs.find((input) => input.checked).value;
6535
- modeInputs.forEach((input) => {
6536
- input.disabled = true;
6537
- });
6538
- $("smooth-shading").disabled = true;
6539
- $("view-status").textContent = "Preparing view\u2026";
6540
- $("notice").hidden = true;
6541
- try {
6542
- viewer.set_view(modelID, {
6543
- display,
6544
- 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
6545
4972
  });
6546
- await viewer.when_idle();
6547
4973
  } finally {
6548
- if (request === appearanceRequest) {
6549
- syncControls();
6550
- announce($("view-label").textContent + " view ready.");
6551
- }
4974
+ if (this._sceneImport === controller) this._sceneImport = null;
6552
4975
  }
6553
4976
  }
6554
- modeInputs.forEach((input) => listen(input, "change", applyView));
6555
- listen($("smooth-shading"), "change", applyView);
6556
- for (const name of panelNames) {
6557
- listen($(name + "-button"), "click", () => {
6558
- if (name === "info") populateInfo();
6559
- togglePanel(name);
6560
- if (name === "section" && !$("section-panel").hidden && !$("section-enabled").checked) {
6561
- $("section-enabled").checked = true;
6562
- applySection();
6563
- }
6564
- });
6565
- listen($("close-" + name), "click", () => {
6566
- closePanel(name);
6567
- if (name === "colors") {
6568
- togglePanel("view");
6569
- }
6570
- $(name + "-button").focus();
6571
- });
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);
6572
4985
  }
6573
- function applySection() {
6574
- if (modelID === null) return;
6575
- const enabled = $("section-enabled").checked;
6576
- viewer.set_section(
6577
- modelID,
6578
- enabled ? {
6579
- axis: $("section-axis").value,
6580
- position: Number($("section-position").value) / 100,
6581
- flip: $("section-flip").getAttribute("aria-pressed") === "true"
6582
- } : null
6583
- );
6584
- $("section-value").textContent = $("section-position").value + "%";
6585
- $("section-button").setAttribute("aria-pressed", String(enabled));
6586
- }
6587
- listen($("section-enabled"), "change", applySection);
6588
- listen($("section-axis"), "change", applySection);
6589
- listen($("section-position"), "input", applySection);
6590
- listen($("section-flip"), "click", () => {
6591
- $("section-flip").setAttribute(
6592
- "aria-pressed",
6593
- String($("section-flip").getAttribute("aria-pressed") !== "true")
6594
- );
6595
- applySection();
6596
- });
6597
- listen($("grid-button"), "click", () => {
6598
- gridEnabled = !gridEnabled;
6599
- updateGrid();
6600
- });
6601
- function setColor(color) {
6602
- if (modelID === null) return;
6603
- viewer.set_color(modelID, color);
6604
- records.get(modelID).color = color;
6605
- syncColors();
6606
- renderList();
6607
- }
6608
- listen($("model-color"), "input", () => setColor($("model-color").value));
6609
- listen(
6610
- $("restore-color"),
6611
- "click",
6612
- () => setColor(records.get(modelID)?.originalColor)
6613
- );
6614
- root.querySelectorAll("[data-model-color]").forEach((b) => listen(b, "click", () => setColor(b.dataset.modelColor)));
6615
- function setBackground(color) {
6616
- background = color;
6617
- const rgb = color === "transparent" ? [255, 255, 255] : [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16));
6618
- $("workspace").classList.toggle(
6619
- "dark-background",
6620
- rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722 < 115
6621
- );
6622
- viewer?.set_bg_color(color);
6623
- 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);
6624
4993
  }
6625
- listen(
6626
- $("background-color"),
6627
- "input",
6628
- () => setBackground($("background-color").value)
6629
- );
6630
- root.querySelectorAll("[data-background]").forEach(
6631
- (b) => listen(b, "click", () => setBackground(b.dataset.background))
6632
- );
6633
- listen($("image-button"), "click", async () => {
6634
- if (modelID === null) return;
6635
- const name = records.size > 1 ? "viewstl-scene.png" : records.get(modelID).name.replace(/\.[^.]+$/, "") + ".png";
6636
- try {
6637
- const blob = await viewer.get_image_blob("image/png");
6638
- if (disposed) return;
6639
- const url = URL2.createObjectURL(blob), link = document2.createElement("a");
6640
- link.href = url;
6641
- link.download = name;
6642
- link.click();
6643
- setTimeout(() => URL2.revokeObjectURL(url), 1e3);
6644
- announce("Image ready to download.");
6645
- } catch {
6646
- showNotice("The image could not be saved. Please try again.");
6647
- }
6648
- });
6649
- let dragDepth = 0;
6650
- const acceptedDrag = (event) => {
6651
- const kind = dropKind(event.dataTransfer);
6652
- if (kind === "files") return kind;
6653
- if (!options.url_import_endpoint || event.target?.isContentEditable || event.target?.closest?.("input, textarea"))
6654
- return null;
6655
- return kind;
6656
- };
6657
- const clearDrop = () => {
6658
- dragDepth = 0;
6659
- $("drop-overlay").hidden = true;
6660
- };
6661
- listen(dropTarget, "dragenter", (event) => {
6662
- if (acceptedDrag(event)) {
6663
- event.preventDefault();
6664
- dragDepth++;
6665
- $("drop-overlay").hidden = false;
6666
- }
6667
- });
6668
- listen(dropTarget, "dragover", (event) => {
6669
- if (acceptedDrag(event)) {
6670
- event.preventDefault();
6671
- event.dataTransfer.dropEffect = "copy";
6672
- }
6673
- });
6674
- listen(dropTarget, "dragleave", (event) => {
6675
- if (dragDepth > 0 && --dragDepth === 0) clearDrop();
6676
- });
6677
- listen(dropTarget, "drop", (event) => {
6678
- const kind = acceptedDrag(event);
6679
- clearDrop();
6680
- if (!kind) return;
6681
- event.preventDefault();
6682
- try {
6683
- if (kind === "files")
6684
- addFiles([...event.dataTransfer.files]).catch(
6685
- (error) => showNotice(error.message)
6686
- );
6687
- else
6688
- for (const url of droppedURLs(event.dataTransfer))
6689
- openURL(url).catch((error) => showNotice(error.message));
6690
- } catch (error) {
6691
- showNotice(error.message);
6692
- }
6693
- });
6694
- listen(dropTarget, "dragend", clearDrop);
6695
- listen(window2, "blur", clearDrop);
6696
- root.querySelectorAll("[data-dialog]").forEach((b) => listen(b, "click", () => $(b.dataset.dialog).showModal()));
6697
- root.querySelectorAll("dialog").forEach(
6698
- (dialog) => listen(dialog, "click", (event) => {
6699
- if (event.target === dialog) {
6700
- const r = dialog.getBoundingClientRect();
6701
- if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom)
6702
- if (dialog === $("save-dialog")) exportUI.close();
6703
- else dialog.close();
6704
- }
6705
- })
6706
- );
6707
- listen(root, "keydown", (event) => {
6708
- if (event.key !== "Escape") return;
6709
- if ($("save-dialog")?.open) return;
6710
- if (selectionMode) {
6711
- setSelectionMode(false);
6712
- closePanels();
6713
- announce("Viewing mode. Drag to rotate the scene.");
6714
- return;
6715
- }
6716
- if (moveMode) {
6717
- setMoveMode(false);
6718
- announce("Move tool closed.");
6719
- return;
6720
- }
6721
- const active = panelNames.find((name) => !$(name + "-panel").hidden);
6722
- if (active) {
6723
- closePanels();
6724
- if (active === "colors") togglePanel("view");
6725
- $(active + "-button").focus();
6726
- }
6727
- });
6728
- listen(root, "pointerdown", (event) => {
6729
- if (!event.target.closest(
6730
- ".view-panel,.info-panel,.toolbar,.scene-heading-button,.move-mode"
6731
- ))
6732
- closePanels();
6733
- });
6734
- $("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.`;
6735
- $("scene-arrangement").value = "original";
6736
- $("notice").hidden = true;
6737
- $("drop-overlay").hidden = true;
6738
- if ($("save-button"))
6739
- exportUI = bindAppExport({
6740
- $,
6741
- listen,
6742
- window: window2,
6743
- getState: () => ({
6744
- busy,
6745
- selectedId: modelID,
6746
- models: [...records.values()]
6747
- }),
6748
- getViewer: () => viewer,
6749
- onOpen: () => {
6750
- setMoveMode(false);
6751
- closePanels();
6752
- },
6753
- announce
6754
- });
6755
- if ($("print-button"))
6756
- printUI = bindAppPrint({
6757
- $,
6758
- listen,
6759
- window: window2,
6760
- getState: () => ({
6761
- busy,
6762
- selectedId: modelID,
6763
- models: [...records.values()]
6764
- }),
6765
- getViewer: () => viewer,
6766
- onOpen: () => {
6767
- setMoveMode(false);
6768
- closePanels();
6769
- },
6770
- announce
6771
- });
6772
- if ($("animation-details"))
6773
- animationUI = bindAppAnimation({
6774
- $,
6775
- listen,
6776
- window: window2,
6777
- getState: () => ({
6778
- busy,
6779
- selectedId: modelID,
6780
- models: [...records.values()]
6781
- }),
6782
- getViewer: () => viewer,
6783
- beforePlay(id) {
6784
- setMoveMode(false);
6785
- prepareManual(id);
6786
- centerAnimationPivot(records.get(id), viewer._models.get(id));
6787
- $("scene-arrangement").value = "custom";
6788
- },
6789
- refreshPose: refreshAnimatedPose,
6790
- announce
6791
- });
6792
- if ($("size-details"))
6793
- toolsUI = bindAppTools({
6794
- $,
6795
- listen,
6796
- window: window2,
6797
- getViewer: () => viewer,
6798
- getState: () => ({ selectedId: modelID, busy }),
6799
- refresh: renderScene,
6800
- announce,
6801
- changeSize(operation) {
6802
- setMoveMode(false);
6803
- const record = records.get(modelID);
6804
- syncAnimatedRecord(record, viewer._models.get(modelID));
6805
- operation();
6806
- syncAnimatedRecord(record, viewer._models.get(modelID));
6807
- $("scene-arrangement").value = "custom";
6808
- renderScene();
6809
- },
6810
- duplicate() {
6811
- const original = records.get(modelID), id = viewer.duplicate_model(modelID), m = viewer._models.get(id);
6812
- const record = {
6813
- ...original,
6814
- id,
6815
- name: m.display_name,
6816
- position: { ...original.position },
6817
- rotation: { ...original.rotation }
6818
- };
6819
- syncAnimatedRecord(record, m);
6820
- record.originalPosition = { ...record.position };
6821
- record.originalRotation = { ...record.rotation };
6822
- records.set(id, record);
6823
- nextID = Math.max(nextID, id + 1);
6824
- $("scene-arrangement").value = "custom";
6825
- selectModel(id);
6826
- setMoveMode(true, "move", true);
6827
- announce("Copy selected at the same position. Drag to move it.");
6828
- }
6829
- });
6830
- updateGrid();
6831
- setBusy(false);
6832
- setMoveMode(false);
6833
- const api = {
6834
- openFiles: addFiles,
6835
- openURL,
6836
- cancelLoading() {
6837
- assertActive();
6838
- cancelLoading();
6839
- },
6840
- clear() {
6841
- assertActive();
6842
- clearScene();
6843
- },
6844
- select(id) {
6845
- assertActive();
6846
- selectModel(id);
6847
- },
6848
- remove(id) {
6849
- assertActive();
6850
- removeModel(id);
6851
- },
6852
- getState() {
6853
- assertActive();
6854
- for (const record of records.values())
6855
- syncAnimatedRecord(record, viewer?._models.get(record.id));
6856
- return {
6857
- selectedId: modelID,
6858
- busy,
6859
- arrangement: $("scene-arrangement").value,
6860
- memoryBudget: policy.budget,
6861
- models: [...records.values()].map((record) => ({
6862
- id: record.id,
6863
- name: record.name,
6864
- size: record.size,
6865
- units: record.units,
6866
- position: { ...record.position },
6867
- rotation: { ...record.rotation },
6868
- color: record.color,
6869
- animation: viewer.get_model_animation_state(record.id)
6870
- }))
6871
- };
6872
- },
6873
- dispose() {
6874
- if (disposed) return;
6875
- exportUI?.dispose();
6876
- printUI?.dispose();
6877
- animationUI?.dispose();
6878
- toolsUI?.dispose();
6879
- cancelLoading();
6880
- setMoveMode(false);
6881
- appearanceRequest++;
6882
- disposed = true;
6883
- abort.abort();
6884
- modelMover?.dispose();
6885
- viewer?.dispose();
6886
- records.clear();
6887
- if (options.id_prefix)
6888
- modeInputs.forEach((input) => input.name = "view-mode");
6889
- mountedRoots.delete(root);
6890
- }
6891
- };
6892
- mountedRoots.set(root, api);
6893
- return api;
6894
- }
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
+ };