Headless Renderer

@simplio3d/viewer renders a project’s 3D scene and nothing else — no swatches, no dropdowns, no sidebar, no price, no form. You build the UI in your own framework and drive the scene imperatively. Use it when the hosted iframe is too rigid and the REST API leaves you with no renderer.

This is a separate package from the SDK. @simplio3d/sdk is a data client (projects, blocks, pricing, quotes). @simplio3d/viewer is the renderer. They are independent — the viewer needs no SDK, and it needs no credential at all: it reads the public GET /share/:projectId/:token endpoint. Never put an API token in browser code — it is owner-scoped across every project, billing-gated, and auto-rotated 90 days after a billing lapse.

Availability — not yet on the public npm registry. npm install @simplio3d/viewer and the jsDelivr/unpkg URLs below currently return 404. Until the package is published, get the built bundle from the platform repository at packages/viewer/dist/simplio3d-viewer.standalone.js, serve it from your own origin, and import it by relative path — every other line of the examples below is unchanged. Vendoring also pins the exact build you tested.

Install (once published)

npm install @simplio3d/viewer three

# NOT YET PUBLISHED — this returns 404 today. Vendor dist/simplio3d-viewer.standalone.js
# from the platform repo instead, and import it by relative path.

# three is a PEER dependency, deliberately. If the package bundled it, a host
# that already runs three would get two copies: instanceof fails across the
# boundary, THREE.Cache splits, disposal leaks — and none of it throws.

Render a project

import { createViewer } from '@simplio3d/viewer';

const viewer = await createViewer({
  el: '#stage',              // element or CSS selector
  projectId: 'proj_123',
  shareToken: 'e060a524…',   // from the project's Share dialog
});

// Your UI, your framework. Build it from viewer.blocks (authored order).
viewer.select('blk_frame', 'walnut');

No bundler (script tag)

<!-- TODAY: vendor the bundle and serve it from your own origin. -->
<script type="module">
  import { createViewer } from './vendor/simplio3d-viewer.standalone.js';
  await createViewer({ el: '#stage', projectId, shareToken });
</script>

<!-- ONCE PUBLISHED, the same file is reachable from a CDN — 404 until then:
  import { createViewer } from 'https://cdn.jsdelivr.net/npm/@simplio3d/viewer/dist/simplio3d-viewer.standalone.js';
-->

<!-- The standalone build BUNDLES three (~268 kB gzip), because three ships no
     UMD build and a plain HTML page cannot resolve a bare specifier.
     If your page already loads three, use dist/index.js with an import map
     instead so you do not end up with two copies. -->

Build your own UI

// viewer.blocks is the authored order — never sort it.
// viewer.visibility is the result of conditional-logic evaluation.
for (const block of viewer.blocks) {
  if (viewer.visibility[block.id] === false) continue;
  for (const variant of block.dropdownVariants ?? []) {
    renderSwatch(variant.label, () => viewer.select(block.id, variant.value));
  }
}

// Re-render your controls when a rule shows or hides a block.
viewer.on('applied', () => rebuildUI());

Selection shapes

viewer.getState();
// {
//   'blk_frame':  'walnut',                  // dropdown, select-material,
//                                            // thumbnail-selector, toggle-switch, carousel
//   'blk_extras': ['armrests', 'headrest'],  // checkbox — chosen values
//   'blk_size':   { width: 120, height: 40 } // number-input — keyed by NUMERAL VARIANT id
// }

// number-input is a MAP, not a number: one block can expose several numeric
// parameters, and conditional rules target a specific one.

// setState is ATOMIC — use it to restore a saved configuration. A loop of
// select() calls re-applies after each one and is order-dependent.
viewer.setState(savedConfiguration);

Screenshots

// Returns a high-resolution, TRANSPARENT PNG Blob.
// This is what fills configuration.screenshotUrl on a quote submission.
const blob = await viewer.snapshot();

