@bubo-squared/miraj
v0.1.2
Published
Developer friendly Gaussian Splatting viewer for eCommerce
Readme
miraj
Developer friendly Gaussian Splatting viewer for eCommerce
Example usage
Creating the viewer instance
const viewer = new MirajViewer('viewerContainer');Loading a file from URL
viewer.load('/example_splat.ply', FileFormat.PLY);Loading a Bubo² hosted model
Managed loading loads models hosted by Bubo², but also includes automatic loading of markers and viewer camera position and constraints.
// Load the smallest available variant
viewer.loadManaged('5hxx8e');
// or load a different variant, e.g. the high quality
viewer.loadManaged('5hxx8e', variant='high');Viewer configuration
Viewer configuration controls many aspects of the viewer behavior and can be provided at initialization or updated at any time. For details on all available configuration options and their effects consult the Configuration options section or type documentation.
// Configuration can be provided at initialization
const viewer = new MirajViewer('canvasContainer', {
enableLoadingSpinner: false,
enableLoadingBar: false,
});
// or updated later, updates will only affect provided options
// i.e. enableLoadingSpinner remains false after this update
viewer.updateConfig({
enableLoadingBar: true
});
// setting `reset` to `true` will reset config to default values
// and apply the provided changes on top
// enableLoadingBar will set to provided value (false)
// while everything else (including api_url) would be reset to defaults
viewer.updateConfig({
enableLoadingBar: false,
}, true);Note on MirajViewer.loadManged
It's important to note that loadManaged loads all cameraControllerConfig config options from the server, and applies those changes on top of existing config.
This is done because the options like intial camera position, camera constraints, etc. are usually set per model in Bubo² web application.
Thus, any programmatic config changes to those properties should be done after loadManaged starts loading the model.
// if we set any `cameraControllerConfig` options prior to calling `loadManaged`
viewer.updateConfig({
cameraControllerConfig: {
distance: 50,
}
});
// prints 50
console.log(viewer.config.cameraControllerConfig?.distance);
await viewer.loadManaged('9pcqOx');
// those options will be overwritten by the values associated with the loaded model
// prints value fetched from server
console.log(viewer.config.cameraControllerConfig?.distance);Another thing to note is that loadManaged is an async function which will resolve once the config and markers have been loaded from the server, and the model file loading started. Thus any cameraControllerConfig options set before loadManaged resolves could be overwritten.
// without await, updateConfig will execute before loadManaged
// fetches model associated config from the server, and thus
// config will be overwritten with the server
viewer.loadManaged('9pcqOx').then(() => {
// prints value fetched from server (second)
console.log(viewer.config.cameraControllerConfig?.distance);
});
viewer.updateConfig({
cameraControllerConfig: {
distance: 50,
}
});
// prints 50 (first)
console.log(viewer.config.cameraControllerConfig?.distance);The correct approach is to update configs after loadManaged is done.
// use either `.then`, or `await`
viewer.loadManaged('9pcqOx').then(() => {
// config is now updated with values from the server
// and we can override distance
viewer.updateConfig({
cameraControllerConfig: {
distance: 50,
}
});
// prints 50
console.log(viewer.config.cameraControllerConfig?.distance);
});Configuration Options Table
MirajConfig
Configuration options for the MirajViewer:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiUrl | string | 'https://api.bubo2.com/api' | The base URL for the API |
| cameraControllerConfig | OrbitControllerConfig | {} | Camera controller configuration |
| enableLoadingSpinner | boolean | true | Show loading spinner during model loads |
| enableLoadingBar | boolean | true | Show loading progress bar during model loads |
OrbitControllerConfig
Camera controller configuration options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| target | [number, number, number] | [0, 0, 0] | Camera orbit target point |
| flipUpDirection | boolean | true | Camera up direction (-Y if true, +Y if false) |
| horizontalAngle | number | 0 | Horizontal orbit angle (degrees) |
| verticalAngle | number | 90 | Vertical orbit angle (degrees) |
| distance | number | 20 | Distance from camera to target |
| minDistance | number | 0.1 | Minimum allowed camera distance |
| maxDistance | number | 100 | Maximum allowed camera distance |
| minVerticalAngle | number | 1 | Minimum vertical angle (degrees) |
| maxVerticalAngle | number | 179 | Maximum vertical angle (degrees) |
| rotationSpeed | number | 1 | Orbit rotation speed multiplier |
| zoomSpeed | number | 1 | Zoom speed multiplier |
| panSpeed | number | 1 | Pan speed multiplier |
| enableZoom | boolean | true | Enable zooming |
| enablePan | boolean | true | Enable panning |
| enableOrbit | boolean | true | Enable orbiting |
Position Constraints
Optional limits for target and camera positions:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| targetMinX, targetMaxX | number | undefined | Target X-axis limits |
| targetMinY, targetMaxY | number | undefined | Target Y-axis limits |
| targetMinZ, targetMaxZ | number | undefined | Target Z-axis limits |
| positionMinY, positionMaxY | number | undefined | Camera Y-axis position limits |
Tracking model load progress
Every call to viewer.load(...) or viewer.loadManaged(...) emits a consistent set of lifecycle events:
loadstart(once) – a new load beginsloadprogress(0..many) – incremental progress (progressin [0, 1); it will never be exactly 1.0)loadcomplete– load finished successfullyloaderror– load failedloadcancel– load was cancelled by a new one or unload
All events include a monotonically increasing requestId. Use it to ignore stale events if needed.source is the URL (or 'memory' for in‑memory data).progress is a float; convert to percent with progress * 100.
Simple logging example:
const viewer = new MirajViewer('viewerContainer');
viewer.addListener('loadstart', (e) => {
console.log(`Loading started (#${e.requestId}): ${e.source}`);
});
viewer.addListener('loadprogress', (e) => {
console.log(`Progress (#${e.requestId}): ${(e.progress * 100).toFixed(1)}%`);
});
viewer.addListener('loadcomplete', (e) => {
console.log(`Completed (#${e.requestId}) – splats: ${e.totalSplats ?? 'n/a'}`);
});
viewer.addListener('loaderror', (e) => {
console.error(`Error (#${e.requestId}):`, e.error);
});
viewer.addListener('loadcancel', (e) => {
console.log(`Cancelled (#${e.requestId})`);
});
// Start a load (this may cancel an earlier one)
viewer.load('/example_splat.ply', FileFormat.PLY);Notes:
- Calling
viewer.load(...)while another load is active triggersloadcancelfor the previous one before the newloadstart. - For managed models (
loadManaged), markers and camera options are applied first; the lifecycle events cover the actual file download & parse phase. loadprogressstops before 1.0; final completion is signaled byloadcomplete.
Working with Markers
Markers are lightweight HTML elements anchored to 3D positions. They always face the screen and auto‑reposition each render.
Adding a marker
const marker = viewer.addMarker({
position: [0, 0, 0],
userData: { tag: 'center' }
});
console.log(marker.data.id);Removing markers
viewer.removeMarker(marker); // by Marker instance
viewer.removeMarker(marker.data.id); // by idCurrent markers vs markerData
viewer.markers→ live Marker objects (with.elementand.data)viewer.markerData→ serialized data only (no DOM)- Assigning
viewer.markerData = [...]replaces all existing markers:
viewer.markerData = [
{ position: [1, 0, 0] },
{ position: [-1, 0, 0], userData: { label: 'Left' } }
];Styling
Each marker root element has class miraj-marker which you can override in your styles.
.miraj-marker {
width: 18px;
height: 18px;
background: #ff0;
border: 2px solid #333;
border-radius: 50%;
cursor: pointer;
}Or you can add classes to markers programmatically:
for (const m of viewer.markers) {
m.element.classList.add('my-marker');
}Click‑to‑add-marker example
viewer.on('click', (e) => {
const world = viewer.getWorldPositionAt(e.canvasPos);
if (!world) return;
if (e.leftClick) {
viewer.addMarker({ position: world });
} else if (viewer.markers.length) {
// Remove nearest (simple demo)
let nearest = viewer.markers[0];
let best = Infinity;
for (const m of viewer.markers) {
const [x,y,z] = m.data.position;
const dx = x - world[0], dy = y - world[1], dz = z - world[2];
const d = Math.hypot(dx, dy, dz);
if (d < best) { best = d; nearest = m; }
}
if (best < 2) viewer.removeMarker(nearest);
}
});Marker events
// This is a good place for customizing marker content
viewer.on('markeradd', (e) => {
console.log('Added', e.marker.data.id);
e.marker.element.title = e.marker.data.id;
event.marker.element.style.backgroundColor = 'yellow';
const idElement = document.createElement('p');
idElement.innerText = marker.data.id;
idElement.style.margin = '0';
idElement.style.marginTop = '-2px';
idElement.style.marginLeft = '25px';
marker.element.appendChild(idElement);
});
viewer.on('markerclick', (e) => {
// Toggle color
const el = e.marker.element;
el.style.backgroundColor = el.style.backgroundColor === 'yellow' ? 'blue' : 'yellow';
});
viewer.on('markerpointerenter', (e) => {
e.marker.element.classList.add('miraj-marker--pulse');
});
viewer.on('markerpointerleave', (e) => {
e.marker.element.classList.remove('miraj-marker--pulse');
});
viewer.on('markerremove', (e) => {
console.log('Removed', e.markerId);
});Event summary:
- markeradd – a marker was created
- markerremove – a marker was removed
- markerclick – user clicked a marker
- markerpointerenter / markerpointerleave – hover transitions
Replacing all markers after a managed load
Managed loads set markers from the server. To override:
await viewer.loadManaged('modelId');
viewer.markerData = []; // clear
viewer.addMarker({ position: [0,0,0] });Tip:
Call viewer.markerData to snapshot current markers (e.g. to persist) then restore later by assigning it back.
Tips and tricks
Enable / disable user interactions
You can toggle zoom, pan, and orbit separately at runtime:
viewer.updateConfig({
cameraControllerConfig: {
enableZoom: false,
enablePan: true,
enableOrbit: true,
}
});
// Re‑enable all
viewer.updateConfig({
cameraControllerConfig: {
enableZoom: true,
enablePan: true,
enableOrbit: true,
}
});Keep camera above the ground
Prevent the camera from dipping below an imaginary ground plane (y = 0):
viewer.updateConfig({
cameraControllerConfig: {
// never go below ground (up is negative Y, so we limit on MaxY, not MinY)
positionMaxY: 0,
}
});Keep the model in view (limit orbit target)
Clamp the orbit target so users cannot drag the model out of frame (values need to be adjusted per model):
viewer.updateConfig({
cameraControllerConfig: {
targetMinX: -5,
targetMaxX: 5,
targetMinY: 0,
targetMaxY: 4,
targetMinZ: -6,
targetMaxZ: 6
}
});Limit zoom distance so camera isn't too close/far
viewer.updateConfig({
cameraControllerConfig: {
minDistance: 20,
maxDistance: 50
}
});Combining everything
viewer.updateConfig({
cameraControllerConfig: {
enableZoom: true,
enablePan: true,
enableOrbit: true,
positionMinY: 0,
targetMinX: -5, targetMaxX: 5,
targetMinY: 0, targetMaxY: 4,
targetMinZ: -6, targetMaxZ: 6,
minDistance: 20, maxDistance: 50
}
});Reminder:
If you use loadManaged, the server can override cameraControllerConfig. Apply these constraints after loadManaged resolves viewer.loadManaged(...).then(() => { viewer.updateConfig(...); });
Programmatic Camera Controls
Beyond user input, you can drive the camera directly through viewer.controller (an OrbitController instance).
controller.orbitBy(dhDeg, dvDeg); // relative orbit in degrees
controller.setOrbit(horizontal?, vertical?); // absolute angles (omit to keep)
controller.panBy(dx, dy, dz); // world-space pan
controller.panByScreen(dxPx, dyPx); // screen-space pan (pixels, like drag)
controller.zoomBy(factor); // factor > 1 zooms in (closer)
controller.setDistance(distance); // absolute distance (clamped)
controller.setTarget([x,y,z]); // move orbit centerExamples
// Orbit right by 10°
viewer.controller.orbitBy(10, 0);
// Orbit up by 10°
viewer.controller.orbitBy(0, 10);
// Look from 45° horizontal, 60° vertical
viewer.controller.setOrbit(45, 60);
// Only change vertical angle
viewer.controller.setOrbit(undefined, 30);Pan screen-style (e.g. arrow keys):
window.addEventListener('keydown', e => {
const stepPx = 40;
if (e.key === 'ArrowLeft') viewer.controller.panByScreen(-stepPx, 0);
if (e.key === 'ArrowRight') viewer.controller.panByScreen(stepPx, 0);
if (e.key === 'ArrowUp') viewer.controller.panByScreen(0, -stepPx);
if (e.key === 'ArrowDown') viewer.controller.panByScreen(0, stepPx);
});Constraint‑Aware Controls (using can* Queries)
Each mutating method has a corresponding predicate so you can:
- Disable UI buttons
- Provide proper affordances when limits reached
Query helpers:
controller.canOrbitBy(dh, dv);
controller.canSetOrbit(h?, v?);
controller.canPanBy(dx, dy, dz);
controller.canPanByScreen(dxPx, dyPx);
controller.canZoomBy(factor);
controller.canSetDistance(distance);
controller.canSetTarget([x,y,z]);Example – enabling / disabling toolbar buttons:
function updateButtons() {
btnOrbitLeft.disabled = !c.canOrbitBy(-10, 0);
btnOrbitRight.disabled = !c.canOrbitBy(10, 0);
btnOrbitUp.disabled = !c.canOrbitBy(0, -10);
btnOrbitDown.disabled = !c.canOrbitBy(0, 10);
btnZoomIn.disabled = !c.canZoomBy(1.2);
btnZoomOut.disabled = !c.canZoomBy(0.8);
btnPanLeft.disabled = !c.canPanByScreen(-50, 0);
btnPanRight.disabled = !c.canPanByScreen(50, 0);
btnPanUp.disabled = !c.canPanByScreen(0, -50);
btnPanDown.disabled = !c.canPanByScreen(0, 50);
}
const c = viewer.controller;
const allButtons = document.querySelectorAll('.camera-btn');
allButtons.forEach(b => b.addEventListener('click', () => {
switch (b.id) {
case 'orbit-left': c.orbitBy(-10, 0); break;
case 'orbit-right': c.orbitBy(10, 0); break;
case 'orbit-up': c.orbitBy(0, -10); break;
case 'orbit-down': c.orbitBy(0, 10); break;
case 'zoom-in': c.zoomBy(1.2); break;
case 'zoom-out': c.zoomBy(0.8); break;
case 'pan-left': c.panByScreen(-60,0); break;
case 'pan-right': c.panByScreen(60,0); break;
case 'pan-up': c.panByScreen(0,-60); break;
case 'pan-down': c.panByScreen(0,60); break;
}
}));
viewer.camera.on('change', updateButtons);
updateButtons();