React BIM Setup
Build the NPM BIM workspace shown by the reference demo: file rail, grouped toolbar, quick controls, central model workspace, and selected-part inspector.
Create a React and TypeScript application, then install the NPM SDK. Provide a Canvas-reachable URL for a supported 3D model.
npm create vite@latest bim-viewer-react -- --template react-ts cd bim-viewer-react npm install @rasterex/viewer npm run dev
Match the reference workspace responsibilities: application shell, model rail, grouped toolbar, quick controls, Viewer host, and inspector are separate host UI components.
src/
App.tsx
App.css
config/
bimConfig.ts
hooks/
useBimViewer.ts
types/
bim.ts
components/
BimFileRail.tsx
BimToolbar.tsx
BimQuickControl.tsx
BimInspector.tsx
ViewerHost.tsxKeep requested UI state separate from confirmed SDK state. The selected part uses only fields supplied by the documented selection event.
export type BimStatus = 'opening' | 'ready' | 'pending' | 'error';
export type BimMode =
| 'part-select'
| 'markup-select'
| 'walkthrough'
| 'hide-parts'
| null;
export type SectionAxis = 'x' | 'y' | 'z';
export type BimVisualState = {
explodeDistance: number;
transparencyPercent: number;
sectionAxis: SectionAxis;
sectionOffset: number;
};
export type SelectedPart = {
name?: string;
properties?: Record<string, unknown>;
};The file rail can provide multiple models. This minimal example starts with one known 3D source and labels it as such in host state.
export const bimModels = [
{
id: 'coordination-model',
displayName: 'Coordination model.ifc',
url: import.meta.env.VITE_BIM_MODEL_URL as string,
is3D: true,
},
];
export const initialVisualState = {
explodeDistance: 0,
transparencyPercent: 0,
sectionAxis: 'z' as const,
sectionOffset: 0,
};Subscribe to selection events before opening controls. Each helper updates confirmed host state only after the NPM command result succeeds; reset does not clear selected-part state because reset is not a documented selection-clear event.
import { useCallback, useEffect, useRef, useState } from 'react';
import { createViewer, type RasterexViewer } from '@rasterex/viewer';
import { initialVisualState } from '../config/bimConfig';
import type { BimMode, BimStatus, BimVisualState, SectionAxis, SelectedPart } from '../types/bim';
type ModelSource = {
displayName: string;
is3D: boolean;
url: string;
};
export function useBimViewer(model: ModelSource | null) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<RasterexViewer | null>(null);
const [status, setStatus] = useState<BimStatus>('opening');
const [message, setMessage] = useState('Choose a 3D model.');
const [mode, setMode] = useState<BimMode>(null);
const [visuals, setVisuals] = useState<BimVisualState>(initialVisualState);
const [selectedPart, setSelectedPart] = useState<SelectedPart | null>(null);
useEffect(() => {
if (!hostRef.current || !model?.is3D) return;
let disposed = false;
const viewer = createViewer({ container: hostRef.current });
viewerRef.current = viewer;
const stopPartSelected = viewer.tools.threeD.on('partSelected', (event) => {
if (!disposed) {
setSelectedPart({ name: event.part?.name, properties: event.properties });
}
});
const stopSelectionCleared = viewer.tools.threeD.on('selectionCleared', () => {
if (!disposed) setSelectedPart(null);
});
async function openModel() {
setStatus('opening');
setMessage('Opening ' + model.displayName);
try {
await viewer.mount();
await viewer.ready();
await viewer.documents.open({ url: model.url, displayName: model.displayName });
if (!disposed) {
setStatus('ready');
setMessage('Model ready. Select a BIM tool.');
}
} catch (error) {
if (!disposed) {
setStatus('error');
setMessage(error instanceof Error ? error.message : 'Model could not be opened.');
}
}
}
void openModel();
return () => {
disposed = true;
stopPartSelected();
stopSelectionCleared();
viewer.destroy();
viewerRef.current = null;
};
}, [model]);
const run = useCallback(async (command: () => Promise<{ success: boolean; error?: string }>, successMessage: string) => {
if (!viewerRef.current || status !== 'ready') return false;
setStatus('pending');
try {
const result = await command();
if (!result.success) {
setStatus('error');
setMessage(result.error ?? '3D command failed.');
return false;
}
setStatus('ready');
setMessage(successMessage);
return true;
} catch (error) {
setStatus('error');
setMessage(error instanceof Error ? error.message : '3D command failed.');
return false;
}
}, [status]);
const setPartSelect = useCallback(async (enabled: boolean) => {
const applied = await run(
() => viewerRef.current!.tools.threeD.setSelect({ enabled, emitSelectionEvents: true }),
enabled ? 'Part selection enabled.' : 'Part selection disabled.'
);
if (applied) setMode(enabled ? 'part-select' : null);
}, [run]);
const setMarkupSelect = useCallback(async (enabled: boolean) => {
const applied = await run(
() => viewerRef.current!.tools.threeD.setMarkupSelect({ enabled }),
enabled ? 'Markup selection enabled.' : 'Markup selection disabled.'
);
if (applied) setMode(enabled ? 'markup-select' : null);
}, [run]);
const setWalkthrough = useCallback(async (enabled: boolean) => {
const applied = await run(
() => viewerRef.current!.tools.threeD.setWalkthrough({ enabled }),
enabled ? 'Walkthrough enabled.' : 'Walkthrough disabled.'
);
if (applied) setMode(enabled ? 'walkthrough' : null);
}, [run]);
const setHideParts = useCallback(async (enabled: boolean) => {
const applied = await run(
() => viewerRef.current!.tools.threeD.setHidePartsMode({ enabled }),
enabled ? 'Hide parts mode enabled.' : 'Hide parts mode disabled.'
);
if (applied) setMode(enabled ? 'hide-parts' : null);
}, [run]);
const setExplode = useCallback(async (distance: number) => {
const applied = await run(
() => viewerRef.current!.tools.threeD.setExplode({ enabled: distance > 0, distance }),
'Explode updated.'
);
if (applied) setVisuals((current) => ({ ...current, explodeDistance: distance }));
}, [run]);
const setTransparency = useCallback(async (valuePercent: number) => {
const applied = await run(
() => viewerRef.current!.tools.threeD.setTransparency({ enabled: valuePercent > 0, valuePercent }),
'Transparency updated.'
);
if (applied) setVisuals((current) => ({ ...current, transparencyPercent: valuePercent }));
}, [run]);
const setCrossSection = useCallback(async (axis: SectionAxis, offset: number) => {
const next = { ...visuals, sectionAxis: axis, sectionOffset: offset };
const applied = await run(
() => viewerRef.current!.tools.threeD.setCrossSection({
enabled: offset !== 0,
x: axis === 'x' ? offset : 0,
y: axis === 'y' ? offset : 0,
z: axis === 'z' ? offset : 0,
}),
'Cross section updated.'
);
if (applied) setVisuals(next);
}, [run, visuals]);
const resetModel = useCallback(async () => {
const applied = await run(() => viewerRef.current!.tools.threeD.resetModel(), 'Model reset.');
if (applied) {
setMode(null);
setVisuals(initialVisualState);
}
}, [run]);
return {
hostRef,
isReady: status === 'ready',
isPending: status === 'pending',
message,
mode,
selectedPart,
setCrossSection,
setExplode,
setHideParts,
setMarkupSelect,
setPartSelect,
setTransparency,
setWalkthrough,
resetModel,
visuals,
};
}Use the reference workspace structure: file rail at the left, a central Viewer with a grouped toolbar above it, quick controls attached to their toolbar trigger, and a side inspector.
import { useState } from 'react';
import './App.css';
import { bimModels } from './config/bimConfig';
import { useBimViewer } from './hooks/useBimViewer';
export default function App() {
const [model, setModel] = useState<(typeof bimModels)[number] | null>(null);
const [quickControl, setQuickControl] = useState<'explode' | 'transparency' | 'section' | null>(null);
const bim = useBimViewer(model);
const disabled = !bim.isReady || bim.isPending;
return <div className="bim-app">
<aside className="file-rail" aria-label="BIM files">
<p className="eyebrow">Your UI</p>
<h1>BIM collaboration</h1>
<nav aria-label="Project navigation"><button type="button">Files</button></nav>
{bimModels.map((item) => <article className={model?.id === item.id ? 'file active' : 'file'} key={item.id}>
<strong>{item.displayName}</strong>
<button type="button" onClick={() => setModel(item)}>Open</button>
</article>)}
<section className="inspector" aria-live="polite">
<strong>Selected part</strong>
<p>{bim.selectedPart?.name ?? 'No part selected.'}</p>
{bim.selectedPart?.properties ? <pre>{JSON.stringify(bim.selectedPart.properties, null, 2)}</pre> : null}
</section>
</aside>
<main className="workspace">
<header className="toolbar" aria-label="BIM toolbar">
<div className="toolbar-zone actions"><span>Model: {model?.displayName ?? 'None'}</span></div>
<div className="toolbar-zone tools">
<button type="button" disabled={disabled} aria-pressed={bim.mode === 'part-select'} onClick={() => void bim.setPartSelect(bim.mode !== 'part-select')}>Select part</button>
<button type="button" disabled={disabled} aria-pressed={bim.mode === 'markup-select'} onClick={() => void bim.setMarkupSelect(bim.mode !== 'markup-select')}>Markup select</button>
<button type="button" disabled={disabled} aria-pressed={bim.mode === 'hide-parts'} onClick={() => void bim.setHideParts(bim.mode !== 'hide-parts')}>Hide parts</button>
<button type="button" disabled={disabled} aria-expanded={quickControl === 'explode'} onClick={() => setQuickControl('explode')}>Explode</button>
<button type="button" disabled={disabled} aria-expanded={quickControl === 'section'} onClick={() => setQuickControl('section')}>Cross section</button>
<button type="button" disabled={disabled} aria-expanded={quickControl === 'transparency'} onClick={() => setQuickControl('transparency')}>Transparency</button>
</div>
<div className="toolbar-zone navigation">
<button type="button" disabled={disabled} aria-pressed={bim.mode === 'walkthrough'} onClick={() => void bim.setWalkthrough(bim.mode !== 'walkthrough')}>Walkthrough</button>
<button type="button" disabled={disabled} onClick={() => void bim.resetModel()}>Reset</button>
</div>
</header>
{quickControl === 'explode' ? <section className="quick-control" role="dialog" aria-label="Explode control">
<label>Explode distance <input type="range" min="0" max="100" value={bim.visuals.explodeDistance} disabled={disabled} onChange={(event) => void bim.setExplode(Number(event.target.value))} /></label>
<output>{bim.visuals.explodeDistance}</output>
</section> : null}
{quickControl === 'transparency' ? <section className="quick-control" role="dialog" aria-label="Transparency control">
<label>Transparency percentage <input type="range" min="0" max="100" value={bim.visuals.transparencyPercent} disabled={disabled} onChange={(event) => void bim.setTransparency(Number(event.target.value))} /></label>
<output>{bim.visuals.transparencyPercent}%</output>
</section> : null}
{quickControl === 'section' ? <section className="quick-control" role="dialog" aria-label="Cross section control">
<div>{(['x', 'y', 'z'] as const).map((axis) => <button type="button" key={axis} aria-pressed={bim.visuals.sectionAxis === axis} onClick={() => void bim.setCrossSection(axis, bim.visuals.sectionOffset)}>{axis}</button>)}</div>
<label>Offset <input type="range" min="0" max="100" value={bim.visuals.sectionOffset} disabled={disabled} onChange={(event) => void bim.setCrossSection(bim.visuals.sectionAxis, Number(event.target.value))} /></label>
<output>{bim.visuals.sectionAxis}: {bim.visuals.sectionOffset}</output>
</section> : null}
<p className="status" role="status" aria-live="polite">{bim.message}</p>
<div ref={bim.hostRef} className="viewer-host" aria-label="BIM model viewer" />
</main>
</div>;
}This layout preserves the reference hierarchy: fixed left rail, top grouped toolbar, central model workspace, and small anchored quick-control panels.
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; }
.bim-app { display: grid; grid-template-columns: 16rem minmax(0, 1fr); min-height: 100vh; }
.file-rail { display: grid; align-content: start; gap: 1rem; padding: 1rem; color: #e0ede6; background: #1a3a2a; }
.eyebrow { margin: 0; color: #4ade80; font-size: .7rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.file, .inspector { display: grid; gap: .6rem; padding: .75rem; border: 1px solid rgba(255,255,255,.14); border-radius: .5rem; }
.file.active { border-color: #4ade80; }
.workspace { position: relative; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); min-height: 100vh; background: #f8fafc; }
.toolbar { display: grid; grid-template-columns: 1fr auto 1fr; gap: .5rem; padding: .5rem .75rem; border-bottom: 1px solid #d5dde7; }
.toolbar-zone { display: flex; flex-wrap: wrap; gap: .35rem; padding: .3rem; border: 1px solid #d5dde7; border-radius: .4rem; background: white; }
.tools { justify-content: center; }.navigation { justify-content: end; }
.quick-control { justify-self: center; z-index: 1; display: grid; gap: .5rem; width: min(22rem, calc(100% - 2rem)); margin: .5rem; padding: .75rem; border: 1px solid #d5dde7; border-radius: .4rem; background: white; box-shadow: 0 .75rem 2rem rgba(15,23,42,.16); }
.status { margin: 0; padding: .5rem .75rem; color: #475569; }.viewer-host { min-height: 32rem; }
.inspector pre { max-height: 16rem; overflow: auto; white-space: pre-wrap; }
@media (max-width: 760px) { .bim-app { grid-template-columns: 1fr; }.file-rail { grid-row: 2; }.toolbar { grid-template-columns: 1fr; }.tools, .navigation { justify-content: start; }.viewer-host { min-height: 60vh; } }Use the BIM verification page for the full acceptance list.
- A file selection does not enable the BIM toolbar until the model has opened.
- The three toolbar zones remain visible and readable at the supported desktop width.
- Quick controls announce their purpose and retain their last confirmed values after a failed command.
- The inspector renders only actual selected-part event fields.
- The Viewer remains the primary working area on small screens.
