Skip to main content

Viewer Plugin Development

This guide covers the VAMS Visualizer Plugin System, a modular architecture for file viewers that supports 17 viewer plugins for 3D models, point clouds, media, documents, and data formats.

Architecture Overview

The plugin system uses a configuration-driven approach with lazy loading. No core code changes are needed to add new viewers.

Core Components

ComponentFilePurpose
PluginRegistrycore/PluginRegistry.tsSingleton that manages all viewer plugins
viewerConfig.jsonconfig/viewerConfig.jsonJSON configuration for all plugins
manifest.tsviewers/manifest.tsVite static analysis paths for dynamic imports
StylesheetManagercore/StylesheetManager.tsPer-plugin CSS lifecycle management
DynamicViewercomponents/DynamicViewer.tsxMain viewer component for rendering
ViewerSelectorcomponents/ViewerSelector.tsxUI for choosing between compatible viewers

How It Works

  1. Initialization -- PluginRegistry.initialize() reads viewerConfig.json and registers metadata for all enabled plugins. No components are loaded at this stage.
  2. Extension Matching -- getCompatibleViewers(extensions, isMultiFile) returns metadata for all plugins that support the given file extensions.
  3. On-Demand Loading -- loadPlugin(pluginId) dynamically imports the React component and optional dependency manager when a viewer is selected.
  4. Cleanup -- unloadPlugin(pluginId) removes the component, cleans up dependencies, and removes plugin stylesheets.

Current Viewers

VAMS ships with 17 viewer plugins across five categories: 3D, Media, Document, Data, and Preview.

For the complete list of all viewers, supported extensions, priority resolution, and extension-to-viewer mapping, see File Viewers.

Priority System

When multiple viewers support the same file extension, the viewer with the lowest priority number is preferred. For example, .ply files match both BabylonJS Gaussian Splat (priority 1) and PlayCanvas Gaussian Splat (priority 2).

Creating a New Viewer Plugin

Adding a new viewer requires three to five steps, with no changes to core system code.

Step 1: Create the Viewer Component

Create a directory and component file under src/visualizerPlugin/viewers/:

viewers/MyViewerPlugin/
MyViewerComponent.tsx # React component (required)
dependencies.ts # Dependency loader (optional)
MyViewer.module.css # Scoped styles (optional)

Implement the ViewerPluginProps interface:

import React, { useEffect, useRef, useState } from "react";
import { ViewerPluginProps } from "../../core/types";
import { downloadAsset } from "../../../services/APIService";

const MyViewerComponent: React.FC<ViewerPluginProps> = ({
assetId,
databaseId,
assetKey,
multiFileKeys,
versionId,
viewerMode,
onViewerModeChange,
onDeletePreview,
isPreviewFile,
viewerConfig,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const loadFile = async () => {
if (!assetKey) return;
try {
setLoading(true);
const response = await downloadAsset({
assetId,
databaseId,
key: assetKey,
versionId: versionId || "",
downloadType: "assetFile",
});
if (response !== false && Array.isArray(response) && response[0] !== false) {
// Use response[1] as the presigned URL
initializeViewer(containerRef.current, response[1]);
}
} catch (err: any) {
setError(err?.message || "Failed to load file");
} finally {
setLoading(false);
}
};
loadFile();

return () => {
// Cleanup viewer resources on unmount
};
}, [assetId, assetKey, databaseId, versionId]);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;

return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
};

export default MyViewerComponent;

Step 2: Add to the Manifest

Update src/visualizerPlugin/viewers/manifest.ts with the component path:

export const VIEWER_COMPONENTS = {
// ... existing entries
"./viewers/MyViewerPlugin/MyViewerComponent": "MyViewerPlugin/MyViewerComponent",
} as const;

If your viewer has a dependency manager, also add it:

export const DEPENDENCY_MANAGERS = {
// ... existing entries
"./viewers/MyViewerPlugin/dependencies": "MyViewerPlugin/dependencies",
} as const;

Step 3: Add Configuration

Add an entry to src/visualizerPlugin/config/viewerConfig.json:

