npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

combinode-embed-api

v1.1.0

Published

Embed API SDK for the CombiNode 3D viewer

Downloads

70

Readme

combinode-embed-api

JavaScript / TypeScript SDK for embedding and controlling the CombiNode 3D viewer in your own web application.

The viewer runs in an <iframe>. This SDK wraps the postMessage protocol between your page and the iframe in a clean, fully-typed, promise-based API.

Contents


Scope of control

The API is deliberately presentational. You get full control over what the viewer shows and does — every node, material, animation, guide, hotspot, light, camera move and transition — addressed by name or stable key.

It does not expose the things underneath that presentation:

  • no geometry buffers, vertex data or bounding-volume structures;
  • no shader source, render-graph access or custom shader injection;
  • no texture bitmaps, and no model/delivery/asset URLs or session keys;
  • no model export, download or re-upload path;
  • no structural edits — you cannot add, delete or reparent nodes, change a light's type, or restructure the scene.

Everything crosses the iframe boundary as plain JSON over postMessage, so a host page can drive the viewer completely without being able to reconstruct the model or the viewer itself.


Installation

npm install combinode-embed-api

Or via CDN (no bundler required):

<script src="https://unpkg.com/combinode-embed-api/viewer-embed-sdk.iife.js"></script>
<!-- exposes window.ViewerEmbed -->

Quick start

The model must have Unlisted or Public visibility to be embeddable. Models set to Draft or Private will fire an error event with code not_embeddable. All newly created models default to Unlisted, so embedding works out of the box unless the visibility was changed.

<iframe
  id="viewer"
  src="https://combinode.com/e/YOUR_MODEL_ID"
  width="800" height="600"
  allow="fullscreen"
  style="border: none; background: #16171d; opacity: 0; transition: opacity .32s ease;"
  onload="this.style.opacity='1'"
></iframe>

Browsers paint a navigating iframe's placeholder as opaque white no matter what background you set on it — that only takes effect once the framed page actually exists and paints. The opacity/onload pair above keeps the iframe invisible until it has, so visitors see the viewer's own loading screen first instead of a white flash.

import { ViewerEmbed } from 'combinode-embed-api';

const iframe = document.getElementById('viewer') as HTMLIFrameElement;
const viewer = new ViewerEmbed(iframe);

// Handle load errors (e.g. model is private)
viewer.onError(({ code, message }) => {
  console.error('Viewer error:', code, message);
  // code: 'not_embeddable' | 'not_found' | 'load_failed'
});

const { modelName, clips } = await viewer.ready();
console.log('Loaded:', modelName, '| clips:', clips);

// fly the camera in
await viewer.camera.flyTo({ posX: 0, posY: 1.5, posZ: 4, tgtX: 0, tgtY: 0.5, tgtZ: 0 });

Constructor

new ViewerEmbed(iframe: HTMLIFrameElement, options?: ViewerEmbedOptions)

| Option | Type | Default | Description | |--------|------|---------|-------------| | targetOrigin | string | '*' | Passed to postMessage. Set to your viewer's exact origin for security (e.g. 'https://combinode.com'). | | commandTimeout | number | 10000 | Milliseconds before a command that expects a response rejects with a timeout error. |

Call viewer.destroy() when you remove the iframe to clean up message listeners and pending timers.


Lifecycle

viewer.ready(): Promise<ReadyPayload>

Resolves once the scene has finished loading. Always await this before sending commands.

const { modelId, modelName, clips } = await viewer.ready();

| Field | Type | Description | |-------|------|-------------| | modelId | string | Unique ID of the loaded model | | modelName | string | Display name | | clips | string[] | Animation clip names available in the model | | settings | unknown | Full scene settings snapshot |

viewer.destroy(): void

Removes the message event listener and rejects all pending commands. Call this when tearing down the iframe.


Camera

viewer.camera.flyTo(opts)           // smooth animated move — returns Promise<void>
viewer.camera.setPosition(opts)     // instant teleport
viewer.camera.setFov(fov)           // set field of view in degrees
viewer.camera.setBounds(opts)       // constrain orbit angles / zoom
viewer.camera.reset()               // return to default position
viewer.camera.getState()            // returns Promise<CameraStatePayload>

flyTo / setPosition options

{
  posX: number; posY: number; posZ: number;  // camera position
  tgtX: number; tgtY: number; tgtZ: number;  // look-at target
  duration?: number;  // flyTo only — animation duration in ms (default 1200)
}

setBounds options (all optional)

{
  polarMin?: number;    // min vertical angle in radians
  polarMax?: number;    // max vertical angle in radians
  azimuthMin?: number;  // min horizontal angle in radians
  azimuthMax?: number;  // max horizontal angle in radians
  zoomMin?: number;     // min zoom distance
  zoomMax?: number;     // max zoom distance
}

getState response

{ posX, posY, posZ, tgtX, tgtY, tgtZ, fov }

Variants

Switch between the model's saved variants (materials, colors, states). Variants can also be created and managed programmatically, with full control over materials, visibility, transforms, and transitions.

Switching Variants

await viewer.variants.switch('setId', 'variantId')
await viewer.variants.switchByName('Color', 'Red')
await viewer.variants.reset()
const sets   = await viewer.variants.getState()     // active state
const all    = await viewer.variants.getAvailable()  // all sets + variants

