v1.0.0 Composable 3D for the web

A composable Three.js viewer
with a runtime plugin marketplace.

Mixed 3D Viewer is a headless WebGL engine, a set of optional DOM widgets, and a host runtime that loads plugins on demand. Drop in IFC, point clouds, Gaussian splats, Cesium tiles, floor plans, and panos — then extend the viewer with your own modules, persisted in every snapshot.

Apps
6
Workspace packages
4
Viewer plugins
9
Dashboard widgets
2

What's inside

Six apps, one engine.

The monorepo ships six thin shell apps over a shared engine. Each picks its own UI surface — full authoring, supervision dashboard, embedded consumer, marketplace admin, and two dual-shape pilots that ship as both hosted apps and marketplace plugins from the same source.

  • Authoring /admin/

    Worker

    The full authoring UI — Sidebar, IconToolbar, ViewportOverlay, DropZone, SplitView. Loads every asset type the engine supports, hosts the plugin browser, and saves snapshots. The only app that mutates content.

    Open worker
  • Preview /dashboard/

    Dashboard

    A grid of viewers, each cell hydrating a saved snapshot. No drop zone, no editing — it consumes what the worker produced. Plugin host mounts per cell so snapshot extensions auto-install.

    Open dashboard
  • Pilot /mic/

    MIC Progress

    A downstream consumer building its own viewer-hosting app. Sidebar + DropZone only — no toolbar, no overlay. The reference for a minimum viable host extended entirely through a plugin.

    Open MIC
  • Admin /marketplace/

    Marketplace

    The plugin admin UI. Upload a manifest.json + index.js (+ optional screenshot); marketplace writes them to /plugins/ via WebDAV and rewrites the registry.

    Open marketplace
  • Dual-shape /slope/

    Surface Tool

    1-click local dip + dip-direction on point clouds and splats — basic PCA-plane or advanced Delaunay-patched facets with orientation ramps, brush/lasso picks, and Fisher's K. Same TS source ships as the slope.tool.v1 marketplace plugin.

    Open Surface Tool
  • Dual-shape /volume/

    Volume Calc

    Earthwork cut / fill / net m³ between two point clouds or splats. Rasterises both into a shared DSM, integrates ΔH per cell, drapes a colour-coded heatmap over the baseline. Same TS source ships as the volume.calc.v1 marketplace plugin.

    Open Volume Calc

Architecture

Three layers, sharp boundaries.

Apps are hosts. Packages are reusable libraries. Plugins are runtime-loadable ES modules. Each layer can be swapped or skipped independently.

apps/          # thin shells — pick which UI to mount
   
   
packages/      # engine + DOM widgets + plugin host
   
   
/plugins/<id>/ # runtime-loadable ESM, served via WebDAV

@ais/viewer-core

Engine

The headless Three.js stack. Owns scene, camera, renderer, controls, the named-Group LayerManager, every data loader, every tool, and the ViewerPlugin contract. No DOM beyond the canvas host — engine writes bytes to GL, nothing else.

@ais/viewer-ui

Optional widgets

DOM adapters — Sidebar, IconToolbar, ViewportOverlay, DropZone, SplitView. Each is self-contained and mounted via viewer.attachUI(adapter, key). Apps cherry-pick the widgets they need.

@ais/plugin-host

Runtime loader

Reads /plugins/registry.json, dynamic-imports plugin ES modules, validates manifest engines against the live viewer-core version, then calls viewer.use(plugin). Installed plugins persist in localStorage across reloads.

@ais/dashboard-widgets

Non-3D cell contract

The parallel surface for the supervision dashboard. Same /plugins/ storage, discriminated by manifest.kind === 'dashboard-widget'. Builtins ship a viewer-snapshot cell that re-hosts a viewer, plus CCTV / chart / record-list / image scaffolds.

Plugin architecture

A plugin is just a module.

Four methods, one snapshot key. Plugins reach into viewer.assets, viewer.layers, and viewer.ui.sidebar directly — there's no event bus and no message broker between them and the engine.

