A little more ViewSTL

Plugin usage & API.

Reference for the released ViewSTL 2.0.0-rc.6 package.

← Quick start · Legacy v1 reference

Parameters, methods and callbacks are checked against this release’s public API. Longer guides below come from its packaged documentation.

Parameters

Set constructor options once and use methods for later changes. Optional arguments in signatures use a question mark; omitted fields retain their documented defaults.

Initialization

new StlViewer(container, options = {})

Pass a DOM element with a usable height. The second argument is an object, not an array. Browser viewing requires WebGL 2. These defaults describe the standalone plugin; the ViewSTL website supplies its own configuration.

Parameter Type / default Meaning
models ModelOptions[], empty Models to start loading after initialization. See Models below.
width, height CSS string or pixel number; "100%" Canvas dimensions. Override canvas_width, canvas_height aliases. Give the parent an explicit height when using a percentage.
canvas_width, canvas_height Same types Legacy aliases, used only when width/height are omitted.
bg_color, bgcolor Color string; "transparent" Background. bg_color takes precedence. Use a hex color such as "#ffffff".
camerax, cameray, cameraz Numbers; 0, 0, 100 Initial camera position. Automatic fitting after loading may replace it.
camera_state CameraState, omitted {position:{x,y,z}, up:{x,y,z}, target:{x,y,z}}; restores the initial camera after loading.
zoom Number; -1 -1 fits loaded models automatically. A nonnegative value sets camera Z; it is not a magnification factor.
center_models Boolean; true Center each subsequently loaded model around its own bounds. Set false to retain source coordinates, as the preview site does.
auto_resize Boolean; true Observe container/window resizing and update the drawing surface. This does not mean auto-fit.
auto_rotate Boolean; false Rotate the camera continuously; separate from per-model animation. Provide a pause control if enabled.
mouse_zoom Boolean; true Enable wheel/pinch camera zoom.
controls 0 or 1; 0 Native orbit (0) or trackball-style (1) controls; no Three.js control objects.
allow_drag_and_drop Boolean; true Enable the low-level canvas local-file drop handler. Use import_scene for transactional scene/companion handling.
grid Boolean; false Initial finite grid. For the infinite grid use set_infinite_grid.
send_no_model_click_event Boolean; false Also report misses to the pointer callback, with id null and distance 0.
pixel_ratio Positive number; device ratio capped at 2 Drawing resolution; increasing it costs GPU memory and rendering time.
worker_url String or URL; beside ESM/classic entry Path to viewstl.worker.js. Set explicitly for CommonJS/bundled integration if automatic resolution is unavailable. Keep companion chunks/CAD assets together.
worker_factory Function returning Worker; omitted Custom worker construction. The worker must run the matching ViewSTL worker implementation.
worker_count Integer 1–4; 2 Parser/worker pool size.
worker_timeout Positive milliseconds; 120000 Worker job timeout.
url_import_endpoint Same-origin path/URL; omitted Your compatible streaming relay for remote_url and URL-based import_scene. The plugin does not configure or contact ViewSTL’s relay automatically.
max_file_bytes Positive integer; 268435456 256 MiB input/output file ceiling.
max_triangles Positive integer; 5000000 Triangle ceiling.
max_uncompressed_bytes Positive integer; 268435456 256 MiB archive expansion ceiling.
max_archive_entries Positive integer; 1024 Archive entry ceiling.
max_memory_bytes Positive integer; Number.MAX_SAFE_INTEGER Optional estimated working-set budget, not a measurement of available RAM.
three Host-supplied Three.js namespace; omitted Optional bridge for get_model_mesh; also checks globalThis.THREE. Native rendering never imports Three.js.
load_three_files String or false; omitted Deprecated worker-directory alias, relative to the page. It never loads Three.js. Explicit worker_url wins; false/omitted uses normal entry-point resolution.
jszip_path, jszip_utils_path Deprecated strings Accepted with no effect. Archive dependencies are supplied as lazy package chunks.
Callbacks Functions; omitted See the individually linked Events / Callbacks entries below.
// The page contains <div id="viewer" style="height:500px"></div>.
const viewer = new StlViewer(document.getElementById('viewer'), {
  center_models: false,
  bg_color: '#ffffff',
  models: [{ id: 0, filename: '/model.stl' }],
  load_error_callback: message => console.error(message)
});
await viewer.when_idle();
Working Example
View code

Copy the complete build/ folder from the plugin download beside this HTML file, add the models below, and serve the folder over HTTP(S). This is the same example program with local file paths.

These are the original legacy ViewSTL example models. Save the required files in a models/ folder beside your HTML: viewstl_plugin.stl.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ViewSTL — Initialization options</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 20px; }
    #example-viewer { width: 100%; height: 500px; }
    button { padding: 10px 16px; }

  </style>
</head>
<body>
  <div id="example-viewer"></div>
  <p id="example-status" role="status">Opening example…</p>
  <button id="example-motion" type="button" hidden>Pause camera rotation</button>
  <button id="example-retry" type="button" hidden>Retry example</button>

  <script type="module">
import StlViewer from './build/viewstl.js';

const container = document.getElementById('example-viewer');
const status = document.getElementById('example-status');
const retry = document.getElementById('example-retry');
const modelURL = new URL('./models/viewstl_plugin.stl', import.meta.url).href;

const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
let loadError = null;
function showError(message) {
  loadError = message;
  status.textContent = message;
  retry.hidden = false;
}
retry.addEventListener('click', () => location.reload());

const viewer = new StlViewer(container, {
  bg_color: '#e1f3ed',
  auto_resize: true,
  auto_rotate: !reducedMotion.matches,
  allow_drag_and_drop: true,
  models: [{ id: 0, filename: modelURL, color: '#428a83' }],
  load_error_callback: showError
});
const setPlaying = enabled => viewer.set_auto_rotate(enabled);

// Refit through the plugin when the example container changes width.
let lastWidth = container.clientWidth;
const resize = new ResizeObserver(() => {
  if (container.clientWidth === lastWidth) return;
  lastWidth = container.clientWidth;
  viewer.do_resize();
  viewer.set_auto_zoom();
});
resize.observe(container);

try {
  await viewer.when_idle();
  if (loadError) throw new Error(loadError);
  viewer.set_camera_view('isometric');
  status.textContent = 'Ready. Drag to rotate, scroll to zoom. Keyboard: arrows, + / − and F.';
  const motionButton = document.getElementById('example-motion');
  let playing = !reducedMotion.matches;
  function updateMotion() {
    motionButton.textContent = (playing ? 'Pause' : 'Resume') + ' camera rotation';
    motionButton.setAttribute('aria-pressed', String(playing));
  }
  motionButton.hidden = false;
  updateMotion();
  motionButton.addEventListener('click', () => {
    playing = !playing;
    setPlaying(playing);
    updateMotion();
  });
  reducedMotion.addEventListener('change', event => {
    if (event.matches) {
      playing = false;
      setPlaying(false);
      updateMotion();
    }
  });
} catch (error) {
  showError(error.message);
  viewer.dispose();
}
addEventListener('pagehide', event => { if (!event.persisted) { resize.disconnect(); viewer.dispose(); } });
  </script>
</body>
</html>

Models

models: [{ ...modelOptions }]

Use one geometry source per model. STL/OBJ coordinates are unitless; 3MF/STEP/IGES units are normalized to millimetres during parsing. OBJ material/texture companion loading is not supported.

Parameter Type / default Meaning
id Nonnegative safe integer; automatic Omit or use -1 for the next ID. Zero is valid; duplicate IDs fail.
filename String Direct model URL/path. Same-origin or CORS permissions are needed. Also the source reference saved by VSJ.
local_file File or Blob A browser-selected local file, not a filesystem path or an HTML input element. For a nameless Blob, supply filename for format detection.
remote_url, remote_format Public URL; optional format Use the configured URL relay. Format: stl, obj, 3mf, step/stp, iges/igs; empty/omitted infers it.
data GeometryData Copied triangle positions (9 numbers per triangle) plus optional RGB colors (one RGB per vertex, 0–1). Float32Array or number arrays.
mesh Static supported mesh Optional existing Three.js mesh input; see the mesh-bridge guide for restrictions. Native data is usually simpler.
orig_filename, display_name Optional strings Original-name metadata and a separate display label. display_name does not replace the VSJ source filename.
x, y, z Finite numbers; 0 Scene translation in model-coordinate units, normally mm.
rotationx, rotationy, rotationz Finite radians; 0 Model rotation.
scale Positive number; 1 Uniform scale.
scalex, scaley, scalez Positive numbers; scale or 1 Per-axis scale overrides.
display String; "flat" flat, smooth, wireframe, edges, mesh, xray, studio or orientation.
smooth_shading Boolean; true for smooth Independent smoothing for the modern view modes.
color Hex string or null; null Null preserves imported colors (or default gray). A hex value overrides them; transparent is a compatibility color value.
opacity Number 0–1; 1 Transparency; zero is valid. A mesh input can supply material opacity.
view_edges Boolean; false Legacy feature-edge overlay.
section Section or null; null {axis:"x"/"y"/"z", position:0..1, flip:false}; open visual cut, not geometry editing.
units mm or inch; "mm" Legacy model-unit label/convention; does not by itself reinterpret unitless coordinates. Use set_file_units for that.
animation Animation or null Per-model motion; see Animation parameters.
animation_state, size_state Serialized scene metadata Normally generated by scene saving; retain when round-tripping rather than construct manually.
viewer.add_models([
  { id: 1, filename: '/part.stl', color: '#498ba6', x: 30 },
  { id: 2, filename: '/part.obj', scale: 2, opacity: 0.7 }
]);
await viewer.when_idle();
Working Example
View code

Copy the complete build/ folder from the plugin download beside this HTML file, add the models below, and serve the folder over HTTP(S). This is the same example program with local file paths.

These are the original legacy ViewSTL example models. Save the required files in a models/ folder beside your HTML: viewstl_plugin.stl · bat.stl.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ViewSTL — Model options</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 20px; }
    #example-viewer { width: 100%; height: 500px; }
    button { padding: 10px 16px; }

  </style>
</head>
<body>
  <div id="example-viewer"></div>
  <p id="example-status" role="status">Opening example…</p>
  <button id="example-retry" type="button" hidden>Retry example</button>

  <script type="module">
import StlViewer from './build/viewstl.js';

const container = document.getElementById('example-viewer');
const status = document.getElementById('example-status');
const retry = document.getElementById('example-retry');
const modelURL = new URL('./models/viewstl_plugin.stl', import.meta.url).href;
const secondModelURL = new URL('./models/bat.stl', import.meta.url).href;

let loadError = null;
function showError(message) {
  loadError = message;
  status.textContent = message;
  retry.hidden = false;
}
retry.addEventListener('click', () => location.reload());

const viewer = new StlViewer(container, {
  models: [
    { id: 0, filename: modelURL, x: -55, scale: 1.5,
      color: '#db7667', opacity: 0.6, rotationx: 0.2, display: 'edges' },
    { id: 1, filename: secondModelURL, x: 55, scale: 2,
      color: '#e5b54c', display: 'smooth' }
  ],
  load_error_callback: showError
});

// Refit through the plugin when the example container changes width.
let lastWidth = container.clientWidth;
const resize = new ResizeObserver(() => {
  if (container.clientWidth === lastWidth) return;
  lastWidth = container.clientWidth;
  viewer.do_resize();
  viewer.set_auto_zoom();
});
resize.observe(container);

try {
  await viewer.when_idle();
  if (loadError) throw new Error(loadError);
  viewer.set_camera_view('isometric');
  status.textContent = 'Ready. Drag to rotate, scroll to zoom. Keyboard: arrows, + / − and F.';
} catch (error) {
  showError(error.message);
  viewer.dispose();
}
addEventListener('pagehide', event => { if (!event.persisted) { resize.disconnect(); viewer.dispose(); } });
  </script>
</body>
</html>

Animation parameters

animation: { delta?: track, exact?: track }

delta adds changes to the starting pose; exact specifies target values. They can run together on different properties, but cannot claim the same property. Rotations use radians and translation uses scene coordinates, not pixels.

Parameter Type / default Meaning
msec Positive milliseconds; 300 Duration of this track.
loop Boolean; false Continuous accumulation for delta only. Exact tracks cannot loop.
x, y, z Finite numbers Translation deltas or target coordinates. Omitted fields are unchanged.
rotationx, rotationy, rotationz Finite radians Rotation deltas or target angles.
scale, scalex, scaley, scalez Finite scale values Delta scale is additive; exact scale is absolute. Resulting scales must remain positive. Negative looping scale deltas are rejected.

Manual transform/size changes stop that model’s animation. VSJ/VSB preserve definitions, phase and paused state. Camera auto-rotation is separate.

viewer.animate_model(0, {
  delta: { rotationz: 2 * Math.PI, msec: 5000, loop: true }
});
viewer.pause_model_animation(0);
viewer.resume_model_animation(0);
Working Example
View code

Copy the complete build/ folder from the plugin download beside this HTML file, add the models below, and serve the folder over HTTP(S). This is the same example program with local file paths.

These are the original legacy ViewSTL example models. Save the required files in a models/ folder beside your HTML: knot.stl · bar.stl.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ViewSTL — Model animation</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 20px; }
    #example-viewer { width: 100%; height: 500px; }
    button { padding: 10px 16px; }

  </style>