Creating Variants ⭐ NEW

// Create a variant set
const { setId } = await viewer.variants.createSet('Paint Colors', 'Pearl White')

// Create a variant in the set
const { variantId } = await viewer.variants.create('Crimson Red', setId, undefined, {
  mode: 'shader',
  durationSec: 0.5,
})

// Apply materials
await viewer.variants.addMaterials(setId, variantId, {
  'body_paint': {
    color: '#cc0000',
    metalness: 0.8,
    roughness: 0.3,
  },
})

// Control visibility
await viewer.variants.addVisibility(setId, variantId, {
  'spoiler': true,
  'bumper_sport': false,
})

// Set transforms (position, rotation, scale)
await viewer.variants.addTransforms(setId, variantId, {
  'doors': {
    rotationDeg: { x: 0, y: 90, z: 0 },
  },
})

// Update variant settings
await viewer.variants.update(setId, variantId, {
  name: 'Crimson Red Sport',
  transitionSettings: { mode: 'snapshot', durationSec: 1.0 },
})

// Capture current scene state as variant
await viewer.variants.captureSnapshot(setId, variantId)

// Delete variant or set
await viewer.variants.delete(setId, variantId)
await viewer.variants.deleteSet(setId)

Material Properties in Variants

When adding materials to a variant, you can use any property from MaterialSettings:

  • Basic: color, emissive, metalness, roughness, opacity, ior, transparent
  • Clearcoat: clearcoat, clearcoatRoughness
  • Transmission: transmission, thickness, attenuationColor, specularIntensity
  • SSS: sssEnabled, sssIntensity, sssThickness, sssDistortion, sssAmbient, sssAttenuation, sssPower, sssScale
  • Surface: normalMap, bumpMap, displacementMap, normalScale, bumpScale
  • Edge Falloff: edgeFalloffEnabled, edgeFalloffColor, edgeFalloffIntensity, edgeFalloffWidth
  • Environment: aoMapIntensity, envMapIntensity

Transition Settings

Control how smoothly variants animate when switching:

interface VariantTransitionSettings {
  mode: 'shader' | 'snapshot';  // GPU fade vs. full state blend
  durationSec: number;           // Animation duration (e.g., 0.3 - 2.0)
}
  • 'shader' — Fast GPU cross-fade (best for color/material changes)
  • 'snapshot' — Full state blend (best for complex, many-property changes)

VariantSetInfo

{
  id:              string;
  name:            string;
  activeVariantId: string;
  variants: Array<{ id: string; name: string }>;
}

Complete Example

// Build a car customizer with color and trim options
async function setupCustomizer() {
  // Create color set
  const { setId: colorSetId } = await viewer.variants.createSet('Paint Colors')
  
  const colors = [
    { name: 'Pearl White', hex: '#ffffff' },
    { name: 'Midnight Black', hex: '#0a0a0a' },
    { name: 'Crimson Red', hex: '#cc0000' },
  ]
  
  for (const { name, hex } of colors) {
    const { variantId } = await viewer.variants.create(name, colorSetId)
    await viewer.variants.addMaterials(colorSetId, variantId, {
      'body': { color: hex },
    })
  }
  
  // Create trim set
  const { setId: trimSetId } = await viewer.variants.createSet('Trims')
  
  const { variantId: sportId } = await viewer.variants.create('Sport', trimSetId)
  await viewer.variants.addVisibility(trimSetId, sportId, {
    'sport_bumper': true,
    'standard_bumper': false,
  })
  await viewer.variants.addMaterials(trimSetId, sportId, {
    'wheels': { color: '#1a1a1a', metalness: 0.95 },
  })
}

// Listen for variant changes
viewer.onVariantChanged(({ setName, variantName }) => {
  console.log(`Switched to ${variantName} in ${setName}`)
  updateUIButtons()
})

See EMBED_API_VARIANTS_GUIDE.md for comprehensive examples and workflows.


Animations

await viewer.animations.play('Walk', { loop: 'loop', speed: 1 })
await viewer.animations.pause('Walk')
await viewer.animations.stop('Walk')
await viewer.animations.setSpeed('Walk', 1.5)
await viewer.animations.setLoopMode('Walk', 'pingpong')

const clips  = await viewer.animations.getClips()   // string[]
const tracks = await viewer.animations.getState()   // AnimationTrackSettings[]

play options

{
  loop?:  'loop' | 'once' | 'pingpong';  // default: 'loop'
  speed?: number;                         // default: 1
}

AnimationTrackSettings

{ name: string; playing: boolean; loop: AnimationLoopMode; speed: number }

Auto-spin

Continuously rotate the model around the Y axis.

viewer.autoSpin.enable({ speed: 30, inactivityDelay: 2000 })
viewer.autoSpin.disable()
viewer.autoSpin.setSpeed(60)   // degrees per second
const state = await viewer.autoSpin.getState()

enable options (all optional)

| Option | Type | Description | |--------|------|-------------| | speed | number | Degrees per second. 0 = no spin. Default: 30 | | inactivityDelay | number | ms of user inactivity before spin resumes after interaction |

getState response

