3D Viewer

Embed and control the 3D viewer programmatically. Configure camera, lighting, environment, manage viewer state, take screenshots, and respond to user interactions via callbacks.

Get Embed Configuration

GET/projects/:id/viewer/embed-config
const response = await fetch(BASE_URL + '/projects/proj_123/viewer/embed-config', {
  headers: { 'Authorization': 'Bearer ' + accessToken }
});

const data = await response.json();
// {
//   "success": true,
//   "config": {
//     "projectId": "proj_123",
//     "cameraPosition": [0, 2, 5],
//     "cameraTarget": [0, 0, 0],
//     "cameraFOV": 50,
//     "enableZoom": true,
//     "enablePan": true,
//     "enableAutoRotate": false,
//     "environmentPreset": "studio",
//     "lightIntensity": 1.0,
//     "ambientLightIntensity": 0.5,
//     "enableShadows": true,
//     "backgroundColor": "#f5f5f5",
//     "enableAR": false,
//     "loadingAnimation": "spinner",
//     "loadingMessage": "Loading 3D model..."
//   }
// }

Embed via iframe

The simplest way to embed a 3D viewer is the share URL in an iframe. The Share tab in the editor generates this snippet with your token already filled in — copying it from there is the safest route.

<!-- The share view is a page on YOUR APP DOMAIN.
     It is not the Supabase Functions host used for API calls — that host
     returns JSON, so pointing an iframe at it renders a broken embed. -->
<iframe
  src="https://YOUR_APP_DOMAIN/share/proj_123/SHARE_TOKEN"
  width="100%"
  height="600"
  frameborder="0"
  allowfullscreen
  allow="accelerometer; autoplay; camera; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking"
></iframe>

<!-- Transparent embeds: color-scheme is load-bearing.
     If the host page declares color-scheme light or dark, Chromium paints an
     opaque backdrop behind the iframe and the embed looks like a solid box.
     Setting background:transparent alone does NOT fix it. -->
<iframe
  src="https://YOUR_APP_DOMAIN/share/proj_123/SHARE_TOKEN"
  width="100%"
  height="600"
  frameborder="0"
  allowtransparency="true"
  style="background: transparent; color-scheme: normal;"
  allowfullscreen
  allow="accelerometer; autoplay; camera; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking"
></iframe>

The share view reads three query parameters: ?c= to open a saved configuration, and ?ar=1 / ?ars= for the AR flow. Appearance is controlled by the project’s own settings, not by URL parameters.

Headless Viewer Integration

For a fully custom UI you own the controls and the platform draws the product. There are two ways to do that, and the first is almost always the right one.

Recommended: @simplio3d/viewer — keep your UI, keep our renderer

@simplio3d/viewer is a headless-UI renderer: it draws the configured product and nothing else — no swatches, no sidebar, no price, no form. You build the UI; viewer.select(blockId, value) drives the scene. It reads the same public share endpoint documented below and needs no credential.

Not yet published to npmnpm install @simplio3d/viewer three returns 404 today. Vendor packages/viewer/dist/simplio3d-viewer.standalone.js from the platform repository and import it by relative path; the API is identical either way.

Writing your own renderer against the JSON is supported, but the render’s correctness depends on behaviour the payload does not describe — the material dictionary is keyed by _materialRef (not materialId), envMapIntensity is always recomputed and the stored value ignored, every texture needs flipY = false, materials must be cloned so baked AO survives, one mesh name can target several meshes, and a fixed-material block with visible: false still paints. Getting any of them wrong diverges from the merchant’s dashboard silently. See SDK → Headless Renderer.

Prefer the public share endpoint for the read

GET /share/:projectId/:token is the recommended headless read. It returns each model with a pre-signed storage URL your client fetches directly, decompresses the scene server-side (so you never handle the large-project variants), and needs only the public anon key — no user session and no API token. GET /projects/:id is the authoring read: it returns raw stored models with no download URL and may hand you a gzipped scene you have to expand yourself. Full response schema in Project Sharing.

// 1. Load project data with all option blocks and settings
const projectRes = await fetch(BASE_URL + '/projects/proj_123', {
  headers: { 'Authorization': 'Bearer ' + accessToken }
});
const { project } = await projectRes.json();

// The scene always lives under project.data — feature-detect the two variants.
// gunzipBase64 is the small DecompressionStream helper defined in the
// "Get Project" example above (use pako on React Native / Hermes).
const payload = project.data;
const sceneData = payload.sceneDataCompressed
  ? JSON.parse(await gunzipBase64(payload.sceneDataCompressed))   // 'gzip-base64'
  : payload.sceneData;

