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