packages/viewer-core/src/Plugin.ts
export interface ViewerPlugin {
  readonly id: string;
  readonly label?: string;
  install(viewer: Viewer): void | Promise<void>;
  uninstall?(viewer: Viewer): void;
  getState?(): unknown;
  applyState?(state: unknown): void | Promise<void>;
}
  • Snapshot-persisted

    getState() rides along in snapshot.extensions[plugin.id]. Unknown ids round-trip untouched — a dashboard cell renders a "plugin required" hint instead of losing data.

  • Same source, two shapes

    Author once. Embed in a host app at compile time for fast iteration, or bundle with three / viewer-core / viewer-ui externalised and publish to /plugins/ for runtime hot-load.

  • UI through a single surface

    Plugins contributing UI call viewer.ui.sidebar.addPanel({ id, title, render, actions }). No reaching into internal DOM — the panel surface is part of the public API.

  • Reference pilot: /mic/

    MICProgressPlugin is the canonical example — same TS source embedded in apps/mic-progress and published as a runtime bundle in the marketplace.

FAQ

Common questions.

Five things people ask before they open a viewer or write a plugin.

  • What's the difference between an app and a plugin?

    An app is the host page — it owns the canvas, instantiates a Viewer, and picks which UI widgets to mount (Sidebar, IconToolbar, DropZone, etc.). A plugin is a ViewerPlugin module loaded into a host's Viewer at runtime via viewer.use().

    The same TS source can do both: embedded in an app at compile time for fast iteration, and bundled with externals for hot-load through the marketplace. The apps/mic-progress shell and the published mic.progress.v1 plugin are the same source in two shapes.

  • How do I publish a plugin to the marketplace?
    1. Build your ViewerPlugin class as a Vite library with three, @ais/viewer-core, and @ais/viewer-ui marked external in rollupOptions.
    2. Default-export a factory: export default () => new MyPlugin();
    3. Write a manifest.json with id, version, entryUrl, and an engines range against @ais/viewer-core.
    4. Open /marketplace/, expand the Admin band at the bottom, drop the manifest + index.js (+ optional screenshot), publish.

    The form PUTs everything to /plugins/<id>/<version>/ via WebDAV and rewrites registry.json. Refresh the worker's Extensions panel to install.

  • Can my plugin use third-party npm libraries?

    Yes. Anything from node_modules other than three, @ais/viewer-core, and @ais/viewer-ui is bundled into your index.js. proj4, chart.js, d3-scale, custom utility libs — all fair game, all inlined.

    The tradeoff: each plugin carries its own copy of any dep it imports. Tree-shake aggressively (named imports, not whole namespaces). If a heavy library starts feeling like every plugin will need it, the right move is to add it to viewer-core's public API rather than ship five copies through the marketplace.

  • What happens if I open a snapshot whose plugin isn't installed?

    The snapshot still loads. Plugin state lives under snapshot.extensions[plugin.id], and Snapshot.apply skips applyState for plugins that aren't currently installed but preserves the state untouched in the file.

    The dashboard renders a "plugin available" hint where the extension would have rendered, and re-saving keeps the extension entry intact for the next viewer that does have it installed. Snapshots are forward-compatible by design.

  • Can I self-host this on my own infrastructure?

    Yes — self-hosting is the default deploy. The whole stack is a static site behind nginx: four Vite-built apps at /, /admin/, /dashboard/, /mic/, /marketplace/, plus two WebDAV-enabled buckets at /files/ (user assets) and /plugins/ (plugin bundles).

    No backend services, no databases, no managed cloud dependencies. Build with pnpm build, rsync each app's dist/ into its nginx alias, and you're running. Drop basic auth wherever you want it — the marketplace currently sits behind site-wide auth in v1.

Open it up.

Everything's behind a single deploy. Drop an IFC into the worker, save a snapshot, view it from the dashboard, then write a plugin and publish it through the marketplace.