diff --git a/package-lock.json b/package-lock.json index 35cba5c8..aa2e3df4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vim-web", - "version": "1.0.0-alpha.13", + "version": "1.0.0-beta.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vim-web", - "version": "1.0.0-alpha.13", + "version": "1.0.0-beta.2", "license": "MIT", "dependencies": { "@headless-tree/core": "^1.6.3", diff --git a/package.json b/package.json index 4d9c16a3..98ec4f4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vim-web", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "description": "WebGL and cloud-streaming 3D viewers for VIM files with BIM support", "type": "module", "files": [ diff --git a/src/customInspector.tsx b/src/customInspector.tsx new file mode 100644 index 00000000..9143211f --- /dev/null +++ b/src/customInspector.tsx @@ -0,0 +1,706 @@ +import React, { ChangeEvent, useEffect, useRef, useState } from 'react' +import * as VIM from './vim-web' + +const Webgl = VIM.React.Webgl +type ViewerApi = VIM.React.Webgl.ViewerApi +type IWebglVim = VIM.Core.Webgl.IWebglVim +type IElement = VIM.BIM.IElement +type VimDocument = VIM.BIM.VimDocument + +// The renderer's raw-object add/remove (present on the concrete Renderer but not +// the public IWebglRenderer type). We use it to add our own THREE meshes. +type RawSceneRenderer = { add(o: VIM.THREE.Object3D): void; remove(o: VIM.THREE.Object3D): void } + +// Shape of the internal merged submesh we read geometry from. Reaching into +// these internals is deliberate — the custom room mesh lives outside the +// viewer's tracking on purpose. +type MergedSubmeshLike = { merged: boolean; three: VIM.THREE.Mesh; meshStart: number; meshEnd: number } + +// Sample model used for the initial load. +const RESIDENCE_URL = 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' + +/** + * Custom project inspector. + * + * The built-in BIM tree exposes no API to control its structure or labels + * (only a visibility toggle), so this demo hides it (`ui.panelBimTree: false`) + * and renders its own tree in a left pane instead. The built-in BIM info panel + * is hidden too (`ui.panelBimInfo: false`) and replaced by a "BIM Inspector" + * section below the tree, populated from the current selection. + * + * The tree groups every geometry element by: + * Room > Category > Family > Type > Element + * + * Each level is just an extractor function over the BIM `IElement` (see LEVELS + * below) — reorder or swap them to change the hierarchy. Clicking a leaf + * selects and frames that element in the 3D scene. A single viewer is reused + * across loads: each load unloads the previous vim and loads the new one into + * the same viewer. + */ +export function CustomInspector () { + const viewerDiv = useRef(null) + const viewerRef = useRef(undefined) + const vimRef = useRef(undefined) + const unsubRef = useRef<(() => void) | undefined>(undefined) + // Self-owned THREE meshes visualizing selected rooms, keyed by room-volume + // element index. We render our own meshes rather than toggling the rooms' VIM + // elements because room geometry is opaque-authored and `isRoom` visibility is + // owned by the global showRooms flag — so the engine won't let us show rooms + // as transparent shells. Standalone meshes sidestep all of that: total control + // over material and visibility, untracked by the viewer. Multiple rooms can be + // shown at once (one entry per selected room row). + const roomMeshesRef = useRef>(new Map()) + const bimToken = useRef(0) // guards against out-of-order async BIM-info loads + // True while the inspector is pushing a selection into the viewer. The + // viewer fires onSelectionChanged in response; this flag lets the listener + // ignore that echo so an inspector click can't loop back into itself. + const applyingSelfRef = useRef(false) + + const fileInputRef = useRef(null) + const [fileName, setFileName] = useState() + const [loadError, setLoadError] = useState() + const [tree, setTree] = useState() + const [bimInfo, setBimInfo] = useState() + // The set of selected BIM element indices. Mirrored in a ref so click + // handlers can read the latest value without a stale closure. + const [selected, setSelected] = useState>(() => new Set()) + const selectedRef = useRef>(selected) + const updateSelected = (next: ReadonlySet) => { + selectedRef.current = next + setSelected(next) + } + + // Creates the single, long-lived viewer (once) and wires up selection sync + // and the room-toggle control-bar button. Subsequent loads reuse it. + const ensureViewer = async (): Promise => { + if (viewerRef.current) return viewerRef.current + + const div = viewerDiv.current + if (!div) return undefined + + // Hide the built-in BIM tree, its control-bar toggle, and the BIM info + // panel so this custom inspector visually replaces the stock UI. Ghost + // opacity is dialed down so hidden rooms nearly vanish and only our custom + // shells read as room geometry. + const viewer = await Webgl.createViewer(div, { + ui: { panelBimTree: false, miscProjectInspector: false, panelBimInfo: false }, + isolation: { ghostOpacity: 0.01 }, + }) + viewerRef.current = viewer + ;(globalThis as any).viewer = viewer // for testing in the browser console + + // Sync selections made in the viewport back into the tree highlight. We + // ignore echoes from our own inspector-driven selections (recursion guard) + // and never expand rows here — only update the highlight. + const selection = viewer.core.selection + unsubRef.current = selection.onSelectionChanged.sub(() => { + if (applyingSelfRef.current) return + const next = new Set() + for (const s of selection.getAll()) { + if (s.type === 'Element3D' && s.element !== undefined) next.add(s.element) + } + updateSelected(next) + loadBimInfo(firstOf(next)) + }) + + // Append a toggle button to the control bar for room geometry. The + // callbacks read live state, and `isOn` highlights the button while rooms + // are shown. Returning [...bar, …] keeps the built-in sections. + const showRooms = viewer.renderSettings.showRooms + const Icons = VIM.React.Icons + viewer.controlBar.customize((bar) => [ + ...bar, + { + id: 'custom-rooms', + buttons: [{ + id: 'toggle-rooms', + tip: () => (showRooms.get() ? 'Hide rooms' : 'Show rooms'), + isOn: () => showRooms.get(), + action: () => showRooms.set(!showRooms.get()), + icon: (options) => (showRooms.get() ? Icons.visible(options) : Icons.hidden(options)), + }], + }, + ]) + + return viewer + } + + // Loads a model into the shared viewer, replacing whatever was loaded before, + // then rebuilds the inspector tree. `source` is a url or a buffer + // (RequestSource), matching viewer.load(). + const loadModel = async (source: Parameters[0], message: string) => { + setTree(undefined) + updateSelected(new Set()) + setBimInfo(undefined) + setLoadError(undefined) + + const viewer = await ensureViewer() + if (!viewer) return + + // Drop our self-owned room meshes (not tied to any vim, so unload won't). + clearRoomMeshes(viewer) + + // Unload any previously loaded model so only the new one remains. + for (const vim of [...viewer.core.vims]) { + viewer.unload(vim) + } + vimRef.current = undefined + + viewer.modal.loading({ progress: -1, mode: 'percent', message }) + try { + const vim = await viewer.load(source).getVim() + if (viewerRef.current !== viewer || !vim) return // unmounted mid-load + vimRef.current = vim + + viewer.framing.frameScene.call() + setTree(await buildInspectorTree(vim)) + } catch (err) { + // Surface the real underlying error. + const errorMessage = err instanceof Error ? err.message : String(err) + console.error('Failed to load VIM file:', err) + setLoadError(errorMessage) + } finally { + viewer.modal.loading(undefined) + } + } + + // Reads BIM parameters for the given element and groups them for display. + const loadBimInfo = async (elementIndex: number | undefined) => { + const token = ++bimToken.current + const vim = vimRef.current + const element = elementIndex !== undefined ? vim?.getElementFromIndex(elementIndex) : undefined + if (!element) { setBimInfo(undefined); return } + + const [bimElement, params] = await Promise.all([element.getBimElement(), element.getBimParameters()]) + if (token !== bimToken.current) return // a newer selection superseded this load + + const groups: BimGroup[] = [] + const byGroup = new Map() + for (const p of params) { + if (!p.name) continue + const group = p.group && p.group.length > 0 ? p.group : 'Other' + let rows = byGroup.get(group) + if (!rows) { rows = []; byGroup.set(group, rows); groups.push({ group, rows }) } + rows.push({ name: p.name, value: p.value ?? '' }) + } + const name = bimElement.name && bimElement.name.length > 0 ? bimElement.name : 'Element' + setBimInfo({ title: `${name} [${bimElement.id ?? elementIndex}]`, groups }) + } + + useEffect(() => { + loadModel({ url: RESIDENCE_URL }, 'Loading model…') + return () => { + unsubRef.current?.() + if (viewerRef.current) clearRoomMeshes(viewerRef.current) + viewerRef.current?.dispose() + viewerRef.current = undefined + } + }, []) + + // Read the chosen .vim file from disk and load it as a buffer. + const handleFile = (e: ChangeEvent) => { + const input = e.target + const file = input.files?.[0] + if (!file) return + setFileName(file.name) + const reader = new FileReader() + reader.onload = (event) => { + const buffer = event.target?.result + if (buffer instanceof ArrayBuffer) loadModel({ buffer }, 'Loading from disk') + } + reader.readAsArrayBuffer(file) + // Clear the value so picking the same file again still fires `change`. We + // show the name ourselves (below), so the native "No file chosen" label is + // hidden and never out of sync. + input.value = '' + } + + // Removes and disposes the room mesh for the given room element, if shown. + const removeRoomMesh = (viewer: ViewerApi, roomElement: number) => { + const mesh = roomMeshesRef.current.get(roomElement) + if (!mesh) return + // `add`/`remove` for raw THREE objects exist on the concrete renderer but + // aren't on the public IWebglRenderer type — cast to reach them. + ;(viewer.core.renderer as unknown as RawSceneRenderer).remove(mesh) + mesh.geometry.dispose() + ;(mesh.material as VIM.THREE.Material).dispose() + roomMeshesRef.current.delete(roomElement) + } + + // Removes and disposes every shown room mesh. + const clearRoomMeshes = (viewer: ViewerApi) => { + for (const roomElement of [...roomMeshesRef.current.keys()]) { + removeRoomMesh(viewer, roomElement) + } + } + + // Visualizes a room as our own transparent THREE mesh, built from the room's + // actual geometry and added straight into the render scene. Untracked by the + // viewer, so its material and visibility are entirely ours to control. No-op + // if the room is already shown. + const addRoomMesh = (viewer: ViewerApi, vim: IWebglVim, roomElement: number) => { + if (roomMeshesRef.current.has(roomElement)) return + + const el = vim.getElementFromIndex(roomElement) + const geometry = el ? buildRoomGeometry(el) : undefined + if (!geometry) return + + // Unlit material — the viewer's scene has no THREE lights (it uses custom + // shaders), so a lit material would render black. depthWrite off + double + // side gives a clean translucent shell you can see the contents through. + const material = new VIM.THREE.MeshBasicMaterial({ + color: 0x4aa3ff, + transparent: true, + opacity: 0.25, + depthWrite: false, + side: VIM.THREE.DoubleSide, + }) + const mesh = new VIM.THREE.Mesh(geometry, material) + // Align with the model: the extracted positions are in vim-local space; the + // vim's world transform lives on vim.scene.matrix. + mesh.matrixAutoUpdate = false + mesh.matrix.copy(vim.scene.matrix) + + ;(viewer.core.renderer as unknown as RawSceneRenderer).add(mesh) + roomMeshesRef.current.set(roomElement, mesh) + } + + // Syncs shown room meshes to the selection after a row click. A plain click + // replaces the selection, so we clear all shells and show the clicked room (if + // any). A ctrl/cmd-click only flips the clicked room row, matching how the + // underlying selection toggles — so every selected room row keeps its shell. + const updateRoomMeshes = (item: TreeItem, toggle: boolean, next: ReadonlySet) => { + const viewer = viewerRef.current + const vim = vimRef.current + if (!viewer || !vim) return + + const roomElement = item.kind === 'group' && item.level === 'Room' ? item.roomElement : undefined + + if (toggle) { + if (roomElement !== undefined) { + const stillSelected = item.elements.every((i) => next.has(i)) + if (stillSelected) addRoomMesh(viewer, vim, roomElement) + else removeRoomMesh(viewer, roomElement) + } + } else { + clearRoomMeshes(viewer) + if (roomElement !== undefined) addRoomMesh(viewer, vim, roomElement) + } + + viewer.core.renderer.requestRender() + } + + // Selects every element under a clicked row. A plain click replaces the + // selection; ctrl/cmd-click toggles those elements in or out of it. Selecting + // a Room row also reveals that room's (transparent) volume geometry. + const onSelect = (item: TreeItem, toggle: boolean) => { + const viewer = viewerRef.current + const vim = vimRef.current + const elements = item.elements + if (!viewer || !vim || elements.length === 0) return + + const objects = elements + .map((i) => vim.getElementFromIndex(i)) + .filter((e): e is NonNullable => !!e) + if (objects.length === 0) return + + const current = selectedRef.current + const next = new Set(current) + + // Guard the viewer mutation so the resulting onSelectionChanged echo is + // ignored by our listener. We update the highlight ourselves below. + applyingSelfRef.current = true + try { + if (toggle) { + const allSelected = elements.every((i) => current.has(i)) + if (allSelected) { + viewer.core.selection.remove(objects) + elements.forEach((i) => next.delete(i)) + } else { + viewer.core.selection.add(objects) + elements.forEach((i) => next.add(i)) + } + } else { + viewer.core.selection.select(objects) + next.clear() + elements.forEach((i) => next.add(i)) + } + } finally { + applyingSelfRef.current = false + } + + updateSelected(next) + loadBimInfo(firstOf(next)) + + // Show/hide the transparent shells for selected room rows. + updateRoomMeshes(item, toggle, next) + + // Re-center the orbit pivot on the centroid of the new selection. setTarget + // moves only the orbit target, not the camera. getBoundingBox reflects the + // viewer selection we just mutated; its center is the centroid. + viewer.core.selection.getBoundingBox().then((box) => { + if (!box || viewerRef.current !== viewer) return + const centroid = box.min.clone().add(box.max).multiplyScalar(0.5) + viewer.core.camera.snap().setTarget(centroid) + }) + } + + return ( + // Fill the page's content area. The parent is `position: relative`, so + // absolute inset-0 sizes us reliably (a percentage height would collapse). +
+ {/* Custom inspector pane: header, tree, then BIM info stacked vertically. */} +
+
+

Project Inspector

+ + + {fileName && ( +
+ {fileName} +
+ )} +
+ +
+ {loadError &&
Load failed: {loadError}
} + {!tree && !loadError &&
Loading model…
} + {tree?.map((item, i) => ( + + ))} +
+ +
+

BIM Inspector

+ {bimInfo ? :
Select an element to inspect.
} +
+
+ + {/* 3D viewer fills the rest. + createViewer() forces the element it's given to `position: absolute` + and adds the `vim-component` class (which sets `inset: 0`), so it + fills its nearest positioned ancestor. We give it a `flex: 1`, + `position: relative` wrapper so it fills only this region — not the + pane. Passing the bare flex item instead would let it cover the pane. */} +
+
+
+
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Tree data */ +/* ------------------------------------------------------------------ */ + +// `elements` holds every BIM element index under a node, so a click can select +// the whole hierarchy. For a leaf it's just its own element. +type Leaf = { kind: 'leaf'; label: string; elements: number[] } +type Group = { + kind: 'group'; label: string; level: string; count: number + children: TreeItem[]; elements: number[] + // For Room-level rows: the element index of the room's own volume geometry + // (from the room table), so selecting the row can reveal it. Undefined for + // other levels or rooms with no volume element. + roomElement?: number +} +type TreeItem = Group | Leaf + +/** First value of a set, or undefined when empty. */ +function firstOf (set: ReadonlySet): number | undefined { + return set.values().next().value +} + +/** A grouping level: a display name plus how to extract its bucket key from an element. */ +type Level = { name: string; key: (e: IElement) => string } + +/** + * Builds a standalone THREE.BufferGeometry from an element's merged geometry, in + * the vim's local space. Walks each merged submesh's index range + * (`meshStart..meshEnd`) and copies only the referenced vertices, remapping + * indices so the result is compact. Returns undefined if the element has no + * merged geometry (e.g. instanced-only). Reads internal mesh fields by design. + */ +function buildRoomGeometry (element: VIM.Core.Webgl.IElement3D): VIM.THREE.BufferGeometry | undefined { + const meshes = (element as unknown as { _meshes?: MergedSubmeshLike[] })._meshes + if (!meshes?.length) return undefined + + const positions: number[] = [] + const indices: number[] = [] + for (const sub of meshes) { + if (!sub.merged) continue // instanced submeshes aren't handled here + const geom = sub.three.geometry + const pos = geom.getAttribute('position') + const index = geom.index + if (!pos || !index) continue + + const remap = new Map() + for (let i = sub.meshStart; i < sub.meshEnd; i++) { + const v = index.getX(i) + let nv = remap.get(v) + if (nv === undefined) { + nv = positions.length / 3 + remap.set(v, nv) + positions.push(pos.getX(v), pos.getY(v), pos.getZ(v)) + } + indices.push(nv) + } + } + if (indices.length === 0) return undefined + + const geometry = new VIM.THREE.BufferGeometry() + geometry.setAttribute('position', new VIM.THREE.Float32BufferAttribute(positions, 3)) + geometry.setIndex(indices) + return geometry +} + +/** + * Reads the BIM tables off the loaded vim and builds the nested tree. + * Only geometry elements are included so every leaf is selectable in the scene. + */ +async function buildInspectorTree (vim: IWebglVim): Promise { + const doc = vim.bim + if (!doc?.element) return [] + + const [bimElements, categories, rooms, typeNames] = await Promise.all([ + doc.element.getAll(), + doc.category?.getAll() ?? Promise.resolve([]), + doc.room?.getAll() ?? Promise.resolve([]), + buildFamilyTypeNameMap(doc), + ]) + + // Index BIM tables by their row index for O(1) lookups while grouping. + const categoryByIndex = new Map(categories.map((c) => [c.index, c])) + const roomByIndex = new Map(rooms.map((r) => [r.index, r])) + + // Label rooms by their room number; fall back to "" when absent. + const roomLabel = (e: IElement): string => { + if (e.roomIndex === undefined) return 'No Room' + return roomByIndex.get(e.roomIndex)?.number ?? '' + } + + const categoryLabel = (e: IElement): string => + (e.categoryIndex !== undefined ? categoryByIndex.get(e.categoryIndex)?.name : undefined) ?? 'Uncategorized' + + // Reorder / swap these to change the hierarchy. + const LEVELS: Level[] = [ + { name: 'Room', key: roomLabel }, + { name: 'Category', key: categoryLabel }, + { name: 'Family', key: (e) => e.familyName ?? '(No Family)' }, + { name: 'Type', key: (e) => typeNames.get(e.index) ?? '(No Type)' }, + ] + + const leafLabel = (e: IElement): string => { + const name = e.name && e.name.length > 0 ? e.name : 'Element' + return `${name} [${e.id ?? e.index}]` + } + + // Restrict to elements that actually have geometry — these are the ones a + // user can click and see in the scene. `getAllElements()` returns the 3D + // elements; we group their underlying BIM rows. + const geometryIndices = new Set(vim.getAllElements().map((e) => e.element)) + const elements = bimElements.filter((e) => geometryIndices.has(e.index)) + + const tree = groupElements(elements, LEVELS, 0, leafLabel) + + // The top level is the Room level (LEVELS[0]). Attach each room row's own + // volume-geometry element (from the room table, keyed by the same label the + // grouping used) so selecting the row can reveal that volume. + const roomElementByLabel = new Map() + for (const room of rooms) { + if (room.elementIndex === undefined) continue + const label = room.number ?? '' + if (!roomElementByLabel.has(label)) roomElementByLabel.set(label, room.elementIndex) + } + for (const node of tree) { + if (node.kind === 'group') node.roomElement = roomElementByLabel.get(node.label) + } + + return tree +} + +/** + * Maps each element index to its Revit *type* name (e.g. "Generic - 200mm"). + * + * An element's type isn't a direct field — it's reached through the family + * instance → family type → type element chain. This mirrors vim-web's own + * internal `getFamilyTypeNameMap` helper. Elements that aren't family instances + * (e.g. system-family walls/floors) won't appear here and fall back to "(No Type)". + */ +async function buildFamilyTypeNameMap (doc: VimDocument): Promise> { + const result = new Map() + const familyInstance = doc.familyInstance + const familyType = doc.familyType + const element = doc.element + if (!familyInstance || !familyType || !element) return result + + const [instanceElement, instanceFamilyType, typeElement, names] = await Promise.all([ + familyInstance.getAllElementIndex(), + familyInstance.getAllFamilyTypeIndex(), + familyType.getAllElementIndex(), + element.getAllName(), + ]) + if (!instanceElement || !instanceFamilyType || !typeElement || !names) return result + + for (let i = 0; i < instanceElement.length; i++) { + const ftIndex = instanceFamilyType[i] + if (ftIndex === undefined || ftIndex < 0) continue + const typeElementIndex = typeElement[ftIndex] + if (typeElementIndex === undefined || typeElementIndex < 0) continue + const name = names[typeElementIndex] + if (name) result.set(instanceElement[i], name) + } + return result +} + +/** Recursively groups elements by each level, producing leaves at the bottom. */ +function groupElements (elements: IElement[], levels: Level[], depth: number, leafLabel: (e: IElement) => string): TreeItem[] { + if (depth === levels.length) { + return elements + .map((e): Leaf => ({ kind: 'leaf', label: leafLabel(e), elements: [e.index] })) + .sort((a, b) => a.label.localeCompare(b.label)) + } + + const { name, key } = levels[depth] + const buckets = new Map() + for (const e of elements) { + const k = key(e) + const bucket = buckets.get(k) + if (bucket) bucket.push(e) + else buckets.set(k, [e]) + } + + return [...buckets.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([label, els]): Group => { + const children = groupElements(els, levels, depth + 1, leafLabel) + return { + kind: 'group', + label, + level: name, + count: els.length, + children, + elements: children.flatMap((c) => c.elements), + } + }) +} + +/* ------------------------------------------------------------------ */ +/* Tree rendering */ +/* ------------------------------------------------------------------ */ + +const INDENT_PX = 14 // horizontal step per tree level + +function TreeNode (props: { item: TreeItem; depth: number; selected: ReadonlySet; onSelect: (item: TreeItem, toggle: boolean) => void }) { + const { item, depth, selected, onSelect } = props + const [open, setOpen] = useState(false) // collapsed by default + const isGroup = item.kind === 'group' + + // A row is selected when every element under it is in the selection. + const isSelected = item.elements.length > 0 && item.elements.every((i) => selected.has(i)) + + const rowStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + paddingLeft: `${depth * INDENT_PX + 4}px`, + whiteSpace: 'nowrap', + borderRadius: 3, + background: isSelected ? '#cde4ff' : undefined, + fontWeight: isSelected ? 'bold' : undefined, + } + + // Chevron column toggles expansion only — clicking it never selects (its + // own handler stops propagation, and selection lives on the sibling region). + const chevron = ( + { e.stopPropagation(); setOpen((o) => !o) } : undefined} + style={{ + flexShrink: 0, + width: '1.4rem', + textAlign: 'center', + fontSize: '1.05rem', + lineHeight: 1, + color: '#666', + cursor: isGroup ? 'pointer' : 'default', + userSelect: 'none', + }} + > + {isGroup ? (open ? '▾' : '▸') : ''} + + ) + + return ( +
+
+ {chevron} + {/* Selection target: everything to the right of the chevron. */} + onSelect(item, e.ctrlKey || e.metaKey)} + style={{ flex: 1, cursor: 'pointer', padding: '2px 4px 2px 0' }} + > + {isGroup + ? <>{item.label} ({item.count}) + : <>▪ {item.label}} + +
+ {isGroup && open && item.children.map((child, i) => ( + + ))} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* BIM Inspector rendering */ +/* ------------------------------------------------------------------ */ + +type BimRow = { name: string; value: string } +type BimGroup = { group: string; rows: BimRow[] } +type BimInfo = { title: string; groups: BimGroup[] } + +function BimInfoView (props: { info: BimInfo }) { + const { info } = props + return ( +
+
{info.title}
+ {info.groups.map((g, i) => ( +
+
{g.group}
+ {g.rows.map((r, j) => ( +
+ {r.name} + {r.value} +
+ ))} +
+ ))} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Styles */ +/* ------------------------------------------------------------------ */ + +const paneStyle: React.CSSProperties = { + width: '320px', + flexShrink: 0, + display: 'flex', + flexDirection: 'column', + borderRight: '1px solid #ccc', + background: '#fff', + fontFamily: "'Roboto', sans-serif", + fontSize: '13px', + lineHeight: '1.4rem', +} + +// Each scrollable section takes half the remaining height. `minHeight: 0` is +// required for an `overflow: auto` child of a flex column to actually scroll. +const sectionStyle: React.CSSProperties = { + flex: '1 1 50%', + minHeight: 0, + overflow: 'auto', + padding: '0.5rem 0.75rem', +} diff --git a/src/main.tsx b/src/main.tsx index c42e9989..04a37c33 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { RefObject, useEffect, useRef, ChangeEvent } from 'react' import { createRoot } from 'react-dom/client' import * as VIM from './vim-web' +import { CustomInspector } from './customInspector' type ViewerRef = VIM.React.Webgl.ViewerApi | VIM.React.Ultra.ViewerApi @@ -10,7 +11,15 @@ function isWebglViewer (viewer: ViewerRef): viewer is VIM.React.Webgl.ViewerApi const root = createRoot(document.getElementById('root')!) -root.render() +// Switch dev pages via the URL: `/?page=inspector` (or any path containing +// "inspector") renders the CustomInspector regression test for the +// RenderScene.removeScene crash; otherwise the default viewer demo loads. +const params = new URLSearchParams(window.location.search) +const isInspector = + params.get('page') === 'inspector' || + window.location.pathname.includes('inspector') + +root.render(isInspector ? : ) function App() { const div = useRef(null) diff --git a/src/vim-web/core-viewers/webgl/loader/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index e7ed8e23..006b730d 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -259,6 +259,11 @@ export class Scene implements IScene { */ dispose () { this.clear() + // clear() leaves the (now empty) scene registered in the renderer so it's + // ready to receive new geometry. On dispose we want it gone entirely — + // otherwise an empty scene with an undefined bounding box lingers in the + // renderer's scene list and breaks later bounding-box recomputes. + this._renderer?.remove(this) this._renderer = null } } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts index acf30eac..e6d8f78f 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts @@ -194,13 +194,17 @@ export class RenderScene { this.threeScene.remove(scene.meshes[i].mesh) } - // Recompute bounding box from remaining scenes - const remainingScenes = this._vimScenesById.filter((s): s is Scene => s !== undefined) + // Recompute bounding box from remaining scenes. A scene's box can be + // undefined when its geometry isn't built yet (e.g. another scene is + // mid-clear() during a sequential load), so filter those out before the + // union — reducing over an undefined box would throw. + const boxes = this._vimScenesById + .filter((s): s is Scene => s !== undefined) + .map((s) => s.getBoundingBox()) + .filter((b): b is THREE.Box3 => b !== undefined) this._boundingBox = - remainingScenes.length > 0 - ? remainingScenes - .map((s) => s.getBoundingBox()) - .reduce((b1, b2) => b1.union(b2)) + boxes.length > 0 + ? boxes.reduce((b1, b2) => b1.union(b2)) : undefined } } diff --git a/src/vim-web/react-viewers/errors/errors.ts b/src/vim-web/react-viewers/errors/errors.ts index e0b62b75..58727e94 100644 --- a/src/vim-web/react-viewers/errors/errors.ts +++ b/src/vim-web/react-viewers/errors/errors.ts @@ -1,3 +1,5 @@ +export { webglFileError } from './webglFileError' + export { fileOpeningError } from '../ultra/errors/fileOpeningError' export { serverFileDownloadingError } from '../ultra/errors/serverFileDownloadingError' export { serverFileLoadingError } from '../ultra/errors/fileLoadingError' diff --git a/src/vim-web/react-viewers/errors/webglFileError.tsx b/src/vim-web/react-viewers/errors/webglFileError.tsx new file mode 100644 index 00000000..ccbe56d9 --- /dev/null +++ b/src/vim-web/react-viewers/errors/webglFileError.tsx @@ -0,0 +1,37 @@ +import { MessageBoxProps } from '../panels/messageBox' +import * as style from './errorStyle' + +/** + * Error modal shown when a WebGL load fails. Surfaces the underlying error and + * the source so the cause isn't hidden. This is the WebGL counterpart to the + * Ultra error modals — using an Ultra modal here mislabels the failure. + */ +export function webglFileError (url: string | undefined, error?: string): MessageBoxProps { + return { + title: 'VIM File Error', + body: body(url, error), + footer: style.footer(), + canClose: true + } +} + +function body (url: string | undefined, error?: string): React.ReactElement { + return ( + <> + {style.mainText(<> + We encountered an error loading the VIM file. + )} + {style.subTitle('Details')} + {style.dotList([ + url ? style.bullet('Source:', url) : null, + error ? style.bullet('Error:', error) : null + ])} + {style.subTitle('Tips')} + {style.numList([ + 'Ensure the source points to a valid VIM file', + 'Check your network connection and access policies', + 'Reload the page' + ])} + + ) +} diff --git a/src/vim-web/react-viewers/state/sharedIsolation.ts b/src/vim-web/react-viewers/state/sharedIsolation.ts index a3744278..ebe6866a 100644 --- a/src/vim-web/react-viewers/state/sharedIsolation.ts +++ b/src/vim-web/react-viewers/state/sharedIsolation.ts @@ -109,8 +109,14 @@ export function useSharedIsolation(adapter: IIsolationAdapter) { visibility.set(adapter.computeVisibility()); }) + // Push the initial state into the adapter on mount. The StateRefs may hydrate + // from localStorage to a value that differs from the underlying system's + // independent default, and useOnChange only fires on later changes — so + // without this the persisted value shows in the UI but never reaches the + // material (e.g. a stored ghostOpacity that the ghost material never applies). useEffect(() => { adapter.showGhost(showGhost.get()); + adapter.setGhostOpacity(ghostOpacity.get()); }, []); useSubscribe(adapter.onVisibilityChange, () => onVisibilityChange.call()) diff --git a/src/vim-web/react-viewers/webgl/isolation.ts b/src/vim-web/react-viewers/webgl/isolation.ts index 4d697286..439b3201 100644 --- a/src/vim-web/react-viewers/webgl/isolation.ts +++ b/src/vim-web/react-viewers/webgl/isolation.ts @@ -1,3 +1,4 @@ +import { useRef } from 'react' import * as Core from '../../core-viewers' import { ISelectable } from '../../core-viewers/webgl' import { IIsolationAdapter, useSharedIsolation, VisibilityStatus } from '../state/sharedIsolation' @@ -5,6 +6,18 @@ import { IRenderSettingsAdapter, useRenderSettings } from '../state/renderSettin import { IsolationSettings } from '../webgl/settings' export function useWebglIsolation(viewer: Core.Webgl.Viewer, initialState?: IsolationSettings) { + // Seed the material with the configured ghost opacity once, before the + // isolation StateRefs initialize from it. A persisted localStorage value still + // takes precedence (the StateRef reads it first). Done here rather than in + // createWebglAdapters because that runs on every render. + const seeded = useRef(false) + if (!seeded.current) { + seeded.current = true + if (initialState?.ghostOpacity !== undefined) { + viewer.materials.ghostOpacity = initialState.ghostOpacity + } + } + const { isolationAdapter, renderSettingsAdapter } = createWebglAdapters(viewer, initialState) const isolation = useSharedIsolation(isolationAdapter) const renderSettings = useRenderSettings(renderSettingsAdapter) diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index 3e1ea92c..2f3a7b8c 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -2,7 +2,7 @@ * @module viw-webgl-react */ -import { serverFileDownloadingError } from '../errors/errors' +import { webglFileError } from '../errors/errors' import * as Core from '../../core-viewers' import { LoadRequest } from '../helpers/loadRequest' import { ModalApi } from '../panels/modal' @@ -73,7 +73,7 @@ export class ComponentLoader { * Event emitter for error notifications. */ onError (e: LoadingError) { - this._modal.current?.message(serverFileDownloadingError(e.url)) + this._modal.current?.message(webglFileError(e.url, e.error)) } /** diff --git a/src/vim-web/react-viewers/webgl/settings.ts b/src/vim-web/react-viewers/webgl/settings.ts index 0450458e..1ad3e3a8 100644 --- a/src/vim-web/react-viewers/webgl/settings.ts +++ b/src/vim-web/react-viewers/webgl/settings.ts @@ -21,6 +21,12 @@ export type IsolationSettings = { showGhost: boolean showTransparent: boolean showRooms: boolean + /** + * Initial ghost (hidden-element) opacity, 0-1. When omitted the material's + * built-in default is used. A persisted value from the settings panel (saved + * to localStorage) takes precedence over this. + */ + ghostOpacity?: number } export type SectionBoxSettings = {