{ enabled: boolean; spinning: boolean; speed: number; inactivityDelay: number }

Hotspots

Control interactive hotspot markers inside the scene.

// Visibility
viewer.hotspots.setVisibility('hotspot-1', false)
viewer.hotspots.setAllVisibility(true)

// UI rendering mode
viewer.hotspots.setUiMode('custom')   // 'native' | 'custom' | 'hidden'

// Programmatic trigger
viewer.hotspots.triggerAction('hotspot-1', 0)

// CRUD (for dynamically created hotspots)
const { id } = await viewer.hotspots.create({
  title: 'Engine',
  description: 'V8 twin-turbo',
  meshName: 'engine_mesh',
  localPosition: { x: 0.1, y: 0.4, z: 0 },
})
viewer.hotspots.update(id, { title: 'Updated title' })
viewer.hotspots.remove(id)
viewer.hotspots.clearCustom()        // remove all programmatically-created hotspots

const all = await viewer.hotspots.getAll()

UI modes

| Mode | Behaviour | |------|-----------| | native | Default glass chip + popup rendered by the viewer | | custom | Anchors are invisible; use hotspot:screenPositions event to render your own UI | | hidden | No UI rendered; screen positions still emit and actions still fire |

create payload (all optional except positioning)

{
  id?:            string;    // auto-generated if omitted
  title?:         string;
  description?:   string;
  meshName?:      string;    // attach to a named mesh
  localPosition?: { x, y, z };
  images?:        string[];  // URLs
  actions?:       HotspotAction[];
  uiMode?:        HotspotUiMode;
}

Post-processing (fx)

Adjust rendering effects at runtime.

viewer.fx.set({ bloomEnabled: true, bloomInt: 0.4, exposure: 1.1 })
viewer.fx.reset()
const state = await viewer.fx.getState()   // full FxSettings object

fx.set accepts any partial subset of FxSettings — only the fields you pass are changed. See the FxSettings type for the full list of available properties covering tone mapping, color grading, SSAO, HBAO, SSGI, SSR, bloom, chromatic aberration, depth of field, vignette, and antialiasing.


Environment

Control the scene's HDRI lighting and background.

viewer.env.set({ rotY: Math.PI / 4, showGrid: false, bgColor: '#1a1a2e' })
viewer.env.reset()
const state = await viewer.env.getState()   // full EnvSettings object

env.set accepts any partial subset of EnvSettings. Notable fields:

| Field | Type | Description | |-------|------|-------------| | rotY | number | HDRI rotation in radians | | envIntensity | number | IBL lighting intensity | | bgIntensity | number | Background brightness | | showHdriBackground | boolean | Show HDRI as background vs solid colour | | bgColor | string | Solid background hex colour (e.g. '#ffffff') | | showGrid | boolean | Ground grid visibility | | shadowMethod | 'pcss' \| 'accumulative' \| 'vsm' | Shadow technique | | fogEnabled | boolean | Scene fog |


Objects

Move, rotate, and scale individual objects in the scene.

viewer.objects.setTransform('wheel_fl', {
  position:    { x: 0, y: 0, z: 0 },
  rotationDeg: { x: 0, y: 45, z: 0 },
  scale:       { x: 1, y: 1, z: 1 },
})
viewer.objects.resetTransform('wheel_fl')
const list = await viewer.objects.getAll()  // { key, name }[]

The key values come from objects.getAll() or the scene:objectsChanged event.

Mesh Visibility

Toggle visibility of individual meshes in the scene.

// Hide a mesh
viewer.objects.setMeshVisibility('wheel_front_left', false)

// Show a mesh
viewer.objects.setMeshVisibility('wheel_front_left', true)

// Check current visibility state
const isVisible = await viewer.objects.getMeshVisibility('wheel_front_left')

Selection Outline (Hidden Mesh Support)

Show selection outlines that are visible even when the mesh is hidden. This is useful for highlighting parts without rendering them.

// Show outline for a mesh (works even if mesh is hidden)
viewer.objects.selectOutline('steering_wheel', true)

// Clear the outline
viewer.objects.selectOutline('steering_wheel', false)

Behavior:

  • When mesh is hidden and outline is selected: Only the outline is visible
  • When mesh is visible and outline is selected: Both mesh and outline are rendered
  • The outline uses the scene's edge-detection post-processing effect

Materials

Modify material properties on individual meshes. Control color, metalness, roughness, subsurface scattering, clearcoat, transmission, and more.

// Set material properties on a mesh
viewer.materials.setByMesh('chassis', {
  color: '#ff0000',
  metalness: 0.8,
  roughness: 0.2,
})

// Get all material properties
const state = await viewer.materials.getByMesh('chassis')

// Reset materials to original/baseline
viewer.materials.resetByMesh('chassis')

Supported Material Properties