</head>
<body>
  <div id="example-viewer"></div>
  <p id="example-status" role="status">Opening example…</p>
  <button id="example-motion" type="button" hidden>Pause animation</button>
  <button id="example-retry" type="button" hidden>Retry example</button>

  <script type="module">
import StlViewer from './build/viewstl.js';

const container = document.getElementById('example-viewer');
const status = document.getElementById('example-status');
const retry = document.getElementById('example-retry');
const modelURL = new URL('./models/knot.stl', import.meta.url).href;
const secondModelURL = new URL('./models/bar.stl', import.meta.url).href;

const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
let loadError = null;
function showError(message) {
  loadError = message;
  status.textContent = message;
  retry.hidden = false;
}
retry.addEventListener('click', () => location.reload());

const animations = [
  { delta: { rotationz: 2 * Math.PI, msec: 6000, loop: true } },
  {
    delta: { rotationx: 0.5, rotationy: 0.7, rotationz: 1, msec: 4000, loop: true },
    exact: { x: 110, y: 10, z: 10, scale: 0.9, msec: 3500 }
  }
];
const viewer = new StlViewer(container, {
  models: [
    { id: 0, filename: modelURL, x: -90, scale: 0.55, color: '#64a5b2', display: 'smooth',
      animation: reducedMotion.matches ? null : animations[0] },
    { id: 1, filename: secondModelURL, x: 90, scale: 0.7, color: '#e79a65',
      animation: reducedMotion.matches ? null : animations[1] }
  ],
  load_error_callback: showError
});
function setPlaying(enabled) {
  animations.forEach((definition, id) => {
    const state = viewer.get_model_animation_state(id);
    if (enabled && state?.status === 'idle') viewer.animate_model(id, definition);
    else if (enabled) viewer.resume_model_animation(id);
    else viewer.pause_model_animation(id);
  });
}

// Refit through the plugin when the example container changes width.
let lastWidth = container.clientWidth;
const resize = new ResizeObserver(() => {
  if (container.clientWidth === lastWidth) return;
  lastWidth = container.clientWidth;
  viewer.do_resize();
  viewer.set_auto_zoom();
});
resize.observe(container);

try {
  await viewer.when_idle();
  if (loadError) throw new Error(loadError);
  viewer.set_camera_view('isometric');
  status.textContent = 'Ready. Drag to rotate, scroll to zoom. Keyboard: arrows, + / − and F.';
  const motionButton = document.getElementById('example-motion');
  let playing = !reducedMotion.matches;
  function updateMotion() {
    motionButton.textContent = (playing ? 'Pause' : 'Resume') + ' animation';
    motionButton.setAttribute('aria-pressed', String(playing));
  }
  motionButton.hidden = false;
  updateMotion();
  motionButton.addEventListener('click', () => {
    playing = !playing;
    setPlaying(playing);
    updateMotion();
  });
  reducedMotion.addEventListener('change', event => {
    if (event.matches) {
      playing = false;
      setPlaying(false);
      updateMotion();
    }
  });
} catch (error) {
  showError(error.message);
  viewer.dispose();
}
addEventListener('pagehide', event => { if (!event.persisted) { resize.disconnect(); viewer.dispose(); } });
  </script>
</body>
</html>

Export options

ExportOptions / SceneExportOptions

Parameter Type / default Meaning
format Required string Model: stl, obj, 3mf, step/stp, iges/igs. Scene: vsb or vsj.
model_ids Number array or selected; all if omitted Model exports only. For selected, call set_selection first. Scene exports always include the whole scene.
filename Optional string The modern exporter supplies/replaces the extension.
units mm or inch; mm Model output units. Scene coordinates use mm; inch output divides coordinates by 25.4.
apply_transforms Boolean; true Model export: bake current pose/scale/centering. False uses source coordinates plus accumulated unit conversion only.
colors Boolean; true OBJ/3MF color export. Colored OBJ is a ZIP with OBJ and MTL; ignored for STL/STEP/IGES.
force_basename Boolean; false VSJ only: strip paths/query strings from file references. Companions must still be supplied when reopening.
signal AbortSignal Cancel an active/queued modern export.
on_progress Function Model export: loaded/total triangles, phase exporting/complete. Scene: loaded/total models, phase exporting/packaging/complete.

Call when_idle first. Errors reject modern export Promises, rather than invoking load_error_callback. STL/OBJ have no standard unit metadata. STEP/IGES contain triangle surfaces, without reconstructed CAD solids, history or analytic curves. 3MF requires closed, consistently oriented geometry. Local export does not upload files.

const controller = new AbortController();
const result = await viewer.export_models({
  format: 'obj', model_ids: [0], colors: false,
  signal: controller.signal, on_progress: p => console.log(p.phase)
});
console.log(result.filename, result.blob.size);

Scene import options

import_scene(source, options = {})

Parameter Type / default Meaning
source File, Blob or public HTTP/HTTPS URL VSB/VSJ source. URL sources require the explicitly configured same-origin relay.
format vsb or vsj; inferred Set for a nameless Blob or an extensionless URL.
files File[]; empty Local companion models referenced by a VSJ. Match by relative path or unambiguous basename.
signal AbortSignal Cancellation preserves the existing scene.
on_progress Function Receives loaded, total and phase (reading-scene, opening-model, complete).

Incoming models are staged and appended with new IDs after all succeed. Errors or cancellation preserve the existing scene. Saved camera, presentation, bookmarks and animation are restored; embedding canvas dimensions and resize/mouse controls are not imported. A VSB embeds meshes; a VSJ needs its companion sources.

// Round-trip a self-contained scene without a server.
const saved = await viewer.export_scene({ format: 'vsb' });
const imported = await viewer.import_scene(saved.blob, { format: 'vsb' });
console.log(imported.model_ids);

check_printability(options = {})

Parameter Type / default Meaning
model_ids Number array or selected; all if omitted Loaded models to check.
level basic or detailed; basic Detailed adds intersection, containment and validated-volume checks.
apply_transforms Boolean; true Check current pose/scale.
units_confirmed Boolean; false Confirm physical scale for unitless input; this does not resize geometry.
profile Optional object technology: fdm; optional build_volume_mm [x,y,z], min_wall_mm, line_width_mm and overhang_degrees measured from vertical. No printer defaults are assumed.
max_time_ms Positive milliseconds; 30000, max 120000 Per-model time budget. Inconclusive checks remain incomplete.
signal, on_progress AbortSignal; function Cancel or observe phase, model_id/model_index, total_models and optional elapsed_ms.

Results are bounded mesh checks, not a print guarantee or repair. Basic results remain incomplete because detailed checks were not run. Wall sampling cannot certify a minimum thickness.

const report = await viewer.check_printability({
  model_ids: [0], level: 'detailed', units_confirmed: true
});
console.log(report.summary, report.models[0].checks);

Viewer properties

viewer.when_ready, viewer.ready, viewer.error, …

when_ready is a Promise, not a method. ready reports renderer initialization; error holds the most recent reported error message (it is not reset after every successful operation). models_count includes queued/loading models. load_session increases when a loading batch settles. canvas is the owned canvas element. controls.enabled enables/disables pointer/keyboard camera controls; use set_mouse_zoom for zoom. display_units is managed by set_display_units. Static StlViewer.version and StlViewer.workerURL expose build version and worker resolution. Internal renderer/camera/models are native objects; do not call Three.js internals on them.

await viewer.when_ready;
console.log(StlViewer.version, viewer.ready, viewer.models_count);
viewer.controls.enabled = true;

Methods

Examples assume a ready viewer named viewer with model ID 0 loaded, unless the example loads its own models. Replace example file paths with your own files. Angles are radians. Most legacy setters report errors through load_error_callback; newer size/bookmark APIs can throw and asynchronous jobs reject. Do not rely on setters returning a success flag.

when_idle

when_idle() → Promise<StlViewer>

Wait for initialization, pending model/scene imports and derived view geometry. Individual load failures are reported through load_error_callback; a settled batch does not prove every model loaded. It does not wait for animation, modern exports or analysis to finish.

await viewer.when_idle();
console.log(viewer.get_model_info(0, { measurements: false }));

add_model

add_model(model) → void

Queue one model. Choose an explicit unique ID if you need to reference it later. Loading is asynchronous: awaiting add_model itself does not wait for completion. Errors use load_error_callback. VSJ/VSB filenames use the legacy scene loaders; prefer import_scene for staged imports.

viewer.add_model({ id: 1, filename: '/part.stl' });
await viewer.when_idle();

add_models

add_models(models) → void

Queue an array of model descriptors. This is not an atomic batch: successful models remain if another fails.

viewer.add_models([{ id: 1, filename: '/a.stl' }, { id: 2, filename: '/b.obj' }]);
await viewer.when_idle();

remove_model

remove_model(id) → void

Remove one model and cancel its pending work. Other models remain.

viewer.remove_model(0);

set_display

set_display(id, display) → void

Choose flat, smooth, wireframe, edges, mesh, xray, studio or orientation. Flat/smooth also set their legacy shading state. Derived geometry may load asynchronously; a failed preparation preserves the previous view.

viewer.set_display(0, 'smooth');
await viewer.when_idle();

set_view

set_view(id, {display?, smooth_shading?}) → void

Update mode and smoothing together. Omitted properties retain their current/requested values.

viewer.set_view(0, { display: 'studio', smooth_shading: true });
await viewer.when_idle();

set_smooth

set_smooth(id, enabled) → void

Change smoothing independently for modern modes. It is visually ignored in wireframe but retained for the next surface view.

viewer.set_smooth(0, true);
await viewer.when_idle();

set_section

set_section(id, sectionOrNull) → void

Section axis is x/y/z; position is 0–1 across source bounds, with optional flip. Pass null to clear. This open visual cut affects picking/PNG but not exported geometry or mesh measurements.

viewer.set_section(0, { axis: 'z', position: 0.5, flip: false });
// viewer.set_section(0, null);

set_color

set_color(id, colorOrNull) → void

Set a hex color override, or null to restore source colors/default gray. transparent is accepted for compatibility; set_opacity gives numeric transparency control.

viewer.set_color(0, '#357da1');

set_opacity

set_opacity(id, opacity) → void

Set opacity from 0 (invisible) to 1 (opaque). Rendering of intersecting transparent surfaces is approximate.

viewer.set_opacity(0, 0.5);

set_position

set_position(id, x?, y?, z?) → void

Set absolute translation; undefined axes stay unchanged. Stops this model’s animation.

viewer.set_position(0, 20, undefined, 10);

set_scale

set_scale(id, x, y = x, z = x, keepPrevious = false) → void

Set positive absolute axis scales about the existing pivot. A single factor scales all axes. The legacy keepPrevious option preserves reported scales while applying a uniform geometry factor; x/currentX, y/currentY and z/currentZ must agree. Prefer resize_model for size changes that preserve the world-space center.

viewer.set_scale(0, 2);

set_rotation

set_rotation(id, x?, y?, z?, increment = false) → void

Set radians on supplied axes, or add them when increment is true. Omitted axes are unchanged. Stops this model’s animation.

viewer.set_rotation(0, 0, 0, Math.PI / 2);

rotate

rotate(id, x?, y?, z?) → void

Add rotation in radians to the supplied axes. This is incremental, unlike the default set_rotation.

viewer.rotate(0, 0, 0, Math.PI / 4);

set_edges

set_edges(id, enabled) → void

Toggle the legacy one-degree feature-edge overlay. The modern edges view uses a 30-degree threshold. Preparation can run in a worker.

viewer.set_edges(0, true);
await viewer.when_idle();

set_grid

set_grid(enabled, size?, divisions = 10) → void

Show/hide a finite grid. Omit size to cover the scene; explicit size must be positive. Divisions is an integer from 1 to 1000. This replaces any infinite grid.

viewer.set_grid(true, 100, 10);

set_infinite_grid

set_infinite_grid(enabled, spacing = 1, height = 0) → void

Show/hide the infinite grid. Positive spacing and finite height are scene-coordinate values. It is a visual guide, not a print bed or a model transform.

viewer.set_infinite_grid(true, 10, 0);

set_selection

set_selection(idOrNull) → void

Show the editing frame for one model; null clears it. Also supplies the selected scope for export/check APIs. Frames are excluded from exported images and scenes.

viewer.set_selection(0);

pick_model

pick_model(clientX, clientY) → {id, distance} | null

Pick the nearest visible model surface at viewport coordinates, such as event.clientX/Y. Honors visibility and sections. Returns null for no hit.

const rect = viewer.canvas.getBoundingClientRect();
console.log(viewer.pick_model(rect.x + rect.width / 2, rect.y + rect.height / 2));

set_model_units

set_model_units(id, units, scale = false) → void

Legacy signature requires the model ID. units is mm or inch. With scale false, updates the legacy unit convention; true converts geometry only when the label changes (×25.4 for inch, ÷25.4 for mm). For source-unit correction use set_file_units; for readouts use set_display_units.

viewer.set_model_units(0, 'inch', false);

get_model_size

get_model_size(id) → ModelSize

Get dimensions_mm, baseline_mm, file_units, editable and unit_factor metadata. Dimensions are axis sizes before model rotation. Throws on an unknown/not-ready model.

console.log(viewer.get_model_size(0).dimensions_mm);

resize_model

resize_model(id, [xFactor, yFactor, zFactor]) → ModelSize

Multiply size by positive factors about the model’s world-space center. Preserves placement/rotation and stops this model’s animation. Invalid input throws.