{
"id": "my-viewer",
"name": "My Custom Viewer",
"description": "Description of what this viewer does",
"componentPath": "./viewers/MyViewerPlugin/MyViewerComponent",
"supportedExtensions": [".xyz", ".custom"],
"supportsMultiFile": false,
"canFullscreen": true,
"priority": 1,
"dependencies": [],
"loadStrategy": "lazy",
"category": "3d",
"enabled": true
}

Step 4 (Optional): Create a Dependency Manager

If your viewer requires external libraries, create a dependency manager:

// viewers/MyViewerPlugin/dependencies.ts
import { StylesheetManager } from "../../core/StylesheetManager";

export class MyDependencyManager {
private static loaded = false;
private static readonly PLUGIN_ID = "my-viewer";

static async loadDependencies(): Promise<void> {
if (this.loaded) return;

// Load stylesheets
await StylesheetManager.loadStylesheet(this.PLUGIN_ID, "/path/to/styles.css");

// Load external libraries
const MyLib = await import("my-library");
(window as any).MyLib = MyLib;

this.loaded = true;
}

static cleanup(): void {
StylesheetManager.removePluginStylesheets(this.PLUGIN_ID);
this.loaded = false;
}
}

When using a dependency manager, add the configuration fields to viewerConfig.json:

{
"dependencyManager": "./viewers/MyViewerPlugin/dependencies",
"dependencyManagerClass": "MyDependencyManager",
"dependencyManagerMethod": "loadDependencies",
"dependencyCleanupMethod": "cleanup"
}

Step 5 (Optional): Custom Install Script

If your viewer requires build-time dependency installation beyond standard npm packages, create a custom install script:

  1. Create a directory: web/customInstalls/myviewer/
  2. Add an install script (typically install.js or install.sh)
  3. Add the script to the postinstall chain in web/package.json
Existing Install Scripts

Review existing custom install scripts in web/customInstalls/ for patterns. Viewers like Three.js, CesiumJS, and Potree all have custom install directories.

Plugin Configuration Fields

FieldTypeRequiredDescription
idstringYesUnique plugin identifier
namestringYesDisplay name in the viewer selector UI
descriptionstringYesDescription shown to users
componentPathstringYesPath for manifest lookup (must match manifest.ts key)
supportedExtensionsstring[]YesFile extensions this viewer handles (e.g., [".obj", ".fbx"])
supportsMultiFilebooleanYesWhether the viewer can handle multiple files simultaneously
canFullscreenbooleanYesWhether the viewer supports fullscreen mode
prioritynumberYesSelection priority (lower = preferred when multiple viewers match)
dependenciesstring[]YesRequired library names (informational)
loadStrategystringYes"lazy" (load on demand) or "eager" (load at startup)
categorystringYesViewer category: 3d, media, document, data, or preview
enabledbooleanYesWhether the plugin is active
dependencyManagerstringNoPath to the dependency manager module
dependencyManagerClassstringNoClass name exported by the dependency module
dependencyManagerMethodstringNoStatic method to call for loading dependencies
dependencyCleanupMethodstringNoStatic method to call for cleanup
featuresEnabledRestrictionstring[]NoRequired feature flags (all must be enabled)
isPreviewViewerbooleanNotrue for the preview-only fallback viewer
requiresPreprocessingbooleanNotrue if the viewer needs a preprocessing pipeline
customParametersobjectNoViewer-specific configuration passed to the component

Dependency Chain Loading

The PluginRegistry loads dependencies using configuration-driven method calls:

// 1. Registry reads config
const { dependencyManagerClass, dependencyManagerMethod } = plugin.config;

// 2. Imports the dependency module dynamically
const depModule = await import("../viewers/MyViewerPlugin/dependencies");

// 3. Calls the specified class method
const depClass = depModule[dependencyManagerClass];
await depClass[dependencyManagerMethod]();

// 4. On cleanup, calls the cleanup method
await depClass[dependencyCleanupMethod]();

This pattern means the PluginRegistry contains zero plugin-specific code. All behavior is driven by viewerConfig.json.

StylesheetManager

The StylesheetManager manages CSS lifecycle for viewer plugins, preventing style conflicts and memory leaks.

