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
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 npm — npm 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/calculateViewer 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.
// 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
windowCustomEvents, notpostMessage. 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.changedis 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.