| Category | Property | Type | Range | Default | |----------|----------|------|-------|---------| | Basic | color | hex string | '#000000' - '#ffffff' | '#ffffff' | | | emissive | hex string | any hex | '#000000' | | | emissiveIntensity | number | 0 - 2 | 0 | | | metalness | number | 0 - 1 | 0 | | | roughness | number | 0 - 1 | 0.5 | | | opacity | number | 0 - 1 | 1 | | | transparent | boolean | true/false | false | | | ior | number | 1 - 2+ | 1.5 | | | alphaTest | number | 0 - 1 | 0 | | Clearcoat | clearcoat | number | 0 - 1 | 0 | | | clearcoatRoughness | number | 0 - 1 | 0 | | | clearcoatMap | URL or null | texture URL | null | | | clearcoatNormalMap | URL or null | texture URL | null | | Transmission | transmission | number | 0 - 1 | 0 | | | thickness | number | 0 - 1+ | 0.5 | | | attenuationDistance | number | any | 0 | | | attenuationColor | hex string | any hex | '#ffffff' | | | specularIntensity | number | 0 - 2 | 1 | | | specularColor | hex string | any hex | '#ffffff' | | Subsurface Scattering | sssEnabled | boolean | true/false | false | | | sssIntensity | number | 0 - 1+ | 0 | | | sssThickness | number | 0 - 1+ | 0 | | | sssThicknessColor | hex string | any hex | '#ffffff' | | | sssDistortion | number | 0 - 1 | 0.1 | | | sssAmbient | number | 0 - 1 | 0 | | | sssAttenuation | number | 0 - 1 | 0.1 | | | sssPower | number | 1 - 4 | 2 | | | sssScale | number | 5 - 50 | 10 | | Normal / Displacement | normalMap | URL or null | texture URL | null | | | normalScale | {x, y} | 0 - 2 | {x: 1, y: 1} | | | bumpMap | URL or null | texture URL | null | | | bumpScale | number | 0 - 2 | 1 | | | displacementMap | URL or null | texture URL | null | | | displacementScale | number | 0 - 2 | 1 | | | displacementBias | number | -1 - 1 | 0 | | Edge Falloff | edgeFalloffEnabled | boolean | true/false | false | | | edgeFalloffColor | hex string | any hex | '#8fdcff' | | | edgeFalloffIntensity | number | 0 - 2 | 1 | | | edgeFalloffWidth | number | 0 - 1 | 0.24 | | | edgeFalloffPower | number | 1 - 4 | 2.2 | | | edgeFalloffInteriorOpacity | number | 0 - 1 | 0.15 | | Environment | aoMapIntensity | number | 0 - 2 | 1 | | | envMapIntensity | number | 0 - 2 | 1 |

Material Examples

Metallic material:

viewer.materials.setByMesh('part', {
  color: '#c0c0c0',
  metalness: 0.9,
  roughness: 0.1,
})

Glass/Transparent material:

viewer.materials.setByMesh('window', {
  transmission: 0.95,
  thickness: 0.5,
  opacity: 0.9,
  color: '#ffffff',
})

Skin with subsurface scattering:

viewer.materials.setByMesh('face', {
  sssEnabled: true,
  sssIntensity: 0.5,
  sssThickness: 0.3,
  sssThicknessColor: '#f4a460',
  metalness: 0,
  roughness: 0.6,
})

Clearcoat (car paint):

viewer.materials.setByMesh('body', {
  color: '#ff0000',
  metalness: 0.8,
  roughness: 0.3,
  clearcoat: 1.0,
  clearcoatRoughness: 0.05,
})

Finding Mesh Names

Use objects.getAll() to discover available mesh names:

const meshes = await viewer.objects.getAll()
meshes.forEach(mesh => {
  console.log(`Mesh: ${mesh.name} (key: ${mesh.key})`)
})

Material Updates vs Full Reset

Material updates are partial — only properties you specify are changed:

// Only changes color; other properties unchanged
viewer.materials.setByMesh('part', { color: '#ff0000' })

// Reset all materials to baseline
viewer.materials.resetByMesh('part')

Scene graph

Every object-addressing command accepts either a node key (stable, from the outline) or an object name. Prefer keys — names are not guaranteed unique.

const tree = await viewer.scene.getOutline()
// [{ key, name, type, visible, materials: string[], children: [...] }]

// Only geometry-bearing nodes, two levels deep
const shallow = await viewer.scene.getOutline({ meshesOnly: true, maxDepth: 2 })

const node = await viewer.scene.getNode('Seat_Cushion')
// { key, name, visible, transform, local, bounds, materials }

const bounds = await viewer.scene.getBounds(['Seat_Cushion', 'Armrest'])
// { min, max, center, size, radius }

const stats = await viewer.scene.getStats()
// { nodeCount, meshCount, materialCount, clipCount, hotspotCount, guideCount, variantSetCount }

The outline is descriptive only: names, addressing keys, visibility, bounds and material names. It never exposes geometry buffers, shaders, textures or asset URLs.


Transitions

Anything that changes a visual property accepts an optional transition:

