Message APINPM Package/docs/workflows/bim/react-setup

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.

1. Create And Install

Create a React and TypeScript application, then install the NPM SDK. Provide a Canvas-reachable URL for a supported 3D model.

bash
npm create vite@latest bim-viewer-react -- --template react-ts
cd bim-viewer-react
npm install @rasterex/viewer
npm run dev
1. Create And Install

2. File Structure

Match the reference workspace responsibilities: application shell, model rail, grouped toolbar, quick controls, Viewer host, and inspector are separate host UI components.

text
src/
  App.tsx
  App.css
  config/
    bimConfig.ts
  hooks/
    useBimViewer.ts
  types/
    bim.ts
  components/
    BimFileRail.tsx
    BimToolbar.tsx
    BimQuickControl.tsx
    BimInspector.tsx
    ViewerHost.tsx
2. File Structure

3. types/bim.ts

Keep requested UI state separate from confirmed SDK state. The selected part uses only fields supplied by the documented selection event.

typescript
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>;
};
3. types/bim.ts

4. config/bimConfig.ts

The file rail can provide multiple models. This minimal example starts with one known 3D source and labels it as such in host state.

typescript
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,
};
4. config/bimConfig.ts

5. hooks/useBimViewer.ts

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.

tsx
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,
  };
}
5. hooks/useBimViewer.ts

6. App.tsx

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.

tsx
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>;
}
6. App.tsx

7. App.css

This layout preserves the reference hierarchy: fixed left rail, top grouped toolbar, central model workspace, and small anchored quick-control panels.

css
* { 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; } }
7. App.css

8. Verify The Reference-Style Workspace

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.