// 2. Load option blocks for the configurator UI
const blocksRes = await fetch(BASE_URL + '/projects/proj_123/option-blocks', {
  headers: { 'Authorization': 'Bearer ' + accessToken }
});
const { blocks } = await blocksRes.json();

// 3. Load pricing blocks for CPQ
const pricingRes = await fetch(BASE_URL + '/projects/proj_123/pricing-blocks', {
  headers: { 'Authorization': 'Bearer ' + accessToken }
});
const { blocks: pricingBlocks } = await pricingRes.json();

// 4. Build your custom UI with any framework
// - models/camera are NOT reliably top-level: in the COMPRESSED variant
//   project.data holds ONLY sceneDataCompressed + sceneDataEncoding, so read
//   them from the decompressed scene first:
//     const models = sceneData?.models ?? payload.models ?? [];
//     const camera = sceneData?.camera ?? payload.camera;
// - Each model entry has id + fileName; this endpoint does NOT return a
//   download URL — fetch the binary from GET /assets/:assetId/download, or use
//   the share endpoint, which hands you a pre-signed url per model
// - Frame the camera from the resolved camera value above, if present
// - Render sceneData.optionBlocks as custom components
// - Apply material/visibility changes based on selections
// - Calculate prices in real-time via /pricing-blocks/calculate

Viewer State

A per-project scratchpad you write and read yourself — useful for persisting a configuration between sessions or devices. It is not populated automatically: the configurator never writes to it, so a GET before your first PUT returns the empty default below. It does not reflect a live viewer session.

PUT/projects/:id/viewer/state
GET/projects/:id/viewer/state
// Store whatever shape suits your integration.
await fetch(BASE_URL + '/projects/proj_123/viewer/state', {
  method: 'PUT',
  headers: {
    'Authorization': 'Bearer ' + accessToken,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    selections: { blk_frame: 'black', blk_fabric: 'linen' },
    cameraPosition: [0, 2, 5]
  })
});

// Read it back.
const response = await fetch(BASE_URL + '/projects/proj_123/viewer/state', {
  headers: { 'Authorization': 'Bearer ' + accessToken }
});
const data = await response.json();
// { "success": true, "state": { ...exactly what you PUT... } }
//
// Never written to? You get the default:
// { "success": true, "state": { "loaded": false, "selections": {},
//   "visibleBlocks": [], "cameraPosition": [0,2,5], "cameraTarget": [0,0,0] } }

Viewer events

The share view broadcasts three window CustomEvents. These are the supported integration points — bind to them rather than to the page’s internal markup, which changes between releases.

// Runs INSIDE the configurator document (see the scope note below).

window.addEventListener('simplio3d:option.changed', (e) => {
  const { blockId, blockName, value, label, type } = e.detail;
  // type is one of: dropdown | carousel | checkbox | toggle-switch |
  //                 thumbnail-selector | select-material | number-input
  //
  // `value` shape depends on `type`:
  //   checkbox      -> string[]                 (selected values)
  //   number-input  -> Record<string, number>   (named numeric values)
  //   everything else -> a single value
  //
  // `label` is a customer-facing name only for select-material;
  // for the other types it mirrors `value`.
});

// Both fire together on the same successful submission — subscribe to ONE.
window.addEventListener('simplio3d:quote.submitted', (e) => {
  const { requestId, formData } = e.detail;
});
window.addEventListener('simplio3d:form.submitted', (e) => {
  const { requestId, formData } = e.detail;
});

Scope and current limits

  • These are window CustomEvents, not postMessage. They do not cross an iframe boundary, so a parent page embedding the configurator in an <iframe> cannot receive them. They are reachable from a native WebView host (where the configurator is the top-level document) by injecting a listener script and forwarding to your native bridge.
  • There is no inbound channel. Setting a selection, moving the camera or requesting a screenshot from a host page is not part of the supported interface today. To open the configurator in a specific state, use a saved-configuration permalink (?c=<savedConfigId>).
  • There is no price event. Price is rendered inside the viewer; it is not broadcast. Derive it from your own catalogue if you need it outside the configurator.
  • Coverage. option.changed is emitted by the seven selectable option types listed above. Modular module placement, text, artwork-upload, pattern and design-canvas inputs do not currently emit it.

Building a mobile app? See the Mobile & native apps guide.

Continue reading