const size = viewer.resize_model(0, [2, 2, 2]);
console.log(size.dimensions_mm);

set_file_units

set_file_units(id, units) → ModelSize

Reinterpret a unitless STL/OBJ source as mm or inch while preserving its center. Reversing the choice reverses the conversion. Files with explicit CAD/3MF units cannot be reinterpreted; invalid requests throw.

// For a unitless STL/OBJ authored in inches:
viewer.set_file_units(0, 'inch');

reset_model_size

reset_model_size(id) → ModelSize

Restore the recorded size baseline, keeping source-unit correction and world-space center. Stops this model’s animation.

viewer.reset_model_size(0);

duplicate_model

duplicate_model(id) → number

Return a new model ID at the same pose, with independent appearance/transforms. Immutable geometry is shared. Animation definitions/phase are copied paused. Move the copy to see it separately.

const copyId = viewer.duplicate_model(0);
viewer.set_position(copyId, 50, 0, 0);

set_display_units

set_display_units(units) → void

Choose mm/inch for optional app readouts and scene metadata. Geometry, scene position values and grid spacing are unchanged.

viewer.set_display_units('inch');

set_bg_color

set_bg_color(color) → void

Set the canvas background to a hex color or transparent.

viewer.set_bg_color('#ffffff');

set_center_models

set_center_models(enabled) → void

Control centering of subsequently loaded models only. Existing model geometry/positions are unchanged.

viewer.set_center_models(false);

set_zoom

set_zoom(value = -1) → void

-1 enables automatic fitting. A nonnegative value sets camera Z (clamped above zero), not an optical zoom multiplier.

viewer.set_zoom(-1);

set_auto_zoom

set_auto_zoom() → void

Fit the loaded bounds, preserving the viewing direction, and enable automatic fitting after later load batches.

viewer.set_auto_zoom();

set_auto_rotate

set_auto_rotate(enabled) → void

Start/stop continuous camera rotation. This does not animate model transforms. Hosts should provide pause and honor reduced-motion preferences.

viewer.set_auto_rotate(false);

set_mouse_zoom

set_mouse_zoom(enabled) → void

Enable/disable wheel and pinch zoom in native camera controls.

viewer.set_mouse_zoom(true);

set_auto_resize

set_auto_resize(enabled) → void

Enable/disable resize observation. Does not fit models; use set_auto_zoom for fitting.

viewer.set_auto_resize(true);

do_resize

do_resize() → void

Refresh the drawing surface after a layout change, especially when automatic resize is disabled.

viewer.do_resize();

set_on_model_mouseclick

set_on_model_mouseclick(callbackOrNull) → void

Install the legacy-named pointer callback, replacing any existing pointer handler. Alias of set_on_model_mousedown; pass null to remove it.

viewer.set_on_model_mouseclick((id, event, distance, type) => {
  if (id !== null) viewer.set_selection(id);
});

set_on_model_mousedown

set_on_model_mousedown(callbackOrNull) → void

Install the shared pointer handler. It receives pointer-down and double-click/touch notifications, not only a completed click. See event type codes under on_model_mousedown.

viewer.set_on_model_mousedown((id, event, distance, type) => {
  console.log(id, type, distance);
});

set_drag_and_drop

set_drag_and_drop(enabled) → void

Toggle the low-level canvas local-file drop listeners. They call add_model for each file; the optional app provides richer URL/scene-companion handling.

viewer.set_drag_and_drop(true);

get_camera_state

get_camera_state() → CameraState

Return a copy of position, up and target vectors, each with x/y/z numbers.

const camera = viewer.get_camera_state();
console.log(camera.position);

set_camera_state

set_camera_state(state) → void

Restore any supplied position/up/target vectors; omitted vectors remain unchanged. Up must be nonzero and position must differ from target.

const camera = viewer.get_camera_state();
viewer.set_camera_state(camera);

set_camera

set_camera(x?, y?, z?) → void

Set camera position coordinates, preserving omitted axes, up and target.

viewer.set_camera(100, 100, 100);

set_camera_view

set_camera_view(name) → void

Fit from front, back, left, right, top, bottom or isometric using Z-up and perspective projection. Models do not rotate.

viewer.set_camera_view('top');

get_camera_bookmarks

get_camera_bookmarks() → CameraBookmark[]

Return a defensive copy of named camera states. Restore one with set_camera_state.

const bookmarks = viewer.get_camera_bookmarks();
if (bookmarks.length) viewer.set_camera_state(bookmarks[0].camera);

set_camera_bookmarks

set_camera_bookmarks(bookmarks) → void

Replace the bookmark list. Each entry is {name, camera}; at most 32 unique names, each at most 80 characters. Saved in VSJ/VSB, not localStorage. Invalid lists throw.

viewer.set_camera_bookmarks([{
  name: 'Overview', camera: viewer.get_camera_state()
}]);

get_model_info

get_model_info(id, {measurements?: boolean} = {}) → ModelInfo | null

Synchronous metadata: name, orig_filename, position, dims, rotation, display, smooth_shading, section, color, scale, triangles, source bounds, has_colors, source_units, units, opacity and file_bytes. dims includes scale but not rotation. By default area/volume are calculated synchronously; volume is not validated. Use measurements:false for quick metadata (area/volume null), or measure_model for validated volume.

const info = viewer.get_model_info(0, { measurements: false });
console.log(info?.dims, info?.triangles);

measure_model

measure_model(id, options = {}) → Promise<ModelMeasurements>

Worker analysis returns area_mm2, volume_mm3 (nullable), volume_valid and volume_reason. Supports signal, max_time_ms (default 30000) and on_progress({phase, elapsed_ms}). Open/invalid/inconclusive geometry does not receive a validated volume. Rejections must be caught by the caller.

const result = await viewer.measure_model(0);
console.log(result.area_mm2, result.volume_valid ? result.volume_mm3 : result.volume_reason);

get_model_data

get_model_data(id) → GeometryData | null

Return copied raw positions/colors in source space, without scene transforms. Mutating the copy cannot change the viewer. Prefer duplicate_model when you want a full model copy.

const data = viewer.get_model_data(0);
if (data) console.log(data.positions.length / 9);

get_model_mesh

get_model_mesh(id) → Three.js Mesh | null

Optional interoperability only: needs options.three or globalThis.THREE supplied by the host. Returns an independent mesh; dispose its geometry/material afterward. Without a bridge, reports an error and returns null. The native viewer does not download Three.js.

// Only when this viewer was given the host's Three.js namespace:
const mesh = viewer.get_model_mesh(0);
if (mesh) {
  console.log(mesh.geometry);
  mesh.geometry.dispose();
  mesh.material.dispose();
}

animate_model

animate_model(id, animationOrNull) → void

Start/replace a validated delta/exact animation. Pass null to stop and keep the current pose. Invalid definitions leave existing motion intact. See Animation parameters.

viewer.animate_model(0, { delta: { rotationz: Math.PI * 2, msec: 5000, loop: true } });
Working Example
View code

Copy the complete build/ folder from the plugin download beside this HTML file, add the models below, and serve the folder over HTTP(S). This is the same example program with local file paths.

These are the original legacy ViewSTL example models. Save the required files in a models/ folder beside your HTML: knot.stl · bar.stl.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ViewSTL — Model animation</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 20px; }
    #example-viewer { width: 100%; height: 500px; }
    button { padding: 10px 16px; }

  </style>
</head>
<body>
  <div id="example-viewer"></div>
  <p id="example-status" role="status">Opening example…</p>
  <button id="example-motion" type="button" hidden>Pause animation</button>
  <button id="example-retry" type="button" hidden>Retry example</button>

  <script type="module">
import StlViewer from './build/viewstl.js';

const container = document.getElementById('example-viewer');
const status = document.getElementById('example-status');
const retry = document.getElementById('example-retry');
const modelURL = new URL('./models/knot.stl', import.meta.url).href;
const secondModelURL = new URL('./models/bar.stl', import.meta.url).href;

const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
let loadError = null;
function showError(message) {
  loadError = message;
  status.textContent = message;
  retry.hidden = false;
}
retry.addEventListener('click', () => location.reload());

const animations = [
  { delta: { rotationz: 2 * Math.PI, msec: 6000, loop: true } },
  {
    delta: { rotationx: 0.5, rotationy: 0.7, rotationz: 1, msec: 4000, loop: true },
    exact: { x: 110, y: 10, z: 10, scale: 0.9, msec: 3500 }
  }
];
const viewer = new StlViewer(container, {
  models: [
    { id: 0, filename: modelURL, x: -90, scale: 0.55, color: '#64a5b2', display: 'smooth',
      animation: reducedMotion.matches ? null : animations[0] },
    { id: 1, filename: secondModelURL, x: 90, scale: 0.7, color: '#e79a65',
      animation: reducedMotion.matches ? null : animations[1] }
  ],
  load_error_callback: showError
});
function setPlaying(enabled) {
  animations.forEach((definition, id) => {
    const state = viewer.get_model_animation_state(id);
    if (enabled && state?.status === 'idle') viewer.animate_model(id, definition);
    else if (enabled) viewer.resume_model_animation(id);
    else viewer.pause_model_animation(id);
  });
}

// Refit through the plugin when the example container changes width.
let lastWidth = container.clientWidth;
const resize = new ResizeObserver(() => {
  if (container.clientWidth === lastWidth) return;
  lastWidth = container.clientWidth;
  viewer.do_resize();
  viewer.set_auto_zoom();
});
resize.observe(container);

try {
  await viewer.when_idle();
  if (loadError) throw new Error(loadError);
  viewer.set_camera_view('isometric');
  status.textContent = 'Ready. Drag to rotate, scroll to zoom. Keyboard: arrows, + / − and F.';
  const motionButton = document.getElementById('example-motion');
  let playing = !reducedMotion.matches;
  function updateMotion() {
    motionButton.textContent = (playing ? 'Pause' : 'Resume') + ' animation';
    motionButton.setAttribute('aria-pressed', String(playing));
  }
  motionButton.hidden = false;
  updateMotion();
  motionButton.addEventListener('click', () => {
    playing = !playing;
    setPlaying(playing);
    updateMotion();
  });
  reducedMotion.addEventListener('change', event => {
    if (event.matches) {
      playing = false;
      setPlaying(false);
      updateMotion();
    }
  });
} catch (error) {
  showError(error.message);
  viewer.dispose();
}
addEventListener('pagehide', event => { if (!event.persisted) { resize.disconnect(); viewer.dispose(); } });
  </script>
</body>
</html>

pause_model_animation

pause_model_animation(id) → void

Pause while retaining pose, definition and elapsed time.

viewer.pause_model_animation(0);

resume_model_animation

resume_model_animation(id) → void

Resume a paused model animation. Hidden documents/lost graphics contexts may still temporarily suspend it.

viewer.resume_model_animation(0);

get_model_animation_state

get_model_animation_state(id) → AnimationPlaybackState | null

Return status (idle/running/paused/stopped/completed), elapsed_ms, suspended and track summaries {mode,msec,loop,progress}. Unknown IDs return null.

console.log(viewer.get_model_animation_state(0));

check_printability

check_printability(options = {}) → Promise<PrintReport>

Run local basic/detailed checks; see Print-check options. Returns overall summary and per-model checks with pass/issue/inconclusive/not_run status, messages and bounded highlights. Does not repair geometry or guarantee printing.

const report = await viewer.check_printability({ model_ids: [0], level: 'basic' });
console.log(report.summary);

is_printability_current

is_printability_current(report) → boolean

Check whether the report still matches the viewer’s geometry, transforms and units before displaying it. Camera/color changes do not invalidate it.

const report = await viewer.check_printability({ model_ids: [0] });
console.log(viewer.is_printability_current(report));

show_printability

show_printability(report, {issue, model_id?}) → number

Show bounded diagnostic geometry for a check code, such as boundary_edges. Returns highlighted-element count. Requires a current report. Highlights do not enter PNG, model or scene exports.

const report = await viewer.check_printability({ model_ids: [0] });
if (viewer.is_printability_current(report)) {
  viewer.show_printability(report, { model_id: 0, issue: 'boundary_edges' });
}

clear_printability

clear_printability() → void

Remove temporary diagnostic highlights without removing models.

viewer.clear_printability();

clean

clean() → void

Cancel viewer work and remove all models, selection/highlights, bookmarks and model animation. Display units reset to mm. Keeps the viewer/canvas usable for another load.

viewer.clean();
viewer.add_model({ id: 0, filename: '/replacement.stl' });
await viewer.when_idle();

dispose

dispose() → void

Permanently release this viewer’s workers, GPU resources, listeners and owned DOM. Other children of the container remain. Create a new instance to view again.

viewer.dispose();

Events and callbacks

Pass callbacks in the constructor options. The examples show independent configurations; combine the options you need in one viewer. Callback names are options, not DOM event names or an addEventListener API.

ready_callback

ready_callback()

Runs after renderer initialization, before initial models have finished loading. It no longer means “Three.js finished loading.” Use viewer.when_ready if a Promise is preferable.