const form = new FormData();
form.append('screenshot', blob, 'configuration.png');

Camera

viewer.camera.reset();                       // back to the project's saved camera
viewer.camera.frame();                       // fit the whole product
viewer.camera.set([3, 2, 5], [0, 0.5, 0]);   // position, target

Events

const off = viewer.on('option.changed', (d) => console.log(d.blockId, d.value));
off(); // unsubscribe

// 'ready' | 'progress' | 'option.changed' | 'applied' | 'visibility.changed' | 'error'

// NOTE: 'progress', model-load 'error's, the first 'applied' and 'ready' all fire
// BEFORE createViewer resolves, so viewer.on() cannot see them. Pass callbacks in:
await createViewer({
  el, projectId, shareToken,
  onProgress: ({ loaded, total }) => setBar(loaded / total),
  onError: (err) => console.warn(err.message),
});

Escape hatch — the live three.js objects

const { scene, camera, renderer, controls, parts } = viewer.three;

// Add a floor, a custom light rig, post-processing, a second camera…
scene.add(myFloor);

// parts is a name → meshes index. One name can map to SEVERAL meshes:
// duplicate mesh names across subgroups are intentional on this platform,
// which is why scene.getObjectByName() (first match only) under-applies.
console.log(parts.get('Leg')?.length);

Teardown

viewer.dispose();
// Releases the WebGL context, listeners, geometries and materials.
// Safe to mount/unmount repeatedly — without forceContextLoss a browser
// runs out of WebGL contexts after ~16 mounts and later ones render nothing.

Owner billing lapse (HTTP 402)

import { createViewer, ShareFetchError } from '@simplio3d/viewer';

try {
  await createViewer({ el, projectId, shareToken });
} catch (err) {
  if (err instanceof ShareFetchError && err.isUnavailable) {
    // The project OWNER's billing lapsed. Show a neutral message.
    // Do NOT retry, and do NOT surface billing detail to a shopper.
    showUnavailable();
  }
}

What it deliberately does not do

  • No UI. That is the entire point — you own every pixel.
  • No viewport chrome either. No floating tools (configuration summary, AR, show dimensions, reset camera, take snapshot, reset configuration), no watermark, no custom loading animation, no brand logo. The package appends a <canvas> and nothing else.
  • Only a subset of project settings. It reads about 16 of the ~168 project settings — lighting, exposure, camera, background, auto-rotate, shadows, zoom and pan limits. The watermark, loading, AR, floating-tool, branding-accent and preview-language groups are not read, and a newly added platform setting does not reach the package automatically.
  • No pricing. POST /pricing-blocks/calculate already prices authoritatively, server-side. viewer.pricingBlocks gives you the data to send.
  • No form handling or submission.
  • No modular module placement. Modular projects render their base scene, but the drag-and-drop snap system is not included in this release.
  • Five block types store but do not paint. select() on atext-input, file-upload, design-canvas,pattern-designer or hotspot block records the value and emitsoption.changed, but changes nothing on screen. Number-input dimension scaling is likewise not applied.

The other route: the clean viewer embed

If what you want is “the viewer with all the settings the merchant already configured, plus the floating tools, and I’ll add my own option buttons”, embed the hosted clean viewer instead — the real share view with the sidebar, price, form and cart suppressed. It honours every project setting, ships the complete localised floating-tool overlay, and supports modular and AR.

<iframe
  src="https://YOUR_APP_DOMAIN/share/PROJECT_ID/SHARE_TOKEN?ui=viewer"
  width="100%"
  height="600"
  frameborder="0"
  allowfullscreen
  allow="accelerometer; autoplay; camera; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking"
></iframe>

Use this package instead when you need the canvas inside your own DOM, a synchronous API, or the three escape hatch. One current trade-off runs the other way: the package can be driven from your UI today via select() and setState(), while the clean viewer embed is display-only until its postMessage bridge ships.

Continue reading