import { StylesheetManager } from "../../core/StylesheetManager";

// Load a stylesheet for a plugin
await StylesheetManager.loadStylesheet(pluginId, href);

// Load multiple stylesheets
await StylesheetManager.loadStylesheets(pluginId, [href1, href2]);

// Remove all stylesheets for a plugin
StylesheetManager.removePluginStylesheets(pluginId);

// Get statistics about loaded stylesheets
const stats = StylesheetManager.getStats();

// Complete cleanup of all managed stylesheets
StylesheetManager.cleanup();

Stylesheets are automatically removed when unloadPlugin() is called, preventing CSS accumulation over time.

Feature Flag Restrictions

Viewer plugins can be restricted based on deployment feature flags using the featuresEnabledRestriction configuration field.

How It Works

During plugin registration, the PluginRegistry checks each plugin's featuresEnabledRestriction against the application's enabled features (loaded from /api/secure-config). Plugins with unmet feature requirements are silently excluded.

Example: CesiumJS Viewer

The CesiumJS viewer requires the ALLOWUNSAFEEVAL feature flag because CesiumJS uses dynamic code execution for WebGL shader compilation:

{
"id": "cesium-viewer",
"featuresEnabledRestriction": ["ALLOWUNSAFEEVAL"],
"customParameters": {
"cesiumIonToken": ""
}
}

To enable this viewer, set app.webUi.allowUnsafeEvalFeatures to true in the CDK config.json.

Available Feature Flags

FlagEffect on Viewers
ALLOWUNSAFEEVALEnables CesiumJS and Needle USD viewers (require unsafe-eval CSP directive)
LOCATIONSERVICESCan be used to gate geospatial viewers

Multiple Requirements

When multiple features are specified, all must be enabled:

{
"featuresEnabledRestriction": ["ALLOWUNSAFEEVAL", "LOCATIONSERVICES"]
}

ViewerPluginProps Interface

All viewer components receive these props:

interface ViewerPluginProps {
assetId: string; // Asset identifier
databaseId: string; // Database identifier
assetKey?: string; // Single file S3 key
multiFileKeys?: string[]; // Multiple file S3 keys
versionId?: string; // File version
viewerMode: string; // Display mode ("wide", "fullscreen")
onViewerModeChange: (mode: string) => void;
onDeletePreview?: () => void; // Callback to delete preview
isPreviewFile?: boolean; // Whether this is a preview file
viewerConfig?: any; // Plugin-specific customParameters
}

Custom Parameters

Viewers can access custom parameters from their configuration:

const MyViewer: React.FC<ViewerPluginProps> = ({ viewerConfig }) => {
const apiKey = viewerConfig?.cesiumIonToken;
const enableXR = viewerConfig?.enableXR ?? true;
// Use parameters to configure the viewer
};

DynamicViewer and ViewerErrorBoundary

The DynamicViewer component handles the full lifecycle of displaying a viewer:

import { DynamicViewer } from "./visualizerPlugin";

<DynamicViewer
files={files}
assetId="asset-123"
databaseId="db-456"
viewerMode="wide"
onViewerModeChange={setViewerMode}
/>;

DynamicViewer automatically:

  • Determines compatible viewers based on file extensions
  • Shows a ViewerSelector if multiple viewers are available
  • Loads the selected viewer's component on demand
  • Wraps the viewer in a ViewerErrorBoundary for graceful error handling
  • Cleans up resources when switching viewers or unmounting

Troubleshooting

Plugin Not Appearing

  1. Check that enabled is true in viewerConfig.json
  2. Verify the component path exists in manifest.ts
  3. Check the browser console for feature restriction messages
  4. Confirm required feature flags are enabled in the deployment configuration

Component Not Found Error

  1. Ensure the componentPath in viewerConfig.json matches a key in manifest.ts
  2. Verify the component file has a export default statement
  3. Check for typos in the manifest constants

Dependency Loading Issues

  1. Verify the dependency manager path exists in DEPENDENCY_MANAGERS in manifest.ts
  2. Confirm dependencyManagerClass and dependencyManagerMethod in the config match the actual export names
  3. Check the browser console for import errors

Next Steps