// Pass this option when constructing the viewer.
const options = {
  ready_callback() {
    console.log("Viewer initialized");
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

all_loaded_callback

all_loaded_callback()

Runs when a nonempty loading batch settles, including failed/cancelled jobs. An empty initial viewer does not emit it. For ordinary model batches it runs before the final model_loaded_callback; record errors separately.

// Pass this option when constructing the viewer.
const options = {
  all_loaded_callback() {
    console.log("Loading batch settled");
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

loading_progress_callback

loading_progress_callback(status, session)

status maps model IDs to {loaded,total} byte counts; totals may be unknown (0). Optional CAD phase is loading-cad or parsing-cad. session is the load-session number. Do not interpret a missing total as completion.

// Pass this option when constructing the viewer.
const options = {
  loading_progress_callback(status, session) {
    for (const [id, p] of Object.entries(status)) {
      console.log(session, id, p.total ? p.loaded / p.total : p.loaded, p.phase);
    }
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

model_loaded_callback

model_loaded_callback(id)

Runs after a model successfully loads. id identifies it. No success event is emitted for a failed model.

// Pass this option when constructing the viewer.
const options = {
  model_loaded_callback(id) {
    console.log("Loaded model", id);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

load_error_callback

load_error_callback(message)

Receives an error string for initialization, loading, legacy guarded methods and worker/view failures. Not every API uses this channel: modern exports, import_scene and analysis reject Promises and should be caught with try/catch. Host callback exceptions are logged rather than allowed to break the viewer.

// Pass this option when constructing the viewer.
const options = {
  load_error_callback(message) {
    console.error(message);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

on_model_drop

on_model_drop(filename)

Low-level canvas callback before each local dropped file is passed to add_model. filename is a name string, not a File. It is a notification, not a cancellable hook.

// Pass this option when constructing the viewer.
const options = {
  on_model_drop(filename) {
    console.log("Dropped", filename);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

on_model_mouseclick

on_model_mouseclick(id, event, distance, type)

Legacy-named alias of on_model_mousedown. There is one shared pointer callback; on_model_mousedown wins if both constructor options are supplied. Use either setter to replace it.

// Pass this option when constructing the viewer.
const options = {
  on_model_mouseclick(id, event, distance, type) {
    console.log(id, type, distance);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

on_model_mousedown

on_model_mousedown(id, event, distance, type)

Native pointer notification. type: 1 for ordinary mouse pointer-down, 3 for right-button pointer-down, 11 for double-click, 20 for touch pointer-down. id is null on misses only when send_no_model_click_event is true; then distance is 0. event is the native PointerEvent/MouseEvent. A double-click also has its ordinary pointer-down events.

// Pass this option when constructing the viewer.
const options = {
  on_model_mousedown(id, event, distance, type) {
    if (id !== null && type === 11) console.log("Double-clicked", id);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

animation_state_callback

animation_state_callback(id, state, reason)

Deferred state-change notification, not a per-frame callback. state is AnimationPlaybackState. Reasons include started, restored, replaced, paused, resumed, completed, stopped, manual, removed, clean and error. Manual transforms stop model motion.

// Pass this option when constructing the viewer.
const options = {
  animation_state_callback(id, state, reason) {
    console.log(id, state.status, reason);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

animation_complete_callback

animation_complete_callback(id, state)

Fires once when all finite tracks complete, following animation_state_callback with reason completed. Pausing, stopping and looping motion do not count as completion.

// Pass this option when constructing the viewer.
const options = {
  animation_complete_callback(id, state) {
    console.log("Animation finished", id, state.elapsed_ms);
  }
};
const viewer = new StlViewer(document.getElementById('viewer'), {
  ...options, models: [{ id: 0, filename: '/model.stl' }]
});

Save and load methods

Modern Promise-based methods support explicit scope, progress and cancellation. The older methods remain for compatibility, with their distinct behavior documented below. VSB/VSJ save a whole scene; ordinary model formats save the models explicitly selected by the API caller. All local encoding stays on-device.

export_models

export_models(options) → Promise<ExportResult>

Export one or more meshes into a local Blob. Required format and other fields are under Export options. Returns {blob,filename,format,units,model_ids,triangles,representation:"mesh"}. Omitted model_ids means all loaded models. The preview’s Save UI explicitly selects just one model.

await viewer.when_idle();
const result = await viewer.export_models({ format: 'stl', model_ids: [0] });
console.log(result.filename, result.blob.size);

download_models

download_models(options) → Promise<ExportResult>

Same export settings/result as export_models, plus a browser download. Use explicit model_ids or selected for one-model scope. Trigger from a user action.

await viewer.download_models({ format: 'step', model_ids: [0], filename: 'part' });

get_stl_bin

get_stl_bin(id) → ArrayBuffer | null

Legacy synchronous binary STL. Applies scale and centering, but does not bake scene translation/rotation. Missing/not-ready models return null (unknown IDs also report an error). Use export_models for current-pose, cancellable export.

const bytes = viewer.get_stl_bin(0);
if (bytes) console.log(bytes.byteLength);

download_model

download_model(id, filename?) → void

Legacy STL-only browser download, with get_stl_bin transform behavior. The filename is not a format selector: giving .step does not create STEP. Prefer download_models for modern formats and correct extensions.

viewer.download_model(0, 'part.stl');

get_image_blob

get_image_blob(type = "image/png") → Promise<Blob>

Wait for pending viewing work, draw and capture the canvas. Returns a PNG by default. Sections, background and camera are included; selection/diagnostic overlays are excluded. Other MIME types depend on browser support.

const png = await viewer.get_image_blob();
console.log(png.type, png.size);

export_scene

export_scene(options) → Promise<SceneExportResult>

Capture the whole loaded scene as vsb or vsj. Returns {blob,filename,format,model_ids,triangles,representation:"scene"}. VSB embeds meshes; VSJ holds references. Both preserve camera, appearance, size/unit metadata, bookmarks and animation. Pending loads/empty scenes reject; call when_idle first.

await viewer.when_idle();
const saved = await viewer.export_scene({ format: 'vsb', filename: 'my-scene' });
console.log(saved.blob.size);

download_scene

download_scene(options) → Promise<SceneExportResult>

Export the whole scene and start its browser download. Use VSB for a self-contained file; VSJ companions must be kept alongside the manifest.

await viewer.download_scene({ format: 'vsj', filename: 'scene', force_basename: true });

import_scene

import_scene(source, options = {}) → Promise<{model_ids, scene, used_files}>

Preferred staged scene loader. Accepts File/Blob or public URL; see Scene import options. Appends models with new IDs atomically and restores saved settings. Local VSJ companions go in files; URL inputs/references require the configured relay. Errors reject without partially replacing the scene.

const saved = await viewer.export_scene({ format: 'vsb' });
const result = await viewer.import_scene(saved.blob, { format: 'vsb' });
console.log(result.model_ids);

get_vsj

get_vsj(as_js_obj = false, force_basename = false, for_vsb = false) → string | Scene

Legacy synchronous scene description of already loaded models. false returns JSON text; true returns an object. force_basename removes paths from references. for_vsb prepares embedded filenames/geometry conventions for packaging; normally leave false and use get_vsb/export_scene. Does not embed model data or wait for loading.

await viewer.when_idle();
const json = viewer.get_vsj();
const scene = viewer.get_vsj(true);
console.log(scene.models.length, json.length);

download_vsj

download_vsj(filename = "1.vsj") → void

Legacy JSON scene download. Includes references only, not local file bytes. Wait for loading before calling.

await viewer.when_idle();
viewer.download_vsj('scene.vsj');

load_vsj

load_vsj(source) → void

Legacy asynchronous loader for a URL/path or File/Blob (not raw JSON text). Direct browser URL fetch needs CORS for another origin. It may change settings or add models before a later error, and has no companion-files option. Use import_scene for atomic loading and local companions. Completion uses when_idle/callbacks.

viewer.load_vsj('/scene.vsj');
await viewer.when_idle();

get_vsb

get_vsb() → {vsj, files:[{id, bin}]}

Legacy synchronous package ingredients: scene object and binary STL ArrayBuffers. This is not a ZIP Blob yet. Includes already loaded models only and can allocate substantial memory.

await viewer.when_idle();
const bundle = viewer.get_vsb();
console.log(bundle.files.length);

get_vsb_blob

get_vsb_blob() → Promise<Blob>

Package legacy VSB ingredients into a ZIP Blob. Wait for when_idle explicitly; this method only awaits initialization internally. VSB stores meshes, not original CAD history, and binary STL color precision is limited.

await viewer.when_idle();
const blob = await viewer.get_vsb_blob();
console.log(blob.size);

download_vsb

download_vsb(filename = "1.vsb") → Promise<void | null>

Legacy VSB download; appends .vsb if needed. Errors use load_error_callback and resolve null. Use download_scene for rejection-based error handling and cancellation.

await viewer.when_idle();
await viewer.download_vsb('scene.vsb');

load_vsb

load_vsb(source) → void

Legacy asynchronous VSB loader for URL/path or File/Blob. Direct URL loading follows browser CORS. It does not provide import_scene’s transaction/cancellation contract; wait with when_idle and observe error callbacks.

const blob = await viewer.get_vsb_blob();
viewer.load_vsb(blob);
await viewer.when_idle();

Guides and examples

ViewSTL 2 — native WebGL2 plugin

Version: 2.0.0-rc.5 (ViewSTL v2.00 release candidate, GitHub and npm). On main, the new plugin is in src/ and build/; the legacy tree is in legacy/v1/. The unchanged master branch retains original legacy paths. The v2 website is available at /.

Quick start

Download the plugin ZIP from the GitHub release, then copy its complete build/ folder to /viewstl-v2/ on your website. Paste these three lines into your page's <body>:

<div id="viewer" style="height:500px"></div>
<script src="/viewstl-v2/viewstl.min.js"></script>
<script>new StlViewer(document.getElementById('viewer'), { models: [{ filename: 'model.stl' }] });</script>

Replace model.stl with your model's path and open the page over HTTP(S). You can also drag local files onto the viewer. The full distribution folder is required so the viewer can find its worker and optional format assets.

Browser script

Copy the complete build/ directory to your server, including the worker, its chunks/ and cad/ directories and third-party notices. Then:

<div id="viewer" style="width:100%;height:500px"></div>
<script src="/viewstl-v2/stl_viewer.min.js"></script>
<script>
  const viewer = new StlViewer(document.getElementById('viewer'), {
    models: [{ id: 0, filename: '/models/part.stl', color: '#568bdc' }],
    load_error_callback: message => console.error(message)
  });
</script>

viewstl.min.js is an identical classic-script alias. The canvas requires a container with a nonzero height. Files must be served over HTTP(S), including during local development; browsers restrict workers on file: URLs.

ES modules and npm

import StlViewer from './viewstl-v2/viewstl.js';

const viewer = new StlViewer(container, {
  models: [{ id: 0, local_file: fileInput.files[0] }]
});

await viewer.when_idle();
console.log(viewer.get_model_info(0));

The package contains ESM, CommonJS and TypeScript entry points. Importing the package is safe during server-side rendering; constructing a viewer requires a browser DOM and WebGL2.

Install with npm install viewstl. No version suffix is required: the latest tag selects the current release candidate. The established @omrips/viewstl package is also updated with the same plugin; npm install @omrips/viewstl works without a version suffix. Use either package name consistently in imports and asset-copy paths; there is no need to install both.

With a bundler, copy the complete node_modules/viewstl/build/ directory to your app's public assets (for example public/viewstl-v2/), keeping the worker, chunks/, cad/ and notices together. Set worker_url to its served URL, or provide your bundler's worker factory. Do not assume a bundler copies a module worker's dynamic chunks merely because it sees a URL string.

import StlViewer from 'viewstl';

const viewer = new StlViewer(container, {
  worker_url: '/viewstl-v2/viewstl.worker.js'
});

For Vite, the optional factory form is:

import ViewerWorker from 'viewstl/worker?worker';
const viewer = new StlViewer(container, {
  worker_factory: () => new ViewerWorker()
});

The static-assets/worker_url integration above was verified in production builds using Vite 8.3.4 and Webpack 5.112.0, including STEP/IGES and lazy archive assets. The optional Vite worker-factory example is separate integration guidance and was not part of those checks. A factory must return a new worker for each scheduled job and arrange for all dynamic chunks and CAD assets to be served. The CommonJS entry point also requires worker_url or worker_factory because it cannot infer a browser asset URL.

Node.js model analysis

Run npm install viewstl. Node.js 20+ resolves the analysis worker and optional CAD WASM directly inside the package; there is no asset-copy step for server use.

import { analyzeFile } from 'viewstl/node';
const report = await analyzeFile('part.stl', { units: 'mm' });
console.log(report.dimensions_mm, report.surface_area_mm2, report.volume_mm3);

CommonJS is supported with const { analyzeFile } = require('viewstl/node'). The /node entry point is separate from the browser viewer and has no DOM, WebGL or Three.js dependency. It supports STL, OBJ, 3MF, STEP/STP and IGES/IGS. STEP/IGES measurements describe the tessellated mesh, not exact CAD surfaces. Scene files, rendering, thumbnails and server-side exporting are outside this API.

Input and units

analyzeFile(path, options) accepts a local regular-file path or a file: URL object. analyzeBuffer(data, options) accepts a Node Buffer, Uint8Array or ArrayBuffer, and requires filename or format to identify the format:

import { analyzeBuffer } from 'viewstl/node';
const report = await analyzeBuffer(uploadBuffer, { filename: 'part.stl', units: 'mm' });

The input buffer is copied without modifying or detaching the caller's data. HTTP(S) URLs and network-share paths are rejected. The module does not fetch external model resources, run a web service, or write input files to disk. An application's own upload handler determines how files reach its server and whether they are stored. Browser-selected files sent to a server are uploads; the preview website continues to perform its checks on the user's device.

STL and OBJ carry no reliable physical units. Supply units: 'mm', 'cm', 'm', 'in', 'ft' or 'um' when the input scale is known. With no scale, units and all physical measurement fields (dimensions_mm, surface_area_mm2, volume_mm3) are null; the unit check is inconclusive. The generic dimensions, surface_area and volume fields then use input coordinate units. When units are known, these generic fields also use mm, mm² and mm³.

3MF and CAD file units are honored and normalized to mm automatically. They cannot be overridden with a different input scale. units_source is 'file', 'option' or null. volume_valid describes validation of the mesh volume; it does not confirm a physical scale or guarantee printability. Invalid or unverified volume is returned as null. level: 'basic' skips detailed intersection and validated-volume checks; the default is 'detailed'.

Jobs, limits and cancellation

The convenience functions share an analyzer with two active workers, up to 16 waiting jobs, and at most 128 MiB of copied buffer data waiting in the queue. Create an independent analyzer when the host needs different concurrency:

import { createAnalyzer } from 'viewstl/node';
const analyzer = createAnalyzer({ concurrency: 1, max_queue: 4 });
try {
  const reports = await Promise.all(paths.map(path => analyzer.analyzeFile(path, {
    units: 'mm', signal: controller.signal, timeout_ms: 60000,
    limits: { max_file_bytes: 32 * 1024 ** 2, max_memory_bytes: 512 * 1024 ** 2 }
  })));
} finally {
  await analyzer.dispose();
}

Here paths is the application's array of file paths and controller is its AbortController. Keep submitted batches within the configured queue capacity. concurrency is 1–8; max_queue may be zero; max_queued_bytes controls queued buffer copies. Exceeding queue admission rejects with ERR_ANALYZER_BUSY. Use a shared analyzer for a service rather than creating an unbounded number of independent analyzers per request.

Per-job defaults are 64 MiB input, 1,000,000 triangles, 128 MiB expanded archive data, 1,024 archive entries and a 1 GiB estimated working-set budget. These are independent ceilings: the memory estimate can reject a mesh below the triangle ceiling. Override them in limits with max_file_bytes, max_triangles, max_uncompressed_bytes, max_archive_entries and max_memory_bytes. Unknown limit names are rejected. CAD input/output and WASM-heap checks are also applied, and retained CAD memory counts towards the analysis budget.

timeout_ms defaults to 120,000 and covers waiting, reading, parsing and checks. Expiry terminates the worker and rejects with ERR_ANALYSIS_TIMEOUT. signal cancels queued or active work with an AbortError (ABORT_ERR). dispose() cancels outstanding jobs and waits for their workers to terminate. Workers are also terminated after success or failure before their slots are reused, releasing parser and CAD state. Processing failures do not disable the analyzer.

max_time_ms separately limits the mesh-check stage (30,000 by default, up to 120,000). It may yield a partial report with limit_reason: 'time'. Inspect summary and each check's status; an incomplete check is not a pass. on_progress receives phase and elapsed-time updates. The optional profile uses the same FDM build-volume, overhang, bed-contact and sampled wall-thickness settings as browser print checks. FDM results remain estimates.

Memory limits are conservative admission estimates plus a worker JS-heap limit, not a hard limit on process RSS. ArrayBuffers and WASM memory are not fully covered by the JS-heap limit. Size host/container limits for total concurrency; use process/container isolation when serving untrusted uploads. The library does not provide an HTTP endpoint, authentication or a filesystem sandbox.

Full app controls

mountViewstlApp is the optional app controller, exported from the same viewstl.js bundle as StlViewer (and as a global in the classic script). It owns the file queue, memory admission, cancellation, selection, scene placement, translation/rotation/depth gestures, viewing modes, sectioning, colors, grid, PNG downloads, keyboard handling, and control state.

import { mountViewstlApp } from './viewer/viewstl.js';
const app = mountViewstlApp(document, {
  sample_url: new URL('./sample-vase.stl', import.meta.url).href,
  sample_name: 'Twist vase.stl'
});
await app.openFiles(fileInput.files);
console.log(app.getState()); // detached metadata snapshot, no geometry copies
app.dispose(); // cancels work and releases listeners, workers and WebGL

This controller binds the preview's HTML control layout; the host supplies markup and CSS. The reference layout is Viewstl/site/index.html in the sibling website workspace. requiredControls in src/app.js validates the control IDs before registering handlers. Missing controls and duplicate mounts produce a descriptive error. Custom integrations without that layout use StlViewer. Branding, page metadata, advertisements and static help/privacy text belong to the host page. The preview's JavaScript is only the initialization call.

For an embedded app, pass an HTMLElement containing the layout. Queries, keyboard events and drops are scoped to that root. For multiple roots, prefix each layout's IDs and label/ARIA references and pass the matching id_prefix; the controller separates radio groups as well. dispose() permits remounting and prevents late reads from updating the new app.

openFiles() accepts Files or configured {name, url, size?} sources and resolves when the queue settles. File errors appear in the notice; success is reflected by getState().models. Other methods are select(id), remove(id), cancelLoading() and clear(). An omitted sample_url hides the sample action. worker_url, worker_factory and worker_timeout support alternate hosting, bundlers and CommonJS use.

Loading the bundle does not create a canvas or worker. The first file creates the WebGL2 viewer. App source modules are bundled into viewstl.js; the host ships no separate memory/layout/transform helpers. Worker JS, archive chunks and CAD WASM remain separate plugin-owned assets to keep parsing off the main thread and avoid downloading CAD code for STL/OBJ users.

get_model_info() also exposes copied source bounds, has_colors, and source_units (null for unitless sources), so consumers need not inspect internal geometry or retain its buffers.

Rendering and file processing

  • WebGL2 only; no CanvasRenderer, WebGL1 or IE fallback.
  • No Three.js or other third-party library in the STL/OBJ rendering core.
  • ASCII/binary STL, VisCAM/MeshLab and Materialise colors, plus the legacy ViewSTL colored-STL convention.
  • OBJ polygon fan triangulation and negative indices. OBJ materials, textures and robust triangulation of concave polygons are not included.
  • Core 3MF meshes, nested components, build transforms, units, base materials and vertex colors. Root model relationships are followed. Texture/composite materials, external component parts and unsupported required extensions produce explicit errors rather than silent loss.
  • STEP/STP and IGES/IGS solid/surface import via the optional pinned Open CASCADE WASM reader. Units normalize to millimetres; assembly placements and mesh/face colors are retained. All surfaces in a file form one model. The display mesh is an approximation (relative deflection 0.001, angular deflection 0.5 radians), not an exact CAD editing or measurement kernel. Use self-contained exports: external part files are not fetched, and native SolidWorks/Solid Edge formats, feature history, PMI and curve-only files are unsupported. Incomplete external-reference assemblies may lack parts.
  • Deploy the entire build/cad/ directory alongside chunks/ and the worker. CAD code/WASM is loaded only for CAD files; local input bytes are never uploaded. Each job's worker is terminated on completion, cancellation, error or timeout. CAD admission estimates 128 MiB plus 40 times the input size. The WASM heap is separately capped to at most 512 MiB and to part of the remaining scene budget. Output arrays and triangle counts are also checked; these checks cannot predict exact peak browser/GPU memory for every CAD file.
  • A strict CSP must allow script-src 'self' 'wasm-unsafe-eval' for the worker; JavaScript unsafe-eval is not needed. Serve .wasm as application/wasm. scripts/prepare-cad.mjs replaces the pinned glue's dynamic bindings with static closures and adds its heap ceiling. The WASM is unmodified. Adapted glue, the recipe, license texts and source references ship with the assets.
  • VSJ JSON and VSB archives containing json_data.vsj and STL files.
  • ZIP/3MF uses separately loaded worker chunks containing pinned fflate, saxes and xmlchars. Those chunks are not fetched for ordinary STL/OBJ.
  • ZIP accepts stored and deflated entries, validates CRC/length and bounds expanded data. ZIP64, encryption and multi-disk archives are unsupported.
  • Local files never leave the browser. Remote URLs obey normal CORS and same-origin credential rules; there is no upload service or CORS proxy.

Parsing, bounds, flat normals and initial requested smooth/edge geometry are calculated in workers. Results use transferable buffers. Workers are bounded and terminated after jobs, on cancellation, and on disposal.

Changing an already-loaded model to smooth/edges/wireframe derives its additional data in a worker. That operation intentionally copies positions once, since the main thread retains them for synchronous export and picking. Nonuniform-scale area queries also require a cached synchronous scan. These are distinct from the initial file handoff, which does not clone geometry.

Rendering runs only when needed, including during interaction and animation. Transparency uses model-level back-to-front sorting; intersecting transparent surfaces can still have ordering artifacts. STL export stores face color, so a 3MF triangle's per-vertex gradient cannot survive STL/VSB export exactly.

Model animation

Animation is included in 2.0.0-rc.5 on GitHub and in both npm packages. Playback remains inside the native WebGL2 plugin.

After a model has loaded, one call starts continuous rotation:

await viewer.when_idle();
viewer.animate_model(0, {
  delta: { rotationz: 2 * Math.PI, msec: 5000, loop: true },
});

The legacy animation model option also works. delta adds a change to the starting pose; exact interpolates toward an absolute target. They can operate together on different properties and finish independently:

viewer.animate_model(0, {
  delta: { rotationx: 1, rotationy: 1.1, rotationz: 1.2, msec: 1000, loop: true },
  exact: { x: 1000, y: 150, z: 200, scale: 5, msec: 3000 },
});

viewer.pause_model_animation(0);  // Keep the pose and elapsed time.
viewer.resume_model_animation(0);
console.log(viewer.get_model_animation_state(0));
viewer.animate_model(0, null);    // Stop at the current pose; discard playback.

Supported properties are x/y/z, rotationx/y/z in radians and scale or scalex/scaley/scalez. Durations are positive milliseconds, defaulting to 300. Scale deltas are additive, as in the legacy API; all resulting scales must stay positive. Only delta tracks repeat, accumulating their change each cycle. A negative looping scale delta is rejected. Delta and exact may not claim the same property. Inputs must be finite and within JavaScript's safe numeric range. Invalid replacements leave the current animation intact.

Starting a new animation replaces the old one from the current pose. Manual position, rotation, scale and unit setters stop that model's playback first. Camera interaction, color and viewing-mode changes remain usable. Completed tracks no longer write their properties. Removal, clean() and dispose() release playback; paused/completed models do not keep the render loop running.

Timing follows elapsed frame timestamps, not frame count. Hidden documents and lost WebGL contexts suspend playback without including that gap on resumption. User pause is independent of this automatic suspension. State queries return status, elapsed_ms, suspended and active tracks; unknown IDs return null.

Set callbacks in the viewer options:

const viewer = new StlViewer(container, {
  animation_state_callback(id, state, reason) {
    console.log(id, state.status, reason);
  },
  animation_complete_callback(id, state) {
    console.log('Finished', id, state.elapsed_ms);
  },
});

Callbacks describe transitions, not every frame, and are deferred until the current operation finishes. Completion fires once when every track finishes; an animation with a looping track does not complete. Call playback controls on loaded models, for example after when_idle().

VSB/VSJ preserve the animation definition, original pose, elapsed phase, rotation pivot and pause flag. Both scene-loading APIs start incoming animations together after all incoming models load. VSJ still needs its referenced model files. Animated VSB entries retain unscaled mesh data and explicit scale metadata; static entries retain the previous scale-baked convention. This prevents scale being applied twice. Older viewers may read the pose but cannot restore the new playback-state extension exactly.

PNG and modern export_models() capture the current pose without stopping playback. Legacy get_stl_bin() keeps its documented scale/centering-only export convention. Regular model files do not store animation. Print checks should run while the affected models are paused; subsequent motion makes the report stale.

A self-contained example is in examples/v2/animation.html in the source checkout (serve with pnpm start). It includes Play, Pause, Resume, Stop and Reset.

The app controller also supports an optional animation-* group (see the new site's HTML template): animation-details, animation-fields, animation-axis, animation-seconds, animation-direction, animation-play, animation-pause, animation-stop, animation-status and animation-error. Supply the whole group or omit it. It exposes selected-model spin around the mesh center, duration (0.1–3,600 seconds per turn), direction and playback buttons. Other models keep playing when selection changes. Manual tools capture the current animated pose. Readouts update at most four times per second; geometry and GPU buffers are reused.

mountViewstlApp composes animation_state_callback and animation_complete_callback options with its own UI behavior. app.getState() includes each model's current transform and animation state. Ordinary files never auto-spin. Imported running animations are paused when the browser reports prefers-reduced-motion: reduce; explicit user playback remains available.

App print checks pause running target models. Optional print-resume and print-animation-note elements expose resumption; closing the panel does not silently restart motion. Resuming clears stale results and keeps the panel open. Scene saving includes playback; PNG and regular model saving capture a pose.

Existing API and intentional differences

The existing constructor, model options and documented method names are implemented. See the full inventory. Existing getters remain synchronous. get_vsb_blob() continues to return a Promise.

  • width / height override their canvas_width / canvas_height aliases.
  • bg_color overrides bgcolor.
  • on_model_mousedown overrides on_model_mouseclick; both setter names work.
  • set_model_units(model_id, units, scale) keeps the executable three-argument signature. The old documentation omitted model_id incorrectly.
  • load_three_files: 'directory/' remains a legacy alias for the worker directory, resolved against the page. It never loads Three.js. An explicit worker_url takes precedence; false/omitted uses the entry point's directory.
  • jszip_path and jszip_utils_path are accepted deprecated options, with no effect: archive support is shipped in the package's lazy worker chunks. No scripts are fetched from those legacy paths.
  • get_model_info().dims now updates after scaling. The old implementation returned stale dimensions even when area and volume had changed.
  • Zero IDs, zero coordinates, zero rotations and zero opacity are valid. Invalid positive-only scales, colors, transforms and IDs report errors.
  • Model-loading batches preserve the legacy final callback order: all_loaded_callback precedes the final model_loaded_callback. Failed/cancelled jobs settle the batch instead of leaving it hanging. An empty initial viewer does not emit all_loaded_callback.
  • set_center_models controls centering of subsequently loaded models.
  • Model data remains CPU-resident for synchronous export/picking and context restoration. clean() and dispose() release GPU buffers and worker jobs.
  • dispose() removes only the viewer's owned DOM/listeners, not its siblings.
  • The renderer, camera and models are native objects. Direct Three.js calls into .camera, .scene, .renderer, or .models are not compatible.

STL export retains the legacy convention: geometry scale and centering are baked into vertices; scene position and rotation are not baked. VSB saves position/rotation in its manifest and avoids applying scale twice.

Volume is a signed-tetrahedron mesh calculation reported as an absolute value, not a watertightness or printability certification. For inch models, area/volume follow the legacy conversion convention.

Optional Three.js mesh bridge

The ordinary viewer never imports Three.js. To request a real Three.js Mesh from get_model_mesh, pass your own instance:

const viewer = new StlViewer(container, { three: THREE });
// after loading:
const mesh = viewer.get_model_mesh(0);

The bridge uses options.three or globalThis.THREE. With neither present, get_model_mesh reports a load error and returns null. It does not return a misleading imitation Mesh.

Import supports static BufferGeometry meshes and legacy Geometry vertices/ faces. It copies geometry and basic material color/opacity. Placement is controlled by the supplied model options, as in the old plugin. Skinning, instances, morph targets, child objects, multiple/textured/shader materials are rejected. Export returns an independent BufferGeometry Mesh. Dispose the returned mesh's geometry and material when finished with it.

Use get_model_data(id) for a native copy of raw geometry, and add_model({ data, ...options }) to add it without Three.js. Data does not include scene transforms; copy those options explicitly when duplicating.

Use get_image_blob() for screenshots; direct renderer.domElement.toDataURL outside a draw is not guaranteed because continuous drawing-buffer preservation is disabled.

Additional options and helpers

Option Default
worker_url Beside the ESM/classic script
worker_factory None
worker_count 2, allowed range 1–4
worker_timeout 120,000 ms per job
pixel_ratio Device pixel ratio capped at 2
max_file_bytes 256 MiB
max_triangles 5,000,000
max_uncompressed_bytes 256 MiB per archive
max_archive_entries 1,024
max_memory_bytes Number.MAX_SAFE_INTEGER (optional estimated working-set budget)

Limits are configurable positive integers. These are ceilings, not guarantees that every mobile device has enough memory for the maximum. Derived smooth normals/edge maps can use substantially more memory than flat rendering.

max_memory_bytes adds conservative checks for parsed geometry, text parsing, archive expansion, expanded 3MF instances, and derived smooth/wire/edge maps. It estimates working memory, including GPU buffers; it does not measure free memory. Existing default byte/triangle limits remain unchanged for embedders. The ViewSTL preview site configures its own device-dependent limits: half the approximate navigator.deviceMemory report, or half a 2 GiB fallback if absent. This leaves a 50% margin against that estimate, not a guaranteed allocation or protection against an OS/browser killing an exhausted tab.

Local binary STL files are parsed in chunks of at most 65,536 triangles (3.125 MiB input data) plus their 84-byte header. This avoids holding a complete input file and a second assembled copy alongside geometry. ASCII/OBJ/archive input is still read as a complete buffer; OBJ no longer creates a full array of every input line. Native GPU allocation errors clean up partial resources and report through load_error_callback.

when_ready resolves after renderer initialization, before initial models finish. when_idle() waits for pending model, scene and derived-geometry work; inspect load_error_callback for individual failures. get_image_blob waits for that work, draws and returns a PNG Blob by default.

Keyboard: arrows orbit; + / - zoom; F fits the scene. Mouse: drag orbits, shift/right/middle drag pans, wheel zooms. Touch: one finger rotates and two fingers pan/pinch. Real mobile-device validation remains necessary.

For a same-origin standalone deployment, allow your script and worker assets in the site's Content Security Policy (script-src 'self'; worker-src 'self'), and permit intended remote model hosts in connect-src. SharedArrayBuffer, cross-origin isolation, eval and worker blob bootstrapping are not required.

Additional views and section cuts

The existing flat, smooth and wireframe display values remain supported. New display values are edges (solid with 30-degree feature edges), mesh (triangle overlay), xray, studio and orientation (blue front faces, red back faces). None requires Three.js. view_edges / set_edges retain the legacy one-degree edge threshold.

viewer.set_view(0, {display: 'studio', smooth_shading: true});
await viewer.when_idle();
viewer.set_smooth(0, false); // Independent of the selected modern view
viewer.set_section(0, {axis: 'z', position: 0.5, flip: false});
viewer.set_section(0, null); // Restore the complete model

These fields are also accepted in model options, returned by get_model_info, and preserved by VSJ/VSB scene export. set_display('flat') and set_display('smooth') keep their legacy shading behavior. Smooth shading is ignored visually in wireframe and retained for the next surface view. Derived view changes commit after preparation succeeds; a rejected memory estimate or failed worker preserves the previous appearance. Wait for when_idle() and inspect load_error_callback as with model loading.

Mesh overlay uses shader barycentric coordinates without an extra geometry buffer. Studio, orientation, X-ray and clipping also reuse existing geometry. Smooth normals, wireframe and feature edges are computed on demand in a worker and checked against the memory budget, including cached derived arrays.

Section position is a fraction from 0 to 1 of the source model's bounding box along its chosen axis. The plane follows model transforms, clips picking and PNG rendering, and does not modify exported STL geometry or model statistics. The cut remains open, with an amber boundary highlight; no cap is generated. X-ray uses lightweight back-face/front-face alpha passes; it is an illustrative view, not exact order-independent transparency for intersecting surfaces. Face orientation describes winding relative to the camera, not an automatic watertightness or printability diagnosis.

Optional streaming URL imports

The plugin does not silently route existing filename requests through a server. To retrieve a public URL without depending on the source server's CORS policy, configure a same-origin relay and explicitly use remote_url:

const viewer = new StlViewer(container, {url_import_endpoint: '/api/import-url'});
viewer.add_model({id: 0, remote_url: 'https://example.com/model.stl'});
// Optional remote_format: 'stl', 'obj', '3mf', 'step', 'stp', 'iges', 'igs'.

mountViewstlApp accepts the same endpoint and exposes app.openURL(url, format). Its optional url-dialog/url-form controls are shown in the preview site's HTML. The site supplies markup, styles and configuration; URL interaction, loading, progress, cancellation and parsing live in the plugin. App jobs allow ten minutes by default; low-level viewer worker_timeout remains configurable as before.

The relay receives the URL in a JSON POST body and streams the response without file storage. A signed URL's query string is not displayed as a model name or included in scene exports. Local files still never leave the browser. URL imports are a separate privacy path: the relay sees the remote bytes in transit.

Binary STL is decoded incrementally in the worker. Other formats use bounded browser buffers, including archives and CAD. A complete model appears after download and validation; progressive partial rendering is not implemented. get_model_info().file_bytes reports the imported byte count when available. Downloaded names are metadata, not reloadable URLs; use VSB to package a scene containing these models. Embedders must provide their own compatible relay; no ViewSTL server is implicitly used by the library.

When url_import_endpoint is configured, mountViewstlApp also accepts dropped links (text/uri-list), Firefox URL/title data, and plain HTTP/HTTPS URL text. URL drops use openURL and its existing queue, relay, progress, cancellation and error handling. Multiple URI-list links are deduplicated per drop. Local files take priority over accompanying URL metadata. Plain text is not searched for embedded links, HTML drag contents are not parsed, and drops into text inputs or editable content retain their normal behavior. This belongs to the optional app controller; the low-level viewer's existing file-drop contract is unchanged.

The reference relay lives in the website repository's relay/ directory. Its request is {url, format, max_bytes} and its response supplies X-ViewSTL-Format, percent-encoded X-ViewSTL-Filename, and optional Content-Length. All relay and parser failures use the existing error callback. Cancellation terminates the worker/fetch; the server then closes the upstream connection.

Local mesh export

The optional mountViewstlApp controller can bind a Save dialog using the save-* control group in the preview site's site/index.html. Include the entire group or omit it; incomplete groups fail before mounting. All event handling, selection, progress, cancellation and download dispatch compile into viewstl.js. The host supplies markup, styles and explanatory copy (format option data-note attributes and the CAD limitation paragraph). No Save UI is injected by the low-level viewer.

The reference dialog exports only the current app selection for mesh formats, with positions/rotations applied and millimetre output. Unitless STL/OBJ inputs are assumed to be millimetres. Its optional color setting applies to OBJ/3MF; colored OBJ uses ZIP. It calls download_models with explicit model IDs, so closing an editing panel does not clear the export selection. VSB/VSJ always export the whole scene. save-scope is a text status element displaying the selected filename or whole-scene model count, not a scope dropdown. Cancel, Escape, the close button, backdrop dismissal, changing selection, starting an import, removing/clearing models and app disposal cancel an active export. Errors stay in the dialog and permit another format. Existing PNG controls remain separate.

All exporters live in the plugin and run in disposable workers. They generate local Blobs; no relay, upload, server conversion or third-party network endpoint is used. Export modules and ZIP compression load only on demand. STEP/IGES writing uses JavaScript and does not download the CAD importer or WASM engine.

await viewer.when_idle();
const controller = new AbortController();
const result = await viewer.export_models({
  format: '3mf',                    // stl, obj, 3mf, step/stp, iges/igs
  model_ids: [0, 2],               // omitted = all; 'selected' = selected model
  filename: 'assembly',            // correct extension added/replaced automatically
  units: 'mm',                    // mm (default), inch
  apply_transforms: true,          // default: bake position, rotation, scale and centering
  colors: true,                   // default for OBJ/3MF; ignored for STL/STEP/IGES
  signal: controller.signal,
  on_progress({ loaded, total, phase }) {
    // Triangle counts. phase is 'exporting' or 'complete'.
    // Encoding can reach total before ZIP packaging completes.
  },
});
// {blob, filename, format, units, model_ids, triangles, representation: 'mesh'}
// Keep result.blob, or use the convenience method to initiate a browser download:
await viewer.download_models({format: 'step', model_ids: 'selected'});
// controller.abort() rejects an active/queued operation with AbortError.

Exports capture the model state at dispatch; subsequent edits/removal do not change an in-flight export. Buffers used for rendering are never transferred or detached. No selection, an unknown ID, duplicate IDs or an unfinished model rejects the promise. Call when_idle() first to include pending imports. Errors reject the promise and progress goes only to the supplied callback; exporters do not show dialogs or invoke the viewer's import-error callback. A throwing progress callback rejects its export and terminates the worker. clean()/dispose() cancel active and queued jobs. The caller owns the returned Blob and its lifetime.

apply_transforms: false exports source coordinates and their accumulated unit conversion, without centering, presentation movement/rotation or user scaling. Scene coordinates are treated as millimeters; units: 'inch' divides output coordinates by 25.4 and writes inch metadata where the format supports it. Unitless inputs must be interpreted/scaled appropriately by the embedding app. STL and OBJ have no standardized units; OBJ includes an informational comment.

Format Output and scope
STL Standard binary STL; selected meshes combined into one triangle stream. Attributes are zero, so no ambiguous vendor color encoding.
OBJ With colors: true, a ZIP containing model.obj and materials.mtl, including object groups, face normals, per-face colors and opacity. Vertex colors are averaged per face and colors/opacity quantized to 8-bit channels. With colors: false, a plain .obj file.
3MF ZIP/XML package with separate mesh objects, indexed vertices, units and per-vertex color/opacity using the Materials extension. Requires non-degenerate triangles, paired consistently oriented edges, and positive volume. Open/inconsistently wound meshes reject; there is no automatic repair or full self-intersection validation.
STEP AP214 AUTOMOTIVE_DESIGN: separate named products made of planar triangle faces in open shells. Exact displayed mesh coordinates are written; this is a surface model, not a reconstructed CAD solid.
IGES IGES 5.3 bounded planes (108/form 1) with closed 3D polylines (106/form 12). Models use separate levels. Triangle surfaces, not stitched CAD solids.

STEP/IGES preserve mesh geometry and placement, but omit colors, textures, analytic curves, original CAD topology/assemblies and feature history. They work with open triangle meshes, but reject zero-area triangles. No F3D export is provided. A consuming site can describe these limitations in its own interface; the low-level export API is UI-free. CAD interoperability has been verified against Open CASCADE, not every commercial CAD application.

Section cuts, camera orientation, grid, selection frames and viewing/shading modes are presentation settings and do not modify exported geometry. Use VSJ/VSB for ViewSTL scene settings. Texture/UV data discarded at import cannot be recreated.

The scene memory budget includes pending exports. Output is encoded in chunks instead of building one large text string; Blobs still require browser storage and this API does not stream directly to a filesystem handle. Output obeys max_file_bytes, archives also obey max_uncompressed_bytes and max_archive_entries, and the complete selection obeys max_triangles. Conservative format-specific memory estimates can reject an export before copying geometry. Cancellation terminates its worker, including compression.

The existing synchronous get_stl_bin/download_model and VSJ/VSB methods retain their legacy behavior. Use the new API for scene transforms, correct filename extensions, asynchronous execution and cancellation.

Development and packaging

pnpm install --frozen-lockfile
pnpm build
pnpm test
pnpm check:types
pnpm check:node-types
pnpm check:legacy
pnpm start
pnpm pack

Open /examples/v2/ for the demo and /tests/v2/browser.html for real worker/ WebGL checks. pnpm pack builds a local tarball; it does not publish anything. The package allowlist includes v2 artifacts, types, documentation and license notices. It excludes legacy snapshots, old dependencies, tests and server data.

Before a stable release, validate real mobile hardware, Safari and Firefox, larger user fixtures, npm/bundler integrations and the integrations used by the production site. Automated and desktop browser results are recorded in verification.

Multiple models, colors and an infinite grid

Use center_models: false to preserve the coordinates in assembly files, then call add_model / add_models to append files. set_position changes presentation coordinates without rewriting source geometry. The preview site's Models panel adds selection, individual removal and reversible side-by-side placement.

const viewer = new StlViewer(element, { center_models: false });
viewer.add_models([{id: 0, local_file: partA}, {id: 1, local_file: partB}]);
await viewer.when_idle();
viewer.set_color(0, '#dd766b');
viewer.set_color(0, null); // Restore embedded file colors (or neutral gray if absent).
viewer.set_bg_color('#202d35');
viewer.set_infinite_grid(true, 1, 0); // enabled, base spacing, Z height

The infinite XY grid is a procedural shader. Its spacing adapts as the camera zooms and it fades near the horizon. Panning does not allocate a larger grid; opaque models occlude it. set_grid retains its existing finite-grid behavior. Infinite settings round trip through VSJ/VSB as infinite_grid: {spacing,height}. Colors change shader uniforms without rebuilding the mesh buffers.

An explicit max_memory_bytes budget now covers the whole scene, including resident geometry, cached view arrays and pending derived geometry. Parsing is serialized under that budget so each new model receives the remaining allowance. Removing a model frees its allowance and cancels its pending derived work. These remain conservative estimates, not measurements of free RAM or GPU memory.

Selecting a model for editing

set_selection(id) draws a light 3D bounding frame using that model's transform. set_selection(null) clears it. The frame uses one 288-byte vertex buffer, follows rotation/translation/scale without rebuilding mesh buffers, and is excluded from PNG and scene exports. Removing or clearing its model releases the frame.

pick_model(clientX, clientY) returns {id, distance} for the nearest visible surface, or null for empty space. Coordinates use the same viewport coordinate system as mouse events. It respects transforms, hidden models and section cuts. The preview calls it on double-click only; ordinary dragging does not run a mesh intersection scan. The existing legacy click callbacks retain their behavior.

The preview provides Move, Rotate and Depth controls per model. Rotation uses degrees in the UI and compensates for the original mesh center; the plugin's set_rotation API continues to accept radians with its existing origin behavior. Depth translates along the eye-to-model ray while holding the camera fixed.

Saving ViewSTL scenes

Use export_scene for a Blob or download_scene for a browser download:

const result = await viewer.export_scene({
  format: 'vsb', // or 'vsj'
  filename: 'my-scene',
  signal: abortController.signal,
  on_progress({ loaded, total, phase }) {
    // Model counts; exporting, packaging (VSB only), complete.
  },
});
await viewer.download_scene({ format: 'vsj', filename: 'scene', force_basename: true });

Both methods always capture the entire loaded scene. They return {blob, filename, format, model_ids, triangles, representation: 'scene'}. Pending loads, empty scenes, excess model/triangle counts, memory and output limits reject. Errors reject the Promise instead of using the import-error callback. Cancellation and throwing progress callbacks release the worker and memory reservation.

VSB uses the existing ZIP layout: json_data.vsj plus one colored binary STL per model. Mesh encoding and compression run in a disposable worker using copied snapshot buffers; renderer buffers remain intact. Camera, background, grid, transforms, model colors/opacity, viewing modes and sections stay in the manifest. Original model names are kept as orig_filename. As with the legacy VSB format, STL geometry uses float32 positions and 5-bit per-face colors, taking the first vertex color of each triangle. Texture/UV data and original CAD topology/history are not reconstructed or included.

VSJ is JSON settings and model references, without embedded geometry. The force_basename option defaults to false for API callers; the preview uses true to write only filenames. Referenced files must be supplied separately. A local-file reference alone does not grant browser access to a file on disk. VSB is the self-contained choice. The preview opens both formats through the file picker, drag and drop, and URL opening. Existing get_vsj, get_vsb, get_vsb_blob and their legacy download methods retain their signatures and behavior.

In the app Save dialog, VSB/VSJ save the entire scene. STL/OBJ/3MF/STEP/IGES save only the selected model. There is no user-selectable scope dropdown. The low-level export_models API still permits explicit multiple-model selections for embedders.

Opening ViewSTL scenes

Use the Promise-based import_scene API for local files or public URLs:

const result = await viewer.import_scene(sceneFile, {
  files: companionFiles, // File[]: model files referenced by a local VSJ.
  signal: abortController.signal,
  on_progress({ loaded, total, phase }) {
    // reading-scene: reader progress; opening-model/complete: model counts.
  },
});
console.log(result.model_ids, result.scene, result.used_files);

// Requires the viewer's same-origin url_import_endpoint configuration.
await viewer.import_scene('https://example.com/scene.vsb');
await viewer.import_scene('https://example.com/download?id=123', { format: 'vsj' });

VSB archives contain json_data.vsj and their model files. VSJ manifests reference separate files: supplied local files match by exact relative path or unambiguous basename. Missing or ambiguous files reject with an actionable error. Absolute HTTP/HTTPS references use the configured URL relay. For a remote VSJ, relative references resolve against its final directory after redirects, also through the relay. Local files and manifests are not uploaded. The relay streams remote files without saving them. It still applies its request, size and network limits, so a scene with many remote references can hit the relay's rate limit.

Imports append models with new IDs and restore camera, background/grid, transforms, colors/opacity, view modes, smoothing and sections. Saved centering is converted to equivalent model positions without changing existing models' centering. Embed canvas size and resize/mouse controls are not imported. The development animation update also restores per-model animation and preserves its original pivot. Scene reading and mesh parsing use workers and the existing memory/triangle/archive limits. Models become visible together after all have loaded. Errors, cancellation, or a throwing progress callback remove the incoming models and retain the existing scene. clean() and dispose() also cancel pending scene imports. Existing load_vsj and load_vsb retain their previous signatures and behavior.

In the preview, choose or drop a local VSJ together with its referenced model files; companions used by the scene are not added twice. A VSB needs no separate files. Choose VSB/VSJ explicitly in Open URL for a URL without a scene extension. Scene opening, progress, cancellation and restored editing controls all live in the plugin's app controller; the website initializer remains minimal.

Checking meshes for 3D printing

The optional checks run locally on demand. They analyze the loaded triangle mesh from any supported format, including models restored from VSB/VSJ. CAD accuracy is limited by the imported tessellation. Analysis does not repair or modify models.

const controller = new AbortController();
const report = await viewer.check_printability({
  model_ids: [selectedId], // Omit for all loaded models; 'selected' uses the editing selection.
  level: 'detailed',      // 'basic' is the default.
  apply_transforms: true, // Default: current placement/rotation/scale, like mesh export.
  units_confirmed: true, // Confirm the current mm scale for unitless input.
  profile: {             // Optional; omit for geometry checks only.
    technology: 'fdm',
    build_volume_mm: [220, 220, 250],
    min_wall_mm: 0.8,     // User-chosen thresholds, not universal printing rules.
    overhang_degrees: 45 // Measured from vertical; +Z is print up.
  },
  max_time_ms: 30000,    // Per model, maximum 120000.
  signal: controller.signal,
  on_progress: ({ phase, model_index, total_models }) => console.log(phase)
});
if (viewer.is_printability_current(report)) {
  viewer.show_printability(report, { model_id: selectedId, issue: 'boundary_edges' });
}
viewer.clear_printability();

Basic checks cover degenerate and nearly degenerate triangles, duplicates, open boundaries, non-manifold edges and vertices, inconsistent winding, connected components and physical-unit provenance. Detailed checks add a native-JavaScript BVH for self-intersections (including coplanar overlaps), shell containment, orientation and enclosed volume. Separate components can be intentional. Hollow models require opposite cavity-shell orientation. Vertex connectivity uses exact source coordinates; small gaps are not silently welded closed. Intersection checks use the reported scale-dependent numerical tolerance; near-contact cases can be inconclusive. The existing 3MF export guards remain independent and unchanged.

Optional FDM checks compare dimensions with a build-volume box, identify potential overhang regions, estimate bed contact and sample wall thickness. line_width_mm can supply the wall threshold when min_wall_mm is omitted. No printer defaults are assumed. Build fit tests current orientation dimensions, not actual placement, exclusion zones or alternative rotations. Bed contact virtually lowers each model to its minimum Z; the visible grid and camera do not define the print bed. Thickness tests at most 2,048 face centres using inward normal rays and can miss small features or oblique thin regions. No finding from those samples is explicitly inconclusive, never proof of a minimum thickness. Overhangs do not simulate bridging/supports; contact area does not predict adhesion/stability. Resin, toolpaths, cross-model collisions and Boolean unions are outside these checks.

Reports include each check's pass, issue, inconclusive or not_run status, severity, counts, explanations, bounded highlight samples, dimensions, tolerance, timing and optional estimates. volume_valid means a geometrically validated enclosed volume; its physical size still depends on the units check. Overall status is issues_found, incomplete or no_issues_found. A basic report remains incomplete because detailed checks were not run. Errors and time/memory limits never imply a pass. This API does not certify a successful print.

Workers and analysis modules load only when requested. Models are checked in sequence, with cooperative snapshot copying and aggregate memory admission. The working estimate is 64 MiB plus 2,400 bytes per triangle, with bounded report allowances; a model that can be viewed may be too large to analyze on the same device. One bounded report cache is retained, not the topology/BVH. Geometry, transforms, scale and unit changes invalidate reports; colors and camera changes do not. Removing a checked model, clean/dispose, AbortSignal cancellation or a throwing progress callback releases the active worker and reservations. Report metadata uses weak references so retaining a report cannot retain removed meshes.

Highlights use temporary native WebGL line buffers and are excluded from PNG, mesh and scene exports. They show up to 128 faces or 256 edges per check, through occluding surfaces. Closing the site panel clears highlights. The preview exposes the feature under Info → Check for 3D printing, with selected/all model scope, basic/detailed depth and collapsed optional FDM settings. Unit confirmation resets when the checked scope or an existing result's geometry/transform changes. A complete optional print-* control group is bound by mountViewstlApp; hosts omitting the entire group keep their previous behavior. The website only supplies markup/styles.

The check panel stays open while using the canvas, other controls or dialogs, loading files, selecting models and clearing the scene. Only its close button or Escape with focus inside the panel dismisses it. Reopening it from Info reveals and focuses the existing panel. Loading or changing the checked selection cancels an active check; stale results are cleared. Loading and empty scenes disable Run and show the current availability without closing the panel.

Model size, units, duplicates, cameras and measurements (rc.6)

All operations below are implemented inside the plugin. The optional site controls remain a thin HTML/CSS host.

viewer.resize_model(id, [2, 2, 2]) doubles the model about its world-space bounds center, preserving rotation and placement. Positive finite XYZ factors are required; independent factors allow nonuniform scaling. It stops only that model's animation. get_model_size(id) returns dimensions and the reset baseline in millimetres. reset_model_size(id) restores that baseline while retaining file-unit corrections.

viewer.set_file_units(id, 'inch') reinterprets a unitless STL/OBJ mesh (default mm) without moving its center. Switching back reverses the conversion. Explicit 3MF/STEP/IGES units are normalized to mm during parsing and cannot be reinterpreted. set_display_units('inch') changes the optional app's size/Info readouts only; scene coordinates, position controls and the grid remain in mm. Existing set_model_units and set_scale retain their legacy semantics.

const copy = viewer.duplicate_model(id) returns a new ID at the same pose, with independent appearance, transforms and metadata. Immutable geometry and matching GPU buffers are shared; memory admission remains conservative. The source reference remains unchanged for VSJ, while display_name identifies the copy. Copied animation definitions and phase are retained but paused; the original keeps playing.

viewer.set_camera_view('top') fits the scene from front/back/left/right/top/bottom/isometric using Z up and the existing perspective projection. It never rotates the models. Camera bookmarks are explicit data:

viewer.set_camera_bookmarks([{name: 'Detail', camera: viewer.get_camera_state()}])

Use get_camera_bookmarks() to obtain a defensive copy and set_camera_state(bookmark.camera) to restore one. Up to 32 uniquely named views (80 characters per name) are supported. They are kept in memory and in scene exports, not localStorage.

const {area_mm2, volume_mm3, volume_valid, volume_reason} = await viewer.measure_model(id)

Measurements use workers and support signal, max_time_ms (default 30 seconds) and on_progress. Area includes all triangles at current scale. Volume is nullable: detailed topology, intersections and shell-orientation checks must establish a closed volume. An open/invalid mesh or an inconclusive/time-limited check never becomes a plausible-looking volume. Results are cached by mesh and scale; position/rotation do not invalidate them and uniform resizing updates cached measurements algebraically. Nonuniform area is recalculated in the worker. Memory limits can reject the calculation before buffers are copied. This is a bounded mesh analysis, not a guarantee of printability.

VSB/VSJ retain size baselines, file-unit interpretation, display units, duplicate labels and bookmarks. VSB still embeds geometry; VSJ still needs the referenced source files. No second unit conversion occurs during import. Appending scenes merges bookmark names with numbered suffixes; a combined total above 32 rejects the incoming scene. Old files without the new optional fields continue to load. The size_state.unit_factor serialization field preserves source-unit correction separately from resize factors for exports with apply_transforms:false.

The optional app control group is listed in src/app-tools.js (toolsControls). Provide the whole group or omit it when embedding older markup. The preview's site/index.html is a complete example. Models contains collapsed Size & units and Duplicate; View contains collapsed Camera & saved views; Info contains area and the volume calculation. No server is involved for local files or measurements.

Putting it all together

Try the comprehensive live example: multiple models, local files, selection, color, opacity, shading, scale, position, rotation, animation, camera views and model information.

Working Example
View code

Copy the complete build/ folder from the plugin download beside this HTML file, add the models below, and serve the folder over HTTP(S). This is the same example program with local file paths.

These are the original legacy ViewSTL example models. Save the required files in a models/ folder beside your HTML: viewstl_plugin.stl · bat.stl · knot.stl · bar.stl · apple.stl · hat.stl · esb.stl.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ViewSTL — Comprehensive live example</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 20px; }
    #example-viewer { width: 100%; height: 500px; }
    button { padding: 10px 16px; }
    .actions { display:flex; gap:12px; flex-wrap:wrap; margin:20px 0; }
    
.playground-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 22px}
#playground-controls{min-width:0;margin:24px 0;padding:20px;border:1px solid #cbd7da;border-radius:8px}
#playground-controls legend{padding:0 8px;font-weight:600}
.playground-fields label{display:flex;flex-direction:column;gap:5px;font-size:13px;min-width:0}
.playground-fields input,.playground-fields select{width:100%;min-width:0;min-height:42px;padding:7px;border:1px solid #cbd7da;border-radius:5px;background:white;color:inherit}
.playground-fields input[type=color]{padding:3px;height:42px}
.playground-fields input[type=range]{padding:0}
#playground-controls button,.playground-scene~.actions button,#demo-refresh{min-height:42px;padding:8px 13px;border:1px solid #cbd7da;border-radius:6px;background:white;color:#304f5d}
#demo-information{margin:24px 0}#demo-info{max-height:340px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}
@media(max-width:540px){.playground-fields{grid-template-columns:1fr}}

  </style>
</head>
<body>
  <div id="example-viewer"></div>
  <p id="example-status" role="status">Opening example…</p>
  <button id="example-retry" type="button" hidden>Retry example</button>
<fieldset id="playground-controls" disabled>
  <legend>Try the plugin</legend>
  <div class="playground-fields">
    <label>Selected model<select id="demo-model"></select></label>
    <label>Color<input id="demo-color" type="color" value="#64a5b2"></label>
    <label>Opacity<input id="demo-opacity" type="range" min="0.05" max="1" step="0.05" value="1"></label>
    <label>Shading<select id="demo-display"><option value="flat">Flat</option><option value="smooth">Smooth</option><option value="wireframe">Wireframe</option><option value="edges">Solid + edges</option></select></label>
    <label>Scale<input id="demo-scale" type="number" min="0.05" max="10" step="0.05" value="1"></label>
    <label>Axis<select id="demo-axis"><option value="x">X</option><option value="y">Y</option><option value="z">Z</option></select></label>
    <label>Position (mm)<input id="demo-position" type="number" step="5" value="0"></label>
    <label>Rotation (degrees)<input id="demo-rotation" type="number" step="15" value="0"></label>
  </div>
  <div class="actions">
    <button id="demo-animate" type="button" aria-pressed="false">Start animation</button>
    <button id="demo-reset" type="button">Reset model</button>
    <button id="demo-remove" type="button">Remove model</button>
  </div>
</fieldset>
<div class="playground-fields playground-scene">
  <label>Sample model<select id="demo-sample"><option value="viewstl_plugin.stl">ViewSTL</option><option value="bat.stl">Bat</option><option value="knot.stl">Knot</option><option value="bar.stl">Bar</option><option value="apple.stl">Apple</option><option value="hat.stl">Hat</option><option value="esb.stl">Empire State Building</option></select></label>
  <label>Camera<select id="demo-camera"><option value="isometric">Isometric</option><option value="front">Front</option><option value="back">Back</option><option value="left">Left</option><option value="right">Right</option><option value="top">Top</option><option value="bottom">Bottom</option></select></label>
  <label>Local files<input id="demo-files" type="file" multiple accept=".stl,.obj,.3mf,.step,.stp,.iges,.igs"></label>
</div>
<div class="actions">
  <button id="demo-add" type="button">Add sample</button>
  <button id="demo-fit" type="button">Fit all</button>
  <label><input id="demo-grid" type="checkbox"> Show grid</label>
</div>
<p class="small">Click a model or choose it above. Local files stay on your device; you can also drop them into the viewer. Animation starts only when you choose it. Editing a transform stops that model’s animation.</p>
<details id="demo-information"><summary>Selected model information</summary><button id="demo-refresh" type="button">Refresh information</button><pre id="demo-info">Choose a model.</pre><p class="small">Snapshot from get_model_info(), with measurements disabled to keep interaction quick.</p></details>
  <script type="module">
import StlViewer from './build/viewstl.js';

const element = id => document.getElementById(id);
const status = element('example-status');
const retry = element('example-retry');
const selector = element('demo-model');
const controls = element('playground-controls');
const samples = [
  {
    "file": "viewstl_plugin.stl",
    "title": "ViewSTL",
    "scale": 1.5,
    "color": "#64a5b2"
  },
  {
    "file": "bat.stl",
    "title": "Bat",
    "scale": 2,
    "color": "#e79a65"
  },
  {
    "file": "knot.stl",
    "title": "Knot",
    "scale": 0.55,
    "color": "#a68cbd"
  },
  {
    "file": "bar.stl",
    "title": "Bar",
    "scale": 0.7,
    "color": "#db7667"
  },
  {
    "file": "apple.stl",
    "title": "Apple",
    "scale": 2,
    "color": "#83a95e"
  },
  {
    "file": "hat.stl",
    "title": "Hat",
    "scale": 0.5,
    "color": "#e5b54c"
  },
  {
    "file": "esb.stl",
    "title": "Empire State Building",
    "scale": 1.2,
    "color": "#a0aeb7"
  }
];
const modelURL = file => new URL('./models/' + file, import.meta.url).href;
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const ids = new Set();
let selected = null;
let loadError = null;

function showError(message) {
  loadError = message;
  status.textContent = message;
  retry.hidden = false;
}
retry.addEventListener('click', () => location.reload());
const viewer = new StlViewer(element('example-viewer'), {
  auto_resize: true,
  allow_drag_and_drop: true,
  models: [
    { id: 0, filename: modelURL('viewstl_plugin.stl'), x: -65, scale: 1.5, color: '#64a5b2' },
    { id: 1, filename: modelURL('bat.stl'), x: 65, scale: 2, color: '#e79a65', display: 'smooth' }
  ],
  model_loaded_callback(id) {
    ids.add(id);
    const info = viewer.get_model_info(id, { measurements: false });
    selector.add(new Option(id + ': ' + info.name.split(/[\\/]/).pop(), String(id)));
    selectModel(id);
    status.textContent = 'Ready. ' + ids.size + (ids.size === 1 ? ' model.' : ' models.') + ' Click a model to edit it.';
    retry.hidden = true;
  },
  on_model_mouseclick(id) { if (ids.has(id)) selectModel(id); },
  load_error_callback: showError
});

function updateControls() {
  controls.disabled = selected === null;
  if (selected === null) {
    element('demo-info').textContent = 'Choose a model.';
    return;
  }
  const info = viewer.get_model_info(selected, { measurements: false });
  const axis = element('demo-axis').value;
  element('demo-color').value = info.color;
  element('demo-opacity').value = info.opacity;
  element('demo-display').value = info.display;
  element('demo-scale').value = info.scale.x;
  element('demo-position').value = info.position[axis];
  element('demo-rotation').value = Math.round(info.rotation[axis] * 180 / Math.PI * 100) / 100;
  element('demo-info').textContent = JSON.stringify(info, null, 2);
  const state = viewer.get_model_animation_state(selected);
  const playing = state?.status === 'running';
  element('demo-animate').textContent = playing ? 'Pause animation' : state?.status === 'paused' ? 'Resume animation' : 'Start animation';
  element('demo-animate').setAttribute('aria-pressed', String(playing));
}
function selectModel(id) {
  selected = id;
  selector.value = id === null ? '' : String(id);
  viewer.set_selection(id);
  updateControls();
}
selector.addEventListener('change', () => selectModel(Number(selector.value)));
element('demo-axis').addEventListener('change', updateControls);
element('demo-refresh').addEventListener('click', updateControls);

// Wire each control to a public method; the plugin handles geometry and rendering.
function bind(id, event, apply) {
  element(id).addEventListener(event, async () => {
    if (selected === null || !element(id).checkValidity()) return;
    apply(element(id).value);
    await viewer.when_idle();
    updateControls();
  });
}
bind('demo-color', 'input', value => viewer.set_color(selected, value));
bind('demo-opacity', 'input', value => viewer.set_opacity(selected, Number(value)));
bind('demo-display', 'change', value => viewer.set_display(selected, value));
bind('demo-scale', 'change', value => viewer.set_scale(selected, Number(value)));
bind('demo-position', 'change', value => {
  const position = viewer.get_model_info(selected, { measurements: false }).position;
  position[element('demo-axis').value] = Number(value);
  viewer.set_position(selected, position.x, position.y, position.z);
});
bind('demo-rotation', 'change', value => {
  const rotation = viewer.get_model_info(selected, { measurements: false }).rotation;
  rotation[element('demo-axis').value] = Number(value) * Math.PI / 180;
  viewer.set_rotation(selected, rotation.x, rotation.y, rotation.z);
});
element('demo-animate').addEventListener('click', () => {
  if (selected === null) return;
  const state = viewer.get_model_animation_state(selected);
  if (state?.status === 'running') viewer.pause_model_animation(selected);
  else if (state?.status === 'paused') viewer.resume_model_animation(selected);
  else viewer.animate_model(selected, { delta: { rotationx: 0.4, rotationy: 0.7, rotationz: 1, msec: 3000, loop: true } });
  updateControls();
});
reducedMotion.addEventListener('change', event => {
  if (event.matches) {
    for (const id of ids) viewer.pause_model_animation(id);
    updateControls();
  }
});
element('demo-reset').addEventListener('click', () => {
  if (selected === null) return;
  viewer.animate_model(selected, null);
  viewer.set_position(selected, 0, 0, 0);
  viewer.set_rotation(selected, 0, 0, 0);
  viewer.set_scale(selected, 1);
  viewer.set_color(selected, '#64a5b2');
  viewer.set_opacity(selected, 1);
  viewer.set_display(selected, 'flat');
  viewer.set_auto_zoom();
  updateControls();
});
element('demo-remove').addEventListener('click', () => {
  if (selected === null) return;
  viewer.remove_model(selected);
  ids.delete(selected);
  selector.selectedOptions[0]?.remove();
  selectModel(ids.values().next().value ?? null);
  status.textContent = 'Ready. ' + ids.size + (ids.size === 1 ? ' model.' : ' models.') + ' Add a sample or open a local file.';
});
element('demo-camera').addEventListener('change', event => viewer.set_camera_view(event.target.value));
element('demo-fit').addEventListener('click', () => viewer.set_auto_zoom());
element('demo-grid').addEventListener('change', event => viewer.set_grid(event.target.checked));
element('demo-add').addEventListener('click', () => {
  const sample = samples.find(item => item.file === element('demo-sample').value);
  viewer.add_model({ filename: modelURL(sample.file), x: ids.size * 70, scale: sample.scale, color: sample.color });
});
element('demo-files').addEventListener('change', event => {
  for (const file of event.target.files) viewer.add_model({ local_file: file });
  event.target.value = '';
});

const container = element('example-viewer');
let lastWidth = container.clientWidth;
const resize = new ResizeObserver(() => {
  if (container.clientWidth === lastWidth) return;
  lastWidth = container.clientWidth;
  viewer.do_resize();
  viewer.set_auto_zoom();
});
resize.observe(container);

try {
  await viewer.when_idle();
  if (loadError) throw new Error(loadError);
  viewer.set_camera_view('isometric');
  selectModel(0);
} catch (error) {
  showError(error.message);
  controls.disabled = true;
}
addEventListener('pagehide', event => { if (!event.persisted) { resize.disconnect(); viewer.dispose(); } });
  </script>
</body>
</html>