{ durationSec?: number, easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' }

Omit it (or pass durationSec: 0) for an instant change. All animated commands return a promise that resolves when the transition finishes, so you can await and chain them.

Shader dissolve on visibility

objects.setVisibility with a duration runs the viewer's shader dissolve: both the outgoing and incoming states stay rendered while a per-material fade drives them in and out, so parts appear and disappear smoothly instead of popping.

await viewer.objects.setVisibility('Handle', false, { durationSec: 0.5 })

// Swap two parts in one blended transition
await viewer.objects.setVisibilities([
  { target: 'Handle_Standard', visible: false },
  { target: 'Handle_Premium',  visible: true  },
], { durationSec: 0.6, easing: 'ease-in-out' })

Animated transforms

Omitted channels keep their current value, so one control can drive one axis.

await viewer.objects.transformTo('Lid',
  { rotationDeg: { x: -105, y: 0, z: 0 } },
  { durationSec: 0.9, easing: 'ease-out' })

// Slider: skip the transition and write every frame
slider.oninput = () => viewer.objects.transformTo('Lid',
  { rotationDeg: { x: Number(slider.value), y: 0, z: 0 } })

Animated material properties

Numeric and colour channels interpolate; everything else (map slots, booleans) is applied up front so the blend runs against the final material setup.

await viewer.materials.transition(
  { materialName: 'Body_Paint' },
  { color: '#c0392b', roughness: 0.15, metalness: 0.9 },
  { durationSec: 0.8 },
)

Interpolated properties: color, emissive, attenuationColor, specularColor, emissiveIntensity, metalness, roughness, ior, alphaTest, opacity, clearcoat, clearcoatRoughness, transmission, thickness, attenuationDistance, specularIntensity, bumpScale, displacementScale, displacementBias, aoMapIntensity, envMapIntensity.

Variant transitions

The scene-wide transition mode governs every variant switch:

await viewer.variants.setTransition({ mode: 'shader', durationSec: 0.7 })
const current = await viewer.variants.getTransition()

// Switch several sets in ONE blended transition
await viewer.variants.switchMany([
  { setId: 'set_colour',   variantId: 'v_red'   },
  { setId: 'set_material', variantId: 'v_matte' },
], { durationSec: 0.8 })
  • shader — per-property GPU blend of base colour, roughness, metalness, emissive, normal and textures, plus a dissolve on any object whose visibility changes.
  • snapshot — cross-fade between two rendered states.

Focus and isolation

// Fly until these nodes fill the viewport
await viewer.objects.focus(['Engine_Block'], { duration: 900 })

// Hide everything else and fly in. Sending the same selection again exits
// and returns to the pre-isolation camera pose.
await viewer.objects.isolate(['Engine_Block', 'Piston_01'], { duration: 1000 })
await viewer.objects.exitIsolation()

viewer.onIsolationChanged(({ active, keys }) => updateBreadcrumb(active, keys))

Animation Studio

Authored multi-object timelines, separate from the model's baked clips.

const timelines = await viewer.studio.getAnimations()
// [{ id, name, duration }]

await viewer.studio.play(timelines[0].id)
await viewer.studio.pause()
await viewer.studio.seek({ progress: 0.35 })   // or { time: 2.4 }
const state = await viewer.studio.getState()
// { activeAnimationId, playing, currentTime, duration, progress }

// Keep your own scrub bar in sync with playback you did not start
await viewer.studio.setTimeEvents(true, 15)
viewer.onStudioState(({ progress }) => { bar.value = String(progress) })

Scrubbing baked clips

const clips = await viewer.animations.getClipInfo()
// [{ name, duration, playing, speed, loop, time }]

// Drag-to-scrub without starting playback
slider.oninput = () => viewer.animations.seek('Open', {
  progress: Number(slider.value), play: false,
})

await viewer.animations.playAll({ loop: 'loop', speed: 1 })
await viewer.animations.stopAll()

await viewer.animations.setTimeEvents(true, 10)
viewer.onAnimationTime(({ clips }) => { /* per-clip time/progress */ })

Both event streams are off by default — an always-on stream would tax every embed that never asks for it.


Guides

Spline-driven animated helper content (flow arrows, assembly paths, callout trails). Target individual guides, whole collections, or both.

const guides = await viewer.guides.getAll()
// [{ id, name, collectionId, visible, playing, isInstance }]
const groups = await viewer.guides.getCollections()
// [{ id, name, guideIds }]

await viewer.guides.setVisible({ collectionIds: ['airflow'] }, 'show')
await viewer.guides.setPlayback({ guideIds: ['g_1', 'g_2'] }, 'restart')
await viewer.guides.setSpeed({ collectionIds: ['airflow'] }, 2.5)
await viewer.guides.setSpeed({ collectionIds: ['airflow'] }, null) // authored speed

viewer.onGuideChanged(({ guideIds }) => { })

Lights

const lights = await viewer.lights.getAll()
// [{ id, name, type, color, intensity, castShadow, posX..posZ, rotX..rotZ, angle, penumbra }]

await viewer.lights.set(lights[0].id, { intensity: 3.2, color: '#ffe8c0' })
await viewer.lights.reset()

id and type are read-only — a host page can retune the rig but cannot restructure it.


Controls

await viewer.controls.set({ enableZoom: false, enablePan: false })
const state = await viewer.controls.getState()

// Orbit sliders
const { azimuth, polar, distance } = await viewer.camera.getOrbit()
await viewer.camera.orbitTo({ azimuth: Math.PI / 4, duration: 600 })

Actions

actions.execute runs the viewer's authored action vocabulary — the same one hotspots use internally. This is how a button, slider or dropdown on your page gets to do anything the viewer can do, including behaviours that have no dedicated SDK method.

await viewer.actions.execute([
  { type: 'variant', setId: 'set_colour', variantId: 'v_red', delay: 0 },
  { type: 'isolate', objectKeys: ['root.0.3'], duration: 1, delay: 0.2 },
  { type: 'guide',   guideId: '', collectionIds: ['airflow'], playback: 'play', delay: 0 },
])

// Machine-readable description of every action type and its fields
const schema = await viewer.actions.getSchema()

Supported action types: animation, camera, variant, studio-animation, hotspot-visibility, guide, isolate, open-url. Each takes a delay (seconds) so one call can choreograph a sequence.


State snapshot

Save and restore everything you have changed through the API — in one object, without replaying every individual command.

const snapshot = await viewer.state.getSnapshot()
// { variants: {setId: variantId}, visibility: {key: bool},
//   transforms: {key: {...}}, materials: {name: {...}} }

localStorage.setItem('my-view', JSON.stringify(snapshot))

// …later, in a fresh session
await viewer.state.applySnapshot(
  JSON.parse(localStorage.getItem('my-view')!),
  { durationSec: 0.6 },
)

applySnapshot accepts partial objects — pass only the sections you want to restore.


Utilities

// Capture a PNG screenshot of the current view
const { dataUrl } = await viewer.screenshot()
// dataUrl is a base64 PNG: "data:image/png;base64,..."

// Get the full scene settings snapshot
const settings = await viewer.getSettings()

// Get the model's latest thumbnail URL
const { thumbnailUrl } = await viewer.getThumbnail()
// thumbnailUrl: string | null — null if no thumbnail has been generated yet

Events

Subscribe to viewer events using on / off / once, or the typed shorthand helpers.

// Generic
viewer.on('camera:moved', (state) => console.log(state))
viewer.once('variant:changed', (e) => console.log(e.variantName))
viewer.off('camera:moved', myHandler)

// Typed shorthands
viewer.onReady(({ modelName }) => console.log('Ready:', modelName))
viewer.onError(({ code, message }) => console.error(code, message))
viewer.onCameraMoved((state) => { /* CameraStatePayload */ })
viewer.onVariantChanged(({ setName, variantName }) => { })
viewer.onHotspotClick(({ hotspotId, screenX, screenY }) => { })
viewer.onHotspotScreenPositions(({ positions }) => { })
viewer.onAutoSpinStarted(({ speed }) => { })
viewer.onAutoSpinPaused(() => { })
viewer.onAnimationStarted(({ clipName }) => { })
viewer.onAnimationEnded(({ clipName }) => { })
viewer.onModelSaved(({ modelId, modelName, savedAt }) => { })
viewer.onModelOptimized(({ modelId, modelName, deliveryUrl, optimizationMetrics }) => { })
viewer.onObjectVisibilityChanged(({ keys, visible }) => { })
viewer.onMaterialChanged(({ materialNames, animating }) => { })
viewer.onIsolationChanged(({ active, keys }) => { })
viewer.onGuideChanged(({ guideIds }) => { })
viewer.onStudioState(({ progress, playing }) => { })      // needs studio.setTimeEvents(true)
viewer.onAnimationTime(({ clips }) => { })                // needs animations.setTimeEvents(true)

Full event reference

| Event | Payload | Description | |-------|---------|-------------| | ready | ReadyPayload | Scene finished loading | | error | { code, message } | Load error (not_embeddable, not_found, load_failed) — not_embeddable fires when the model's visibility is draft or private | | camera:moved | CameraStatePayload | Camera position changed | | camera:interactionStart | — | User began dragging / zooming | | camera:interactionEnd | — | User released | | camera:flyStart | — | Animated fly-to started | | camera:flyEnd | — | Animated fly-to completed | | autospin:started | { speed } | Auto-spin activated | | autospin:paused | — | Spin paused by user interaction | | autospin:resumed | — | Spin resumed after inactivity | | autospin:stopped | — | Auto-spin disabled | | variant:changed | { setId, setName, variantId, variantName } | Active variant switched | | variant:conflictDetected | — | Variant conflict between sets | | animation:started | { clipName } | Clip began playing | | animation:ended | { clipName } | Clip finished | | animation:looped | { clipName } | Clip completed one loop iteration | | animation:paused | { clipName } | Clip paused | | hotspot:click | { hotspotId, title, description, isCustom, screenX, screenY } | Hotspot tapped | | hotspot:enter | { hotspotId, screenX, screenY } | Pointer entered hotspot | | hotspot:leave | { hotspotId, screenX, screenY } | Pointer left hotspot | | hotspot:actionTriggered | { hotspotId, actionIndex, action } | Hotspot action executed | | hotspot:screenPositions | { positions: HotspotScreenPosition[] } | Per-frame screen coords for all hotspots | | scene:objectsChanged | — | Scene objects list changed | | object:visibilityChanged | { keys, visible } | Node visibility finished changing (after any dissolve) | | material:changed | { materialNames, animating } | Material properties written; animating: true while a transition is still running | | isolation:changed | { active, keys } | Isolation entered or exited — including from a hotspot action or the viewer's own UI | | guide:changed | { guideIds } | Guide visibility / playback / speed changed | | studio:state | StudioStatePayload | Animation Studio position — opt in with studio.setTimeEvents(true) | | animation:time | { clips: [...] } | Per-clip playback position — opt in with animations.setTimeEvents(true) | | model:saved | { modelId, modelName, savedAt } | Editor saved the model to the server (savedAt is ISO 8601) | | model:optimized | { modelId, modelName, deliveryUrl, optimizationMetrics } | Cloud optimization pipeline completed successfully |

HotspotScreenPosition

{ id: string; screenX: number; screenY: number; behindCamera: boolean }

Use behindCamera to hide hotspot UI elements when the anchor is on the back side of the model.


URL parameters

You can pre-configure the viewer by appending query parameters to the embed src URL — no JavaScript needed.

<iframe src="https://combinode.com/e/MODEL_ID?variant=colorSet:red&spin=1&spin.speed=20"></iframe>

| Parameter | Example | Description | |-----------|---------|-------------| | variant | setId:variantId | Activate a specific variant on load | | anim | Walk:play | Start an animation clip (play, pause, stop) | | spin | 1 | Enable auto-spin with defaults | | spin.speed | 30 | Auto-spin speed in degrees/second | | spin.delay | 2000 | Inactivity delay in ms before spin resumes | | camPosX/Y/Z | 0, 2, 5 | Initial camera position | | camTgtX/Y/Z | 0, 0, 0 | Initial camera target | | fx.* | fx.exposure=1.2 | Any FxSettings field | | env.* | env.rotY=1.57 | Any EnvSettings field | | hotspots | custom | Initial hotspot UI mode (native, custom, hidden) | | bg | transparent | Render with a transparent canvas background | | skipIntro | 1 | Skip the initial fly-in animation. Loads straight at the saved final camera pose, or at the camera.* coords if provided. |

Transparent background

Set bg=transparent to composite the 3D model over your page with no background colour. Shadows are still rendered via a shadow-only ground plane that floats naturally over whatever is behind the iframe. TAA anti-aliasing is automatically downgraded to SMAA in this mode (TAA's multi-frame accumulation can corrupt the alpha channel). Accumulative shadows keep working as normal.

<iframe
  src="https://combinode.com/e/MODEL_ID?bg=transparent"
  style="background: transparent; border: none; opacity: 0; transition: opacity .32s ease;"
  onload="this.style.opacity='1'"
  allowtransparency="true"
></iframe>

Note: Set style="background: transparent" on the <iframe> element itself — without it some browsers render a white box around the frame even when the viewer canvas is transparent. The opacity/onload pair matters too: browsers show a navigating iframe's placeholder as opaque white regardless of that background — it only composites the framed page's own paint once that page exists — so revealing on load is what actually prevents the white flash before the transparent viewer appears.


Custom hotspot UI example

Use 'custom' mode to render your own hotspot tooltips positioned in screen space:

viewer.hotspots.setUiMode('custom')

viewer.on('hotspot:screenPositions', ({ positions }) => {
  for (const { id, screenX, screenY, behindCamera } of positions) {
    const el = document.getElementById(`hs-${id}`)
    if (!el) continue
    el.style.display = behindCamera ? 'none' : 'block'
    el.style.left = `${screenX}px`
    el.style.top  = `${screenY}px`
  }
})

viewer.on('hotspot:click', ({ hotspotId, title, description }) => {
  showTooltip(hotspotId, title, description)
})

TypeScript

All methods and event payloads are fully typed. Import types directly if needed:

import type { 
  FxSettings, 
  EnvSettings, 
  ObjectTransformState,
  MaterialSettings,
  MeshVisibilityState,
} from 'combinode-embed-api/embed/embedPublicTypes';
import type { 
  CameraStatePayload, 
  VariantSetInfo, 
  EmbedEventType,
  MaterialSetByMeshPayload,
  ObjectSetMeshVisibilityPayload,
} from 'combinode-embed-api/embed/embedProtocol';

Use MaterialSettings type for full type safety when configuring materials:

import type { MaterialSettings } from 'combinode-embed-api/embed/embedPublicTypes';

const materialConfig: Partial<MaterialSettings> = {
  color: '#ff0000',
  metalness: 0.8,
  roughness: 0.2,
};

viewer.materials.setByMesh('part', materialConfig);

React Examples

Basic setup

import { useEffect, useRef, useState } from 'react'
import { ViewerEmbed } from 'combinode-embed-api'

export function Viewer3D({ modelId }: { modelId: string }) {
  const iframeRef = useRef<HTMLIFrameElement>(null)
  // Browsers paint a *navigating* iframe's placeholder as opaque white no
  // matter what background the iframe/framed page declares — that only
  // composites the framed page's own paint once it exists. Stay invisible
  // until `onLoad` (the viewer's own JS shell has mounted and is already
  // showing its loading screen by then) so the white placeholder is never
  // shown — your own page background shows through until then instead.
  const [loaded, setLoaded] = useState(false)

  useEffect(() => {
    if (!iframeRef.current) return
    const viewer = new ViewerEmbed(iframeRef.current, {
      targetOrigin: 'https://combinode.com',
    })

    viewer.ready().then(({ modelName }) => {
      console.log('Loaded:', modelName)
      viewer.autoSpin.enable({ speed: 20 })
    })

    return () => viewer.destroy()
  }, [])

  return (
    <iframe
      ref={iframeRef}
      src={`https://combinode.com/e/${modelId}`}
      onLoad={() => setLoaded(true)}
      style={{
        width: '100%',
        aspectRatio: '16/9',
        border: 'none',
        background: '#16171d',
        opacity: loaded ? 1 : 0,
        transition: 'opacity .32s ease',
      }}
      allow="fullscreen"
    />
  )
}

Material controls example

import { useEffect, useRef, useState } from 'react'
import { ViewerEmbed } from 'combinode-embed-api'
import type { MaterialSettings } from 'combinode-embed-api/embed/embedPublicTypes'

export function MaterialControls({ modelId }: { modelId: string }) {
  const iframeRef = useRef<HTMLIFrameElement>(null)
  const viewerRef = useRef<ViewerEmbed | null>(null)
  const [meshes, setMeshes] = useState<string[]>([])
  const [selectedMesh, setSelectedMesh] = useState<string>('')
  const [color, setColor] = useState<string>('#ffffff')
  // See the reveal-on-load note in the Basic setup example above.
  const [loaded, setLoaded] = useState(false)

  useEffect(() => {
    if (!iframeRef.current) return
    const viewer = new ViewerEmbed(iframeRef.current)
    viewerRef.current = viewer

    viewer.ready().then(async () => {
      const allMeshes = await viewer.objects.getAll()
      setMeshes(allMeshes.map(m => m.name))
      if (allMeshes.length > 0) {
        setSelectedMesh(allMeshes[0].name)
      }
    })

    return () => viewer.destroy()
  }, [modelId])

  const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newColor = e.target.value
    setColor(newColor)
    if (viewerRef.current && selectedMesh) {
      viewerRef.current.materials.setByMesh(selectedMesh, { color: newColor })
    }
  }

  const applyPreset = (preset: Partial<MaterialSettings>) => {
    if (viewerRef.current && selectedMesh) {
      viewerRef.current.materials.setByMesh(selectedMesh, preset)
    }
  }

  return (
    <div style={{ display: 'flex', gap: '20px' }}>
      <iframe
        ref={iframeRef}
        src={`https://combinode.com/e/${modelId}`}
        onLoad={() => setLoaded(true)}
        style={{ flex: 1, aspectRatio: '16/9', border: 'none', background: '#16171d', opacity: loaded ? 1 : 0, transition: 'opacity .32s ease' }}
        allow="fullscreen"
      />
      <div style={{ width: '300px', padding: '20px', background: '#f5f5f5' }}>
        <h3>Material Editor</h3>
        <div>
          <label>Mesh:</label>
          <select value={selectedMesh} onChange={e => setSelectedMesh(e.target.value)}>
            {meshes.map(m => <option key={m} value={m}>{m}</option>)}
          </select>
        </div>
        <div>
          <label>Color:</label>
          <input type="color" value={color} onChange={handleColorChange} />
        </div>
        <div style={{ marginTop: '20px' }}>
          <button onClick={() => applyPreset({ metalness: 0.9, roughness: 0.1 })}>
            Metallic
          </button>
          <button onClick={() => applyPreset({ transmission: 0.95, thickness: 0.5 })}>
            Glass
          </button>
          <button onClick={() => applyPreset({ metalness: 0, roughness: 0.8 })}>
            Plastic
          </button>
        </div>
      </div>
    </div>
  )
}

Mesh visibility and selection example

import { useEffect, useRef, useState } from 'react'
import { ViewerEmbed } from 'combinode-embed-api'

export function PartVisibility({ modelId }: { modelId: string }) {
  const iframeRef = useRef<HTMLIFrameElement>(null)
  const viewerRef = useRef<ViewerEmbed | null>(null)
  // See the reveal-on-load note in the Basic setup example above.
  const [loaded, setLoaded] = useState(false)

  useEffect(() => {
    if (!iframeRef.current) return
    const viewer = new ViewerEmbed(iframeRef.current)
    viewerRef.current = viewer
    return () => viewer.destroy()
  }, [modelId])

  const togglePartVisibility = async (partName: string) => {
    if (!viewerRef.current) return
    const isVisible = await viewerRef.current.objects.getMeshVisibility(partName)
    viewerRef.current.objects.setMeshVisibility(partName, !isVisible)
  }

  const highlightPart = (partName: string) => {
    if (!viewerRef.current) return
    viewerRef.current.objects.selectOutline(partName, true)
  }

  const clearHighlight = (partName: string) => {
    if (!viewerRef.current) return
    viewerRef.current.objects.selectOutline(partName, false)
  }

  return (
    <div>
      <iframe
        ref={iframeRef}
        src={`https://combinode.com/e/${modelId}`}
        onLoad={() => setLoaded(true)}
        style={{ width: '100%', aspectRatio: '16/9', border: 'none', background: '#16171d', opacity: loaded ? 1 : 0, transition: 'opacity .32s ease' }}
        allow="fullscreen"
      />
      <div style={{ padding: '20px' }}>
        <button onClick={() => togglePartVisibility('wheel_fl')}>Toggle Front Left Wheel</button>
        <button onClick={() => highlightPart('door')} onMouseLeave={() => clearHighlight('door')}>
          Highlight Door
        </button>
      </div>
    </div>
  )
}