Universal 3D Viewer v0.2.0
Live Demo ↗

Developer Documentation

A framework-agnostic, embeddable JavaScript 3D viewer library that matches Google <model-viewer> in drop-in simplicity and online-3d-viewer (3dviewer.net) in multi-format engineering power.

1. Introduction & Architectural Pillars

The Universal 3D Viewer library is engineered from the ground up for modern web platforms, e-commerce stores (Shopify), content management systems (WordPress), and headless applications.

🛡️

Shadow DOM Encapsulation

100% CSS isolation. Host themes in WordPress and Shopify never distort canvas aspect ratios or controls.

⚡

Core Web Vitals Ready

Native loading="lazy" and instant poster preview thumbnails prevent WebGL from slowing down page LCP.

📐

Universal 3D Ingestion

Supports glTF, OBJ, STL, PLY, FBX, 3MF, CAD (STEP/IGES), BIM (IFC), Rhino 3DM and 24 legacy formats through Assimp WebAssembly.

🎨

Headless & Custom UI

Completely disable the built-in UI and control every aspect via external buttons or custom slotted markup.

2. Installation & Setup

The library ships as the universal-3d-viewer npm package. Version 0.2.0 is not published yet; the pinned jsDelivr URLs below go live as soon as it is. Until then, build the same files from the repository (npm run build:lib) and self-host them.

Method A: Script Tag (Zero Build)

The UMD bundle, dist/library/universal-3d-viewer.umd.js in the npm package, has three inside and registers <universal-3d-viewer> as soon as it loads. Load it from jsDelivr, or copy it and the decoder folders (public/draco, public/basis, public/wasm) to your own site and point asset-base-path at them.

HTML
<script src="https://cdn.jsdelivr.net/npm/universal-3d-viewer@0.2.0/dist/library/universal-3d-viewer.umd.js"></script>
<universal-3d-viewer
  src="/model.glb"
  camera-controls
  asset-base-path="https://cdn.jsdelivr.net/npm/universal-3d-viewer@0.2.0/public/"
  style="width: 100%; height: 500px;"
></universal-3d-viewer>

Do not load universal-3d-viewer.es.js with <script type="module">: it imports the bare specifier three, which a browser cannot resolve without an import map.

The repository also commits two smaller standalone files, lib/universal-3d-viewer.min.js (all 14 loaders) and lib/universal-3d-viewer.web.min.js (web formats only), for hosts that vendor a file, such as a WordPress plugin. They carry the core API only: there is no <universal-3d-viewer> element in them, so you create the viewer from script:

HTML
<div id="viewport" style="width: 600px; height: 400px"></div>
<script src="/assets/universal-3d-viewer.min.js"></script>
<script src="/assets/my-viewer.js"></script>

// my-viewer.js
const viewer = new Universal3DViewer.Universal3DViewer({
  container: document.getElementById('viewport'),
  assetBasePath: '/assets/decoders/',
})
viewer.load('/model.glb').catch((err) => {
  if (!Universal3DViewer.isAbortError(err)) console.error(err)
})

Method B: npm Package (Vite, Next.js, Nuxt)

Terminal
npm install universal-3d-viewer three

Import into your JavaScript or TypeScript codebase:

JavaScript / TypeScript
// Automatically registers the <universal-3d-viewer> custom element
import 'universal-3d-viewer'

// Or import the headless programmatic class
import { Universal3DViewer } from 'universal-3d-viewer'

3. Quick Start

Embedding a 3D model takes just one custom HTML tag:

HTML
<universal-3d-viewer
  src="https://example.com/models/sneaker.glb"
  poster="https://example.com/models/sneaker-poster.webp"
  camera-controls
  auto-rotate
  ar
  style="width: 100%; height: 500px;"
></universal-3d-viewer>

4. Web Component Reference (<universal-3d-viewer>)

HTML Attributes & Properties

Attribute Type Default Description
src string "" URL or Blob to the 3D model (.glb, .gltf, .obj, .stl, .step, .ifc, etc.).
ios-src string "" Optional .usdz URL for Apple AR Quick Look on iPhones and iPads.
poster string "" Thumbnail preview displayed before 3D loads. Optimizes Google LCP score.
loading "auto" | "lazy" "auto" When set to "lazy", defers WebGL initialization until scrolled into view. Init-only: it decides when the engine starts, so changing it afterwards has no effect.
reveal "auto" | "manual" "auto" Anything other than "auto" keeps the poster up and the engine unstarted until the poster is activated (click, Enter or Space) or dismissPoster() is called. Init-only, like loading.
asset-base-path string page URL Base URL the draco/, basis/ and wasm/ decoder folders are served from. Set it whenever the page URL does not describe where they live (a CDN, a WordPress plugin folder, any permalink route). Init-only: read once when the engine is built and not observed afterwards, so set it before the element starts.
camera-controls boolean false Enables mouse orbit, pan, and mobile pinch-to-zoom navigation.
auto-rotate boolean false Enables smooth idle turntable rotation. Under prefers-reduced-motion: reduce the attribute does not start rotation; the toolbar toggle still does, as an explicit request.
auto-rotate-delay number 3000 Idle milliseconds after user drag before auto-rotation resumes.
rotation-speed number 2.0 Rotation speed multiplier.
disable-orbit boolean false Disables rotational orbit while keeping pan and zoom active.
disable-pan boolean false Disables right-click and two-finger translation panning.
disable-zoom boolean false Disables scroll wheel and pinch zoom.
edges boolean false Toggles CAD-style feature edge lines across model meshes.
edge-color string (hex) "#222222" Hex color of CAD edge lines.
edge-threshold number 30 Crease angle threshold (in degrees) for calculating sharp edges.
environment-preset string "studio" PBR lighting preset: "studio", "neutral", "sunset", "warehouse", "night".
environment-image string "" URL to custom equirectangular HDR/EXR/JPEG environment map.
skybox-image string "" URL to 360-degree background image.
skybox boolean false Toggles visibility of the 360 skybox background.
with-credentials boolean false Sends cookies with cross-origin model requests, for models behind a session (a customer portal, a private CDN). The server must send matching CORS headers.
generate-schema boolean false Publishes a schema.org/3DModel JSON-LD block describing src, so search engines index the page as carrying a 3D asset. Written into the light DOM, since crawlers do not read shadow roots.
no-default-ui boolean false Hides the built-in floating toolbar, AR badge, and progress overlay (Headless mode).
ar boolean false Enables the Augmented Reality badge button. The badge hides itself automatically if no mode in ar-modes is available on the device.
ar-scale "auto" | "fixed" "auto" When set to "fixed", locks model to 1:1 true scale and disables AR scaling gestures.
ar-placement "floor" | "wall" "floor" Initial AR placement plane: "floor" (horizontal) or "wall" (vertical surface).
ar-modes string "webxr scene-viewer quick-look" Space-separated AR mechanisms, tried in the order written. webxr presents in-page with hit-test placement, scene-viewer hands off to Google Scene Viewer on Android, and quick-look hands off to Apple AR Quick Look on iOS. Unknown tokens are ignored.
xr-environment boolean false Uses WebXR light estimation to light the model from the real room while presenting, when the device supports it.
ar-status "not-presenting" | "session-started" | "object-placed" | "failed" "not-presenting" Read-only, written by the element. Reflected so page CSS can react, e.g. universal-3d-viewer[ar-status="object-placed"] { … }.
camera-orbit string auto framing Camera position as "θ φ r", e.g. "45deg 55deg 2.5m". Angles take deg/rad; the radius takes m/cm/mm, a % of the auto-fit distance, or auto. A named view (front, back, left, right, top, bottom, isometric) also works.
camera-target string auto Orbit pivot as "x y z", e.g. "0m 1m 0m", or auto for the model's centre.
min-camera-orbit / max-camera-orbit string unbounded Orbit limits in the same "θ φ r" syntax; auto leaves that bound free. e.g. max-camera-orbit="auto 90deg auto" keeps the camera above the horizon.
min-field-of-view / max-field-of-view number 10 / 120 Clamps for field-of-view, in degrees.
camera-projection "perspective" | "orthographic" "perspective" Orthographic removes perspective foreshortening, which is what CAD reviewers usually want for dimensional comparison. Switching preserves the framing.
up-axis "y" | "z" "y" Set "z" for Z-up sources (most CAD formats), which otherwise load lying on their side.
touch-action string "none" CSS touch-action for the canvas. "pan-y" hands vertical swipes back to the page so a viewer embedded mid-article doesn't trap scrolling.
orbit-sensitivity number 1 Multiplier on drag-to-orbit speed.
interpolation-decay number 100 Time constant of camera transitions, in ms. Lower settles faster.
interaction-prompt "auto" | "none" "auto" Shows a drag affordance if the viewer sits untouched after loading. Dismissed for good on the first interaction.
interaction-prompt-style "basic" | "wiggle" "basic" "wiggle" also nudges the model to show it turns.
interaction-prompt-threshold number 3000 Idle time before prompting, in ms.
background-color string null Solid canvas background color (hex e.g. "#0b0f19" or "transparent").
background-image string "" 2D image URL applied to canvas background.
animation-name string "" Name of specific animation clip to play on load.
autoplay boolean false Auto-plays first animation clip on model load.
time-scale number 1.0 Playback speed multiplier for keyframe animations.
animation-crossfade-duration number 300 Milliseconds to blend when switching clips. 0 cuts straight over.
variant-name string null Selects a glTF KHR_materials_variants variant by name. Removing the attribute restores the default.
shadow-intensity number 0.5 Darkness of the contact shadow, 0–1. 0 turns it off; anything above turns it on. Composes with the active theme rather than replacing it.
shadow-softness number 0 Blur radius of the contact shadow.
tone-mapping "aces" | "neutral" | "commerce" | "agx" | "linear" | "reinhard" | "cineon" "aces" Tone-mapping curve. commerce is an alias of neutral, matching <model-viewer>.
min-polar-angle string | number "0deg" Minimum vertical camera angle. Supports "10deg", "0.5rad".
max-polar-angle string | number "180deg" Maximum vertical camera angle (e.g. "90deg" prevents camera from dipping below ground).
min-azimuth-angle string | number null Minimum horizontal turntable rotation sweep (e.g. "-45deg").
max-azimuth-angle string | number null Maximum horizontal turntable rotation sweep (e.g. "45deg").
gallery boolean false Generates multi-angle snapshots and displays them in a horizontal row strip.
show-gallery boolean false Controls visibility of the built-in horizontal gallery row strip.
hidden-nodes string "" Comma-separated list of mesh/node names to hide initially (e.g. "Roof, Spoiler").

Every other attribute applies live after the engine has started. Removing environment-image, skybox-image, animation-name, exposure, time-scale, rotation-speed, auto-rotate-delay, edge-color or edge-threshold reverts to the default.

Keyboard, Focus & Reduced Motion

  • The canvas is exposed as role="img", labelled by alt (falling back to "Interactive 3D model"), and is focusable. While it has focus the arrow keys orbit the camera by 10° per press, whenever camera-controls allows orbiting (not with disable-orbit). The page does not scroll while the model has focus.
  • The canvas, the poster, the toolbar buttons, the gallery thumbnails and the AR buttons show a focus ring on keyboard focus. Its colour is --focus-ring-color (default #60a5fa).
  • Under prefers-reduced-motion: reduce, the auto-rotate attribute does not start rotation, camera moves the element makes (reset, gallery views, camera-orbit/camera-target changes, hotspot focus, arrow keys) jump instead of tweening, the fit on load jumps, and the interaction prompt neither animates nor wiggles the camera. From script, reducedMotion in the config or viewer.setReducedMotion(true | false | null) overrides the OS setting (null follows it again).

Slots & Projection

Project custom markup directly into the component's Shadow Root:

Slot Name Purpose Example
slot="toolbar" Replaces the built-in glass toolbar with custom HTML controls. <div slot="toolbar">...</div>
slot="ar-button" Replaces the default AR pill button with a custom branded button. <button slot="ar-button">View in Room</button>
slot="poster" Custom loading poster preview overlay. <div slot="poster">...</div>
slot="progress-bar" Custom loading progress bar. <div slot="progress-bar">...</div>
slot="gallery" Replaces the built-in horizontal gallery row strip with custom gallery HTML. <div slot="gallery">...</div>
slot="hotspot-{id}" Declarative 3D annotation pin. Requires data-position="x y z". <button slot="hotspot-1" data-position="0 1 0">...</button>

CSS Shadow Parts (::part)

Style internal elements from external CSS without breaking encapsulation. The parts are canvas, hotspots, poster, progress-bar, ar-container, ar-button, ar-exit-button, interaction-prompt, gallery-strip, gallery-thumb (each thumbnail in the gallery strip), toolbar and toolbar-button.

CSS
universal-3d-viewer::part(toolbar) {
  bottom: 24px;
  background: rgba(0, 0, 0, 0.9);
}

universal-3d-viewer::part(ar-button) {
  background: #10b981;
  font-weight: 700;
}

universal-3d-viewer::part(progress-bar) {
  background: linear-gradient(90deg, #f59e0b, #ef4444);
}

universal-3d-viewer::part(gallery-thumb) {
  border-radius: 4px;
}

CSS Custom Properties

Set these on the element (or any ancestor) to retheme the built-in chrome without overriding parts wholesale.

PropertyDefaultDescription
--min-hotspot-opacity 0 Opacity of a hotspot that is occluded by geometry or behind the camera. Raise it to dim hidden pins instead of hiding them.
--max-hotspot-opacity 1 Opacity of a fully visible hotspot.
--poster-color #0f1115 Backdrop behind the poster image while the model loads.
--progress-bar-color blue gradient Fill of the loading bar. Accepts any background value.
--progress-bar-height 3px Thickness of the loading bar.
--progress-mask rgba(255,255,255,0.1) Track behind the loading bar.
--focus-ring-color #60a5fa Keyboard focus outline on the canvas, poster, toolbar buttons, gallery thumbnails and AR buttons.
CSS
universal-3d-viewer {
  /* Keep occluded pins faintly visible rather than hiding them outright */
  --min-hotspot-opacity: 0.25;
  --max-hotspot-opacity: 1;
  --progress-bar-color: linear-gradient(90deg, #10b981, #34d399);
  --progress-bar-height: 4px;
}

/* Each hotspot also reflects its own state */
universal-3d-viewer [slot^="hotspot-"]:not([data-visible]) {
  pointer-events: none;
}

Custom DOM Events

The Web Component dispatches standard bubbling DOM CustomEvents:

Event Name Event Detail (e.detail) Description
load { scene, stats: { polyCount, meshCount, boundingBox } } Fired when 3D model finishes parsing and rendering.
progress { percent } Fired during download and triangulation.
error { error, stage } Fired if file fetching or parsing fails.
surface-click { point, normal, meshName, originalEvent } Fired when user clicks the 3D surface (for dropping pins).
camera-change { orbit: { theta, phi, radius }, target, fov, source } Fired when the camera moves or rotates. source is "user-interaction" when the move came from a drag/zoom, otherwise "none".
camera-projection-change { projection } Fired when the viewer switches between perspective and orthographic.
hotspot-visibility { id, visible } Fired when a hotspot becomes occluded or visible again. Only on change, not every frame.
scene-graph-ready { materialCount } Fired once viewer.model is populated for a newly loaded model.
play / pause { name } Fired when a clip starts/resumes, or is paused/stopped.
finished { name } Fired when a non-looping clip reaches its end.
loop { name } Fired each time a looping clip wraps.
ar-status { status } Fired when an AR session starts, the user places the model, the session ends, or no mode could launch. Mirrors the ar-status attribute.
ar-tracking { status: "tracking" | "not-tracking" } Fired while presenting when the device loses or regains world tracking.

5. JavaScript Programmatic SDK Reference

For full programmatic control, instantiate the Universal3DViewer class directly or call methods on the <universal-3d-viewer> DOM element.

Camera & Navigation API

JavaScript
const viewer = document.querySelector('universal-3d-viewer');

// Reset camera to initial home position with smooth animation
viewer.resetCamera(true);

// Frame model bounding box with padding
viewer.fitToScreen(1.25, true);

// Toggle or configure auto-rotation
viewer.toggleAutoRotate();
viewer.viewer.setAutoRotate(true, { speed: 3.0, delay: 2000 });

// Granular control locks
viewer.viewer.setZoomEnabled(false);
viewer.viewer.setPanEnabled(false);
viewer.viewer.setOrbitEnabled(true);

// Set specific orbit angles (theta, phi, radius)
viewer.viewer.setCameraOrbit(Math.PI / 4, Math.PI / 3, 2.5, true);

Framing, Projection & Picking

JavaScript
// Read and write the view in camera-orbit syntax
viewer.cameraOrbit = '45deg 60deg 120%';   // animates
console.log(viewer.cameraOrbit);           // "45deg 60deg 3.6m"
viewer.cameraTarget = '0m 1m 0m';

// Jump straight there instead of animating
viewer.setCameraOrbitSpec('top', false);

// Constrain how far the user can orbit
viewer.setCameraOrbitLimits('auto 20deg 1m', 'auto 90deg 8m');

// Orthographic for dimensional comparison, perspective for presentation
viewer.setCameraProjection('orthographic');

// Stand a Z-up CAD model upright
viewer.setUpAxis('z');

// Measurements from the loaded model
const size = viewer.getDimensions();        // THREE.Vector3
const centre = viewer.getBoundingBoxCenter();

// What is under this pixel?
const hit = viewer.positionAndNormalFromPoint(event.clientX, event.clientY);
if (hit) {
  viewer.addHotspot({ id: 'pin', position: hit.position, normal: hit.normal, element: pinEl });
}

Authored Cameras, Fit Insets & Reduced Motion

getAuthoredCameras() lists the loaded file's own cameras (glTF cameras) as { index, name, type: 'perspective' | 'orthographic' }, and viewFromAuthoredCamera(index, smooth?) views the model through one, switching projection if that camera needs it; it returns false when there is no camera at index. smooth defaults to animating unless reduced motion is in effect. Afterwards the camera orbits as usual.

Automatic framing (on load, fitToScreen, an up-axis change) fits the corners of the model's bounding box. fitInsets in the config, or setFitInsets({ top, bottom, left, right }), tells it how many canvas pixels your own overlay chrome covers, so the model is framed inside the rest; getFitInsets() reads them back and setFitInsets(null) clears them. The default is no insets. The <universal-3d-viewer> element has the same setFitInsets() / getFitInsets(); it keeps the insets you set and adds its built-in toolbar's height to the bottom inset by itself.

reducedMotion in the config, or setReducedMotion(true | false | null), makes every camera move jump instead of animate: the fit on load, a hotspot's fly-to, and API calls asked to be smooth (resetCamera, fitToScreen, setCameraOrbit, viewFromAuthoredCamera). null, the default, follows the user's prefers-reduced-motion setting; isReducedMotion() reports the value in effect, on the viewer and on the element.

JavaScript
import { Universal3DViewer } from 'universal-3d-viewer'

const viewer = new Universal3DViewer({
  container: document.getElementById('viewport'),
  fitInsets: { top: 64 },   // a 64 px header floats over the canvas
  reducedMotion: false,     // always animate, whatever the OS says
})

await viewer.load('/scene.glb')
for (const cam of viewer.getAuthoredCameras()) {
  console.log(cam.index, cam.name, cam.type)
}
viewer.viewFromAuthoredCamera(0)

// Later, when the header collapses
viewer.setFitInsets({ top: 0 })
viewer.fitToScreen()

viewer.setReducedMotion(null) // follow prefers-reduced-motion again

Loading, Cancellation & Errors

viewer.load(source, options?) takes a URL, a File or a three.js object and resolves with the model's root group. options.companions passes the sibling files (a .bin, textures, an .mtl) that the main file refers to by relative URI, for a multi-file upload; options.signal is an AbortSignal that cancels the download and parse. filesFromDataTransfer(event.dataTransfer) turns a drop, folders included, into that list of files.

A failed load rejects with a ModelLoadError: message is a sentence meant for a person, code is one of 'unsupported-format', 'corrupt', 'missing-resource', 'empty', 'too-large', 'parse-failed' or 'aborted', fileName names the file, and the parser's own error is on cause.

A load that is superseded by a newer load(), aborted through signal, or cut short by dispose() rejects with an error named AbortError and is not reported on the error event. Code that calls load() without a .catch therefore logs "Uncaught (in promise)" when a user switches models quickly. Filter aborts with isAbortError:

JavaScript
import { Universal3DViewer, ModelLoadError, isAbortError, filesFromDataTransfer } from 'universal-3d-viewer'

viewer.load(url).catch((err) => { if (!isAbortError(err)) throw err })

// A drop of model.gltf + model.bin + textures, cancellable
const controller = new AbortController()
dropZone.addEventListener('drop', async (event) => {
  event.preventDefault()
  const files = await filesFromDataTransfer(event.dataTransfer)
  const main = files.find((f) => /\.(gltf|glb|obj|stl|step|stp|ifc)$/i.test(f.name))
  if (!main) return
  try {
    await viewer.load(main, { companions: files.filter((f) => f !== main), signal: controller.signal })
  } catch (err) {
    if (isAbortError(err)) return
    if (err instanceof ModelLoadError) showMessage(err.message) // e.g. code 'missing-resource'
    else throw err
  }
})
cancelButton.addEventListener('click', () => controller.abort())

Live Textures & Color Swatches

Dynamically update mesh colors or apply new texture maps at runtime:

JavaScript
// 1. Change PBR color (pass part name or '*' for all meshes)
viewer.setPartColor('Cushion', '#ff3366');

// 2. Live Texture Swapping (color, roughness, metalness, normal)
await viewer.updateTexture('Cushion', 'map', 'https://cdn.example.com/leather-texture.webp', {
  repeat: [2, 2],
  flipY: true
});

// 3. Inspect all discovered parts and materials
const parts = viewer.viewer.getParts();
console.log(parts); // [{ name: "Seat", materialName: "Leather", color: "#3b82f6", ... }]

Scene Graph Material API (viewer.model)

Once a model is loaded, viewer.model exposes its materials in the <model-viewer> scene-graph shape. Edits write straight to the live material, so they show up on the next frame and travel into exportScene().

JavaScript
viewer.addEventListener('scene-graph-ready', async () => {
  const material = viewer.model.getMaterialByName('Body');

  // Base PBR
  material.pbrMetallicRoughness.setBaseColorFactor([0.8, 0.1, 0.1, 1]);
  material.pbrMetallicRoughness.setBaseColorFactor('#cc1111');   // hex also works
  material.pbrMetallicRoughness.setMetallicFactor(1);
  material.pbrMetallicRoughness.setRoughnessFactor(0.25);

  // Emission, transparency, culling
  material.setEmissiveFactor('#221100');
  material.setAlphaMode('BLEND');       // 'OPAQUE' | 'MASK' | 'BLEND'
  material.setDoubleSided(true);

  // glTF extensions (MeshPhysicalMaterial only; warns otherwise)
  material.setClearcoatFactor(1);
  material.setTransmissionFactor(0.9);
  material.setIor(1.5);

  // Textures
  const texture = await viewer.createTexture('/textures/carbon.jpg');
  material.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
  material.normalTexture.setTexture(null);   // clear a slot

  // Undo everything above. Prefer material.reset() over
  // viewer.resetNodeMaterial(name) — most CAD output leaves materials
  // unnamed, and a name lookup cannot reach those.
  material.reset();
});

// Material variants authored into the glTF
console.log(viewer.availableVariants);  // ['Chrome', 'Matte']
viewer.variantName = 'Chrome';

CAD Feature Edges

Toggle crisp CAD wireframe lines with anti-Z-fighting offsets:

JavaScript
// Toggle edges on or off
viewer.toggleEdges();

// Or configure explicitly
viewer.viewer.setEdges(true, {
  color: '#38bdf8',
  thresholdAngle: 25 // Crease angle in degrees
});

Environment, Skybox & Lighting

JavaScript
// 1. Switch lighting presets ('studio' | 'neutral' | 'sunset' | 'warehouse' | 'night')
viewer.setEnvironmentPreset('sunset');

// 2. Load custom HDR / EXR / WebP environment map
await viewer.setEnvironmentImage('https://cdn.example.com/lighting/studio.hdr');

// 3. Set 360 Skybox Background
await viewer.setSkyboxImage('https://cdn.example.com/skybox/city.jpg');
viewer.toggleSkybox(true);

// 4. Tone mapping & Exposure
viewer.viewer.setExposure(1.2);
viewer.viewer.setToneMapping('aces');

3D Hotspots & Pins

Hotspots automatically project from 3D space to 2D screen pixels and hide when occluded behind the model:

JavaScript
// Add a hotspot programmatically
viewer.addHotspot({
  id: 'turbocharger',
  position: [0.15, 0.45, -0.1],
  normal: [0, 1, 0], // Optional surface normal
  element: `
    <div class="hotspot-pin">
      <span>1</span>
      <div class="tooltip">High-Efficiency Turbocharger</div>
    </div>
  `,
  occlude: true, // Auto-hide when behind model
  onClick: (id) => alert('Clicked hotspot: ' + id)
});

// Remove a hotspot
viewer.removeHotspot('turbocharger');

// Fly camera smoothly to focus on a hotspot
viewer.focusHotspot('turbocharger');

// Clear all hotspots
viewer.viewer.clearHotspots();

A string element is parsed as HTML (innerHTML). Only pass markup your page itself controls. Text that came from a user, a URL parameter or a CMS field must be passed with asText: true, which sets it as plain text (textContent); otherwise it can inject script into the page. Passing a DOM node you built yourself is always safe.

JavaScript
// A label typed by a user: plain text, never HTML
viewer.addHotspot({
  id: 'note-' + note.id,
  position: note.position,
  element: note.label,
  asText: true,
});

Scene Graph Hierarchy & Mesh Visibility

Inspect the full parent-child hierarchy and toggle visibility of any mesh or assembly:

JavaScript
// 1. Get full recursive tree
const tree = viewer.getSceneTree();
console.log(tree); // { name: "Root", children: [{ name: "Engine", polyCount: 12400, ... }] }

// 2. Hide or show specific parts (e.g. for exploded view / x-ray)
viewer.setNodeVisibility('Engine_Cover', false);
viewer.setNodeVisibility('Wheel_Rear_Right', true);

// 3. Reset all nodes to visible
viewer.resetVisibility();

Keyframe Animations (Mixer Control)

Discover, play, cross-fade, and scrub embedded 3D animations:

JavaScript
// 1. List all animation clips in the model
const animations = viewer.getModelAnimations(); // (or viewer.getAnimations() on headless SDK)
console.log(animations); // [{ name: "Run", duration: 1.4, index: 0 }, { name: "Jump", ... }]

// 2. Play with options (loop, speed, cross-fade)
viewer.playAnimation('Run', {
  loop: true,
  timeScale: 1.5,
  crossFadeDuration: 0.4
});

// 3. Playback controls
viewer.pauseAnimation();
viewer.resumeAnimation();
viewer.setAnimationTime(0.75); // Scrub to 0.75 seconds
viewer.stopAnimation();

Camera Rotation & Turntable Constraints

Lock camera rotation to keep the model above ground or restrict horizontal sweep:

JavaScript
viewer.setRotationLimits({
  minPolarAngle: '15deg',
  maxPolarAngle: '90deg',   // Prevents camera from dipping below the floor
  minAzimuthAngle: '-45deg', // Restricts horizontal turntable sweep
  maxAzimuthAngle: '45deg'
});

Automatically generate multi-angle snapshots (front, right, back, left, top, perspective) with clean Blob URLs. Snapshots display one after another in a clean horizontal row, or can be hidden completely so you can render your own custom UI:

JavaScript
// 1. Generate multi-angle snapshots and retrieve Blob URLs
const images = await viewer.generateGalleryImages({
  views: ['front', 'right', 'back', 'left', 'top', 'isometric'],
  format: 'image/webp',
  quality: 0.95
});

// 2. Build your own Custom UI (Row after row, custom thumbnail list, etc.)
const customGalleryRow = document.getElementById('my-custom-gallery');
images.forEach((img) => {
  const thumb = document.createElement('img');
  thumb.src = img.blobUrl;
  thumb.title = img.label;
  thumb.onclick = () => {
    // Smoothly fly camera to this angle
    viewer.setCameraOrbit(img.orbit.theta, img.orbit.phi, img.orbit.radius, true);
  };
  customGalleryRow.appendChild(thumb);
});

// 3. Built-in Strip Show / Hide Controls
viewer.showGallery(false);         // Hide built-in row strip (ideal when using custom UI)
viewer.showGallery(true);          // Show built-in horizontal row strip
viewer.toggleGallery();            // Toggle visibility
console.log(viewer.isGalleryVisible()); // Check current visibility state

Interactive Surface Picking (Raycasting)

Listen to surface-click to drop pins exactly where the user clicks:

JavaScript
viewer.addEventListener('surface-click', (e) => {
  const { point, normal, meshName } = e.detail;
  console.log(`Clicked on ${meshName} at [${point.x}, ${point.y}, ${point.z}]`);

  // Drop a new pin at the hit location with fly-to enabled
  viewer.addHotspot({
    id: 'pin-' + Date.now(),
    position: [point.x, point.y, point.z],
    normal: [normal.x, normal.y, normal.z],
    flyToOnClick: true,
    element: '<div class="hotspot-pin">📍</div>'
  });
});

Capturing High-Res Screenshots

JavaScript
const dataUrl = viewer.captureScreenshot({
  format: 'image/webp',
  quality: 0.95
});

// Download image or show in modal
const link = document.createElement('a');
link.download = 'model-render.webp';
link.href = dataUrl;
link.click();

Exporting the Scene

exportScene() writes the model currently in the viewport to any supported format and resolves with a Blob. Everything you changed through the API — material edits, the active variant, node visibility — is part of what gets written, so this doubles as a "save my configuration" button on a product configurator.

OptionTypeDefaultDescription
format "glb" | "gltf" | "obj" | "stl" | "ply" | "usdz" "glb" Output format. CAD sources (STEP, IGES, IFC…) are tessellated, so they export as mesh formats.
onlyVisible boolean true Skips nodes hidden with setNodeVisibility(). Applies uniformly to every format.
animations boolean true Writes the model's keyframe clips into glTF/GLB output.
optimize boolean false Runs the glTF geometry optimizer (prune, dedupe, weld). Loads on demand, so it costs nothing unless used.
binary boolean true Binary encoding for formats that have both. Only STL is affected — for glTF, format already decides.
fileName string source file name Base name, without extension.
JavaScript
// Save the configured model as a GLB
const blob = await viewer.exportScene({ format: 'glb', optimize: true });

const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'configured-model.glb';
link.click();

// Upload it instead
const body = new FormData();
body.append('model', blob, 'configured-model.glb');
await fetch('/api/save-configuration', { method: 'POST', body });

// Rendered frame as a Blob or data URL
const png = await viewer.toBlob({ mimeType: 'image/png' });
const dataUrl = viewer.toDataURL('image/webp', 0.9);

Augmented Reality (WebXR, Scene Viewer & Quick Look)

activateAR() walks ar-modes in order and launches the first mode the device supports. On a WebXR device the model is presented in-page: a reticle tracks real surfaces, a tap places the model, one finger rotates it and a pinch scales it (unless ar-scale="fixed"). Everything about the studio viewport — camera, background, grid, shadows and edges — is restored when the session ends.

On iOS, ios-src is optional: with no .usdz supplied the viewer exports one from the model currently loaded, so any of the supported CAD/mesh formats can be opened in Quick Look.

JavaScript
const viewer = document.querySelector('universal-3d-viewer');

// Only offer AR when this device can actually run one of the modes
if (await viewer.canActivateAR()) {
  arButton.hidden = false;
}

viewer.addEventListener('ar-status', (e) => {
  // 'session-started' -> 'object-placed' -> 'not-presenting' ('failed' if nothing could launch)
  console.log('AR:', e.detail.status);
});

viewer.addEventListener('ar-tracking', (e) => {
  hintEl.hidden = e.detail.status === 'tracking';
});

await viewer.activateAR();

// End the immersive session programmatically
await viewer.exitAR();

Model Cache & Postprocessing

Parsed models are cached by URL across every viewer on the page, so re-opening one skips both the download and the parse — worth the most on CAD formats, where tessellation in the WASM kernel is the slow step. Each load still gets its own geometry and materials, so edits in one viewer never leak into another.

JavaScript
import { Universal3DViewer } from 'universal-3d-viewer';

Universal3DViewer.modelCacheSize = 10;   // default 5; 0 disables caching
// Estimated bytes of parsed geometry and textures the cache may hold
// (default 256 MB); least recently used models are evicted beyond it.
Universal3DViewer.modelCacheByteBudget = 128 * 1024 * 1024;
Universal3DViewer.clearModelCache();

// Hand the render pass to a postprocessing composer. You build it from the
// engine's renderer/scene/camera and own its passes; the viewer just calls
// render() each frame instead of renderer.render(), and resizes it with the
// canvas. Rebuild your passes on camera-projection-change — switching
// projection replaces the camera object.
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';

const { renderer, scene, camera } = viewer.engine;
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
viewer.registerEffectComposer(composer);

viewer.unregisterEffectComposer();   // back to the plain renderer

6. Custom UI & Headless Integration

You can completely hide the viewer's built-in toolbar by adding no-default-ui and controlling the viewer with your own buttons:

HTML
<!-- Headless Viewer -->
<universal-3d-viewer id="my-viewer" src="model.glb" no-default-ui></universal-3d-viewer>

<!-- Bespoke External HTML Controls -->
<div class="custom-control-bar">
  <button onclick="document.getElementById('my-viewer').resetCamera()">Reset View</button>
  <button onclick="document.getElementById('my-viewer').toggleAutoRotate()">Spin</button>
  <button onclick="document.getElementById('my-viewer').toggleEdges()">CAD Lines</button>
  <button onclick="document.getElementById('my-viewer').toggleFullscreen()">Fullscreen</button>
</div>

7. Extensibility & Plugin Architecture

Universal 3D Viewer features a modular Plugin Architecture allowing developers to extend the 3D engine with custom tools, render loop updates, scene lifecycle hooks, and automatic Shadow DOM toolbar actions without modifying the core codebase.

The ViewerPlugin Interface & Lifecycle

Any custom extension implements the ViewerPlugin interface:

TypeScript
import { ViewerPlugin, PluginContext, PluginToolbarAction } from 'universal-3d-viewer';

export class CustomWatermarkPlugin implements ViewerPlugin {
  public readonly id = 'watermark';
  public readonly name = 'Company Watermark Overlay';
  public readonly version = '1.0.0';

  private context: PluginContext | null = null;

  // 1. Initialized with full access to Three.js scene, camera, renderer & overlays
  public init(context: PluginContext): void {
    this.context = context;
    // ...add your meshes or DOM nodes, then ask for a frame (see below)
    context.invalidate();
  }

  // 2. Render loop hook executed before every frame
  public update(delta: number): void {
    // Animate custom 3D objects, update particle physics, or lerp overlays
  }

  // 3. Called when a new 3D model is loaded and centered
  public onModelLoaded(model: THREE.Group): void {
    console.log('Model loaded into scene:', model);
  }

  // 4. Optional toolbar button automatically injected into the Shadow DOM toolbar
  public getToolbarAction(): PluginToolbarAction | null {
    return {
      id: this.id,
      label: 'Toggle Watermark',
      iconSvg: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">...</svg>',
      onClick: (active) => console.log('Toggled active:', active)
    };
  }

  // 5. Cleanup when plugin or viewer is disposed
  public dispose(): void {
    this.context = null;
  }
}

The viewer renders on demand. An idle viewer draws no frames, so a plugin that changes the scene (moves parts, edits materials or clipping planes, adds helpers) must call context.invalidate() afterwards, or the change stays invisible until something else redraws. needsContinuousRender(), if you implement it, only keeps an animation that is already running alive: it is consulted at the end of a frame that is being drawn and cannot wake a parked loop. A plugin that starts an animation calls context.invalidate() once and returns true from needsContinuousRender() until the animation ends.

Registering Plugins

Plugins can be registered programmatically on the SDK or declarative Web Component:

JavaScript
import { Universal3DViewer, MeasurementPlugin, ExplodedViewPlugin, ClippingPlanePlugin } from 'universal-3d-viewer';

// 1. Programmatic registration on Universal3DViewer or <universal-3d-viewer>
const viewer = new Universal3DViewer({ container: document.getElementById('viewer-div') });

await viewer.use(new MeasurementPlugin({ unit: 'mm' }));
await viewer.use(new ExplodedViewPlugin());
await viewer.use(new ClippingPlanePlugin());

// Retrieve an active plugin instance at any time:
const measurement = viewer.getPlugin('measurement');
measurement.activate();

// Or remove a plugin:
viewer.removePlugin('measurement');

Or declaratively using the plugins HTML attribute on <universal-3d-viewer>:

HTML
<!-- Automatically instantiates and registers Measurement, Exploded View, and Clipping Plane -->
<universal-3d-viewer
  src="assembly.glb"
  camera-controls
  plugins="measurement,exploded,clipping">
</universal-3d-viewer>

Built-in: 3D Tape Measure (MeasurementPlugin)

Enables interactive point-to-point dimensioning on any 3D model surface with real-time 2D floating badges and metric/imperial unit formatting:

JavaScript
import { MeasurementPlugin } from 'universal-3d-viewer';

const measure = new MeasurementPlugin({ unit: 'mm' });
await viewer.use(measure);

measure.activate();           // Crosshair cursor mode
measure.setUnit('in');        // Switch between 'mm', 'cm', 'm', 'in'
console.log(measure.getMeasurement()); // { distance: 124.5, formatted: "124.5 mm", ... }
measure.clear();              // Clear measurements and markers
JavaScript
// Three measurement modes
measure.setMode('distance');  // two points (default)
measure.setMode('angle');     // three points; the middle one is the vertex
measure.setMode('path');      // any number of points, finished explicitly

measure.setUnit('mm');        // 'mm' | 'cm' | 'm' | 'in' — relabels existing readings

// A path ends on Enter, on Escape, or programmatically
measure.finishPath();

// Measurements accumulate rather than replacing each other
const all = measure.getMeasurements();
measure.removeMeasurement(all[0].id);
measure.cancelActive();   // abandon the one being placed
measure.clear();          // remove every measurement

viewer.addEventListener('measurement-change', (e) => {
  if (!e.detail) return;
  const { type, distance, angle, totalLength, formatted } = e.detail;
  label.textContent = formatted;   // "25.40 mm" or "90.0°"
});

Built-in: Exploded Assembly View (ExplodedViewPlugin)

Calculates radial mesh offsets from the model center and smoothly translates parts outwards with smooth easing:

JavaScript
import { ExplodedViewPlugin } from 'universal-3d-viewer';

const exploded = new ExplodedViewPlugin();
await viewer.use(exploded);

exploded.setExplosion(0.75);     // Set explosion factor (0.0 = assembled, 1.0 = fully disassembled)
exploded.animateTo(1.0, 0.8);   // Smoothly animate to exploded state over 0.8 seconds
exploded.reset();                // Instantly reassemble

Built-in: Cross-Section Cutaway (ClippingPlanePlugin)

Slices through 3D meshes along X, Y, or Z axis with filled edges, essential for architectural BIM and mechanical CAD inspection:

JavaScript
import { ClippingPlanePlugin } from 'universal-3d-viewer';

const clipping = new ClippingPlanePlugin();
await viewer.use(clipping);

clipping.enable();               // Activate section cutting plane
clipping.setAxis('z');           // Cut along 'x', 'y', or 'z' axis
clipping.setOffset(0.25);        // Position plane (-1.0 to 1.0 across model bounds)
clipping.flip();                 // Invert cut normal direction

// Cut on more than one axis at once — the planes intersect
clipping.addPlane('x', 0.5);     // a second cut, on another axis
clipping.setPlaneOffset('x', -0.2);
clipping.flipPlane('x');
clipping.removePlane('x');

// Or reduce the model to a box: an opposed pair of planes on every axis
clipping.setBoxClip(-0.5, 0.5);
console.log(clipping.getPlaneCount());  // 6
clipping.clearExtraPlanes();            // back to the single primary cut

Custom 3D Format Loaders

Extend the viewer's file ingestion pipeline with custom CAD parsers, point clouds, or proprietary formats at runtime:

JavaScript
import { Universal3DViewer } from 'universal-3d-viewer';

// Register custom format loader adapter
Universal3DViewer.registerLoader({
  id: 'custom-format-adapter',
  supportedExtensions: ['.custom3d', '.myformat'],
  canHandle(file, magicBytes) {
    return file.name.endsWith('.custom3d');
  },
  async load(file, onProgress) {
    const group = new THREE.Group();
    // Parse custom file buffer and construct Three.js meshes...
    return { scene: group, animations: [], stats: { polyCount: 500, meshCount: 1 } };
  }
});

8. Shopify Liquid Integration

Drop the following snippet into snippets/universal-3d-viewer.liquid in your Online Store 2.0 theme (the full version, with variant swatches, is examples/shopify/snippet.liquid in the repository). It loads the UMD bundle as a classic script; the ES build cannot be loaded with <script type="module"> because it imports the bare specifier three.

Liquid (Shopify)
{%- assign model_media = product.media | where: "media_type", "model" | first -%}
{%- if model_media -%}
  {%- comment -%}
    The UMD bundle: a classic script with three.js inside that registers the element.
    Pinned to 0.2.0; the URL resolves once that version is published to npm.
  {%- endcomment -%}
  {%- assign u3d_cdn = 'https://cdn.jsdelivr.net/npm/universal-3d-viewer@0.2.0' -%}
  <script src="{{ u3d_cdn }}/dist/library/universal-3d-viewer.umd.js" defer></script>

  <universal-3d-viewer
    id="product-viewer-{{ product.id }}"
    src="{{ model_media.sources[0].url }}"
    asset-base-path="{{ u3d_cdn }}/public/"
    poster="{{ model_media.preview_image | image_url: width: 1000 }}"
    alt="{{ product.title | escape }}"
    camera-controls
    auto-rotate
    ar
    loading="lazy"
    environment-preset="studio"
    style="width: 100%; height: 500px;"
  >
    <button slot="ar-button" class="shopify-ar-pill">View in your room (AR)</button>
  </universal-3d-viewer>

  <script>
    // Live product variant switching
    document.addEventListener('variant:change', function(e) {
      if (e.detail && e.detail.variant) {
        const color = e.detail.variant.metafields.custom.color_hex;
        const viewer = document.getElementById('product-viewer-{{ product.id }}');
        if (viewer && color) {
          viewer.setPartColor('Body', color);
        }
      }
    });
  </script>
{%- endif -%}

9. WordPress Shortcode & Gutenberg Block

Use the shortcode anywhere in WordPress posts, pages, or WooCommerce products:

WordPress Shortcode
[universal_3d_viewer
    src="https://example.com/models/watch.glb"
    poster="https://example.com/models/watch-poster.webp"
    camera_controls="true"
    auto_rotate="true"
    edges="true"
    environment_preset="studio"
    ar="true"
    height="550px"
]

10. React & Vue 3 Integration

React (React 18 / 19)

React (TSX)
import React, { useRef } from 'react';
import 'universal-3d-viewer';

export function Product3DViewer({ modelUrl, posterUrl }: { modelUrl: string; posterUrl: string }) {
  const viewerRef = useRef(null);

  const handleReset = () => {
    viewerRef.current?.resetCamera();
  };

  return (
    <div style={{ width: '100%', height: '500px', position: 'relative' }}>
      <universal-3d-viewer
        ref={viewerRef}
        src={modelUrl}
        poster={posterUrl}
        camera-controls
        auto-rotate
        ar
        style={{ width: '100%', height: '100%' }}
      >
        <button slot="hotspot-details" data-position="0 0.5 0">
          Feature Info
        </button>
      </universal-3d-viewer>
    </div>
  );
}

Vue 3

Vue 3 (SFC)
<template>
  <div class="viewer-wrapper">
    <universal-3d-viewer
      ref="viewer"
      :src="modelSrc"
      camera-controls
      auto-rotate
      @surface-click="onSurfaceClick"
    ></universal-3d-viewer>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import 'universal-3d-viewer';

const modelSrc = ref('https://example.com/model.glb');
const viewer = ref(null);

function onSurfaceClick(event) {
  console.log('Surface clicked:', event.detail.point);
}
</script>

11. 3-Tier Multi-Format Support Matrix

Tier Formats Engine / Kernel Bundle Loading Strategy
Tier 1 (Core) .glb, .gltf, .obj, .stl, .ply, .fbx Native Three.js + Draco + Meshopt In the main bundle, parsed on the page's thread. The Draco, Meshopt and Basis decoders load only for files that use them.
Tier 2 (CAD/BIM) .step, .stp, .iges, .brep, .ifc OpenCascade WASM & web-ifc WASM Dynamic On-Demand: WASM kernel loaded only when a CAD/BIM file is opened, and run in a Web Worker (on the page's thread where no worker can start).
Tier 3 (Universal) 24 legacy formats (.3ds, .dae, .dxf, .lwo, .x, .off, etc.) Assimp WebAssembly Normalizer Dynamic On-Demand: WASM loaded on first use and run in a Web Worker, where the file is converted to a GLB buffer (on the page's thread